返回 DeepSeek-Reasonix
history_catalog.go
根目录 / desktop / history_catalog.go
1 package main
2
3 import (
4 "encoding/base64"
5 "encoding/json"
6 "errors"
7 "path/filepath"
8 "strings"
9 "time"
10
11 "reasonix/internal/config"
12 "reasonix/internal/history"
13 "reasonix/internal/historycatalog"
14 "reasonix/internal/provider"
15 "reasonix/internal/sessioncatalog"
16 )
17
18 type HistorySessionPageRequest struct {
19 Scope string `json:"scope"`
20 WorkspaceRoot string `json:"workspaceRoot,omitempty"`
21 Status string `json:"status"`
22 TimeFilter string `json:"timeFilter"`
23 Query string `json:"query"`
24 Cursor string `json:"cursor"`
25 Limit int `json:"limit"`
26 }
27
28 type HistorySessionPage struct {
29 Items []SessionMeta `json:"items"`
30 NextCursor string `json:"nextCursor"`
31 Revision uint64 `json:"revision"`
32 Partial bool `json:"partial"`
33 StaleCursor bool `json:"staleCursor"`
34 }
35
36 type HistorySearchRequest struct {
37 Query string `json:"query"`
38 Scope string `json:"scope"`
39 WorkspaceRoot string `json:"workspaceRoot,omitempty"`
40 Status string `json:"status"`
41 TimeFilter string `json:"timeFilter"`
42 Kinds []string `json:"kinds"`
43 ToolName string `json:"toolName"`
44 Cursor string `json:"cursor"`
45 Limit int `json:"limit"`
46 }
47
48 type HistorySearchHit struct {
49 SessionPath string `json:"sessionPath"`
50 SessionID string `json:"sessionId"`
51 Source string `json:"source"`
52 MessageIndex int `json:"messageIndex"`
53 Role string `json:"role"`
54 Kind string `json:"kind"`
55 ToolName string `json:"toolName,omitempty"`
56 Snippet string `json:"snippet"`
57 Score float64 `json:"score"`
58 SessionTitle string `json:"sessionTitle,omitempty"`
59 TopicTitle string `json:"topicTitle,omitempty"`
60 WorkspaceRoot string `json:"workspaceRoot,omitempty"`
61 LastActivityAt int64 `json:"lastActivityAt"`
62 Open bool `json:"open"`
63 Running bool `json:"running"`
64 Current bool `json:"current"`
65 }
66
67 type HistoryIndexStatus = historycatalog.Status
68
69 type HistoryIndexChangedV1 struct {
70 Revision uint64 `json:"revision"`
71 Indexed int64 `json:"indexed"`
72 Total int64 `json:"total"`
73 Pending int64 `json:"pending"`
74 Roots []string `json:"roots"`
75 Reason string `json:"reason"`
76 }
77
78 func (a *App) registerHistoryIndexEvents() {
79 history.RegisterCatalogObserver(func(status historycatalog.Status, roots []string, reason string) {
80 if roots == nil {
81 roots = []string{}
82 }
83 a.emitRuntimeEvent("history-index:changed-v1", HistoryIndexChangedV1{Revision: status.Revision,
84 Indexed: status.Indexed, Total: status.Total, Pending: status.Pending, Roots: roots, Reason: reason})
85 })
86 }
87
88 type HistorySearchPage struct {
89 Items []HistorySearchHit `json:"items"`
90 NextCursor string `json:"nextCursor"`
91 Revision uint64 `json:"revision"`
92 Partial bool `json:"partial"`
93 StaleCursor bool `json:"staleCursor"`
94 Status HistoryIndexStatus `json:"status"`
95 }
96
97 type HistorySearchContextRequest struct {
98 SessionPath string `json:"sessionPath"`
99 MessageIndex int `json:"messageIndex"`
100 Before int `json:"before"`
101 After int `json:"after"`
102 }
103
104 type HistorySearchContextLine struct {
105 Index int `json:"index"`
106 Role string `json:"role"`
107 Text string `json:"text"`
108 }
109
110 type historySearchCursor struct {
111 Revision uint64 `json:"r"`
112 Rank float64 `json:"b"`
113 Score float64 `json:"s"`
114 Path string `json:"p"`
115 Message int `json:"m"`
116 Part int `json:"a"`
117 RowID int64 `json:"i"`
118 }
119
120 func historyCatalogRoots(targets []sessioncatalog.DirectoryTarget) []historycatalog.Root {
121 roots := make([]historycatalog.Root, 0, len(targets)*2+1)
122 for _, target := range targets {
123 source := target.Scope
124 if source == "" {
125 source = "global"
126 }
127 root := historycatalog.Root{Path: target.Path, Source: source, Scope: target.Scope, WorkspaceRoot: target.WorkspaceRoot}
128 roots = append(roots, root)
129 root.Path = filepath.Join(target.Path, "subagents")
130 root.Subagents = true
131 roots = append(roots, root)
132 }
133 roots = append(roots, historycatalog.Root{Path: config.ArchiveDir(), Source: "archive", Scope: "global", Archive: true})
134 return roots
135 }
136
137 func sessionMetaFromCatalog(record sessioncatalog.SessionRecord, current, open bool) SessionMeta {
138 title := strings.TrimSpace(record.CustomTitle)
139 preview := record.Preview
140 if strings.TrimSpace(preview) == "" && record.TurnsState == sessioncatalog.TurnsUnknown {
141 preview = "History is being indexed — " + filepath.Base(record.Path)
142 }
143 recovered := record.Recovered || strings.TrimSpace(record.RecoveryDigest) != "" || isAutomaticRecoverySessionPath(record.Path)
144 // RecoveryCopy comes from the catalog projection, which re-proves coverage
145 // from real content at index time. History uses it for the dedicated
146 // recovery-copy group and safe bulk cleanup entry points.
147 return SessionMeta{Path: record.Path, Preview: preview, Title: title, Turns: record.Turns,
148 TurnsState: string(record.TurnsState), CreatedAt: record.CreatedAt, LastActivityAt: record.LastActivityAt,
149 ModTime: record.LastActivityAt, Current: current, Open: open, Scope: record.Scope,
150 WorkspaceRoot: record.WorkspaceRoot, TopicID: record.TopicID, TopicTitle: record.TopicTitle,
151 Recovered: recovered, RecoveryCopy: record.RecoveryCopy,
152 RecoveryGroupID: record.RecoveryGroupID, RecoveryRole: record.RecoveryRole,
153 RecoveryCanonical: record.RecoveryCanonical}
154 }
155
156 func (a *App) ListHistorySessions(req HistorySessionPageRequest) HistorySessionPage {
157 out := HistorySessionPage{Items: []SessionMeta{}}
158 catalog := a.sessionCatalog.Load()
159 if catalog == nil {
160 out.Partial = true
161 return out
162 }
163 limit := req.Limit
164 if limit <= 0 {
165 limit = 50
166 }
167 if limit > 200 {
168 limit = 200
169 }
170 statusFilter := strings.TrimSpace(req.Status)
171 needRuntimeFilter := statusFilter == "open" || statusFilter == "current"
172 _, overlays := a.catalogRuntimeOverlays()
173 active := a.activeSessionPath(a.activeSessionDir())
174 cursor := req.Cursor
175 for {
176 page, err := catalog.ListSessions(a.bootContext(), sessioncatalog.SessionPageRequest{
177 Scope: req.Scope, WorkspaceRoot: req.WorkspaceRoot, Cursor: cursor, Limit: limit,
178 Query: req.Query, TimeFilter: req.TimeFilter,
179 })
180 if err != nil {
181 out.Partial = true
182 return out
183 }
184 out.Revision = page.Revision
185 if page.StaleCursor {
186 out.StaleCursor = true
187 return out
188 }
189 for i, record := range page.Items {
190 overlay := overlays[sessionRuntimeKey(record.Path)]
191 // Match frontend HistoryPanel: "open" means open-but-not-current.
192 if statusFilter == "open" && (!overlay.open || record.Path == active) {
193 continue
194 }
195 if statusFilter == "current" && record.Path != active {
196 continue
197 }
198 out.Items = append(out.Items, sessionMetaFromCatalog(record, record.Path == active, overlay.open))
199 if len(out.Items) == limit {
200 if i+1 < len(page.Items) || page.NextCursor != "" {
201 out.NextCursor = sessioncatalog.CursorAfter(page.Revision, record.LastActivityAt, record.Path)
202 }
203 status := catalog.Status()
204 out.Partial = status.State != sessioncatalog.StateReady || status.Indexed < status.Total
205 return out
206 }
207 }
208 if !needRuntimeFilter {
209 // No post-filter: a single catalog page is the whole result page.
210 out.NextCursor = page.NextCursor
211 break
212 }
213 if page.NextCursor == "" {
214 out.NextCursor = ""
215 break
216 }
217 cursor = page.NextCursor
218 }
219 status := catalog.Status()
220 out.Partial = status.State != sessioncatalog.StateReady || status.Indexed < status.Total
221 return out
222 }
223
224 func (a *App) GetHistoryIndexStatus() HistoryIndexStatus {
225 if catalog := history.SharedCatalog(); catalog != nil {
226 return catalog.Status()
227 }
228 return HistoryIndexStatus{State: "opening", Pending: 1, Mode: "memory"}
229 }
230
231 func decodeHistoryCursor(encoded string) (*historySearchCursor, error) {
232 if strings.TrimSpace(encoded) == "" {
233 return nil, nil
234 }
235 b, err := base64.RawURLEncoding.DecodeString(encoded)
236 if err != nil {
237 return nil, err
238 }
239 var cursor historySearchCursor
240 if err := json.Unmarshal(b, &cursor); err != nil || cursor.Path == "" {
241 return nil, errors.New("invalid history search cursor")
242 }
243 return &cursor, nil
244 }
245
246 func encodeHistoryCursor(cursor historySearchCursor) string {
247 b, _ := json.Marshal(cursor)
248 return base64.RawURLEncoding.EncodeToString(b)
249 }
250
251 func desktopHistoryText(messages []provider.Message, candidate historycatalog.Candidate) (string, bool) {
252 if candidate.MessageIndex < 0 || candidate.MessageIndex >= len(messages) {
253 return "", false
254 }
255 message := messages[candidate.MessageIndex]
256 switch candidate.Kind {
257 case "user_text", "assistant_text":
258 return message.Content, true
259 case "tool_input":
260 if candidate.PartIndex < 0 || candidate.PartIndex >= len(message.ToolCalls) {
261 return "", false
262 }
263 call := message.ToolCalls[candidate.PartIndex]
264 return strings.TrimSpace(call.Name + " " + call.Arguments), true
265 case "tool_error", "tool_output":
266 return strings.TrimSpace(message.Name + " " + message.Content), true
267 default:
268 return "", false
269 }
270 }
271
272 func (a *App) SearchHistoryContent(req HistorySearchRequest) HistorySearchPage {
273 status := a.GetHistoryIndexStatus()
274 out := HistorySearchPage{Items: []HistorySearchHit{}, Status: status, Revision: status.Revision,
275 Partial: status.State != "ready" || status.Pending > 0 || (status.Total > 0 && status.Indexed < status.Total)}
276 catalog := history.SharedCatalog()
277 if catalog == nil || strings.TrimSpace(req.Query) == "" {
278 return out
279 }
280 cursor, err := decodeHistoryCursor(req.Cursor)
281 if err != nil {
282 return out
283 }
284 if cursor != nil && cursor.Revision != status.Revision {
285 out.StaleCursor = true
286 return out
287 }
288 limit := req.Limit
289 if limit <= 0 {
290 limit = 50
291 }
292 if limit > 200 {
293 limit = 200
294 }
295 kinds := req.Kinds
296 if len(kinds) == 0 {
297 kinds = []string{"user_text", "assistant_text", "tool_input", "tool_error"}
298 }
299 var after *historycatalog.SearchCursor
300 if cursor != nil {
301 after = &historycatalog.SearchCursor{Rank: cursor.Rank, SessionPath: cursor.Path, MessageIndex: cursor.Message,
302 PartIndex: cursor.Part, RowID: cursor.RowID}
303 }
304 // Keep pulling FTS candidates until the filtered page is full.
305 items, nextCursor, lastErr := a.collectHistorySearchItems(req, catalog, kinds, historySearchRootFilter(a, req), after, limit, status.Revision)
306 if lastErr != "" {
307 out.Status.LastError = lastErr
308 return out
309 }
310 out.Items, out.NextCursor = items, nextCursor
311 return out
312 }
313
314 func historyTimeMatches(timestamp int64, filter string) bool {
315 if strings.TrimSpace(filter) == "" || filter == "all" {
316 return true
317 }
318 now := time.Now()
319 startToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
320 value := time.UnixMilli(timestamp)
321 switch filter {
322 case "today":
323 return !value.Before(startToday)
324 case "yesterday":
325 return !value.Before(startToday.AddDate(0, 0, -1)) && value.Before(startToday)
326 case "older":
327 return value.Before(startToday.AddDate(0, 0, -1))
328 default:
329 return true
330 }
331 }
332
333 func (a *App) GetHistorySearchContext(req HistorySearchContextRequest) []HistorySearchContextLine {
334 var options history.Options
335 bestLength := -1
336 for _, root := range historyCatalogRoots(a.sessionCatalogTargets()) {
337 if !desktopHistoryPathWithin(req.SessionPath, root.Path) || len(root.Path) <= bestLength {
338 continue
339 }
340 bestLength = len(root.Path)
341 options = history.Options{}
342 switch {
343 case root.Archive:
344 options.ArchiveDir = root.Path
345 case root.Subagents:
346 options.SessionDir = filepath.Dir(root.Path)
347 default:
348 options.SessionDir = root.Path
349 }
350 }
351 if bestLength < 0 {
352 return []HistorySearchContextLine{}
353 }
354 searcher := history.NewSearcher(options)
355 lines, err := searcher.Around(a.bootContext(), history.AroundRequest{SessionPath: req.SessionPath, MessageIndex: req.MessageIndex, Before: req.Before, After: req.After})
356 if err != nil {
357 return []HistorySearchContextLine{}
358 }
359 out := make([]HistorySearchContextLine, 0, len(lines))
360 for _, line := range lines {
361 role := ""
362 if fields := strings.Fields(line.Text); len(fields) > 1 {
363 role = strings.TrimSuffix(fields[1], "]")
364 }
365 out = append(out, HistorySearchContextLine{Index: line.Index, Role: role, Text: line.Text})
366 }
367 return out
368 }
369
370 func desktopHistoryPathWithin(path, root string) bool {
371 absPath, err := filepath.Abs(filepath.Clean(strings.TrimSpace(path)))
372 if err != nil {
373 return false
374 }
375 absRoot, err := filepath.Abs(filepath.Clean(strings.TrimSpace(root)))
376 if err != nil {
377 return false
378 }
379 rel, err := filepath.Rel(absRoot, absPath)
380 return err == nil && (rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))))
381 }
382
383 func (a *App) RebuildHistoryIndex() error {
384 if a == nil || a.shuttingDown.Load() {
385 return errors.New("application is shutting down")
386 }
387 return history.RebuildSharedCatalog(a.bootContext(), historyCatalogRoots(a.sessionCatalogTargets()))
388 }
389
389 lines GO