返回 DeepSeek-Reasonix
history_catalog.go
根目录 / desktop / history_catalog.go
1 package main
2
3 import (
4 "errors"
5 "path/filepath"
6 "strings"
7 "time"
8
9 "reasonix/internal/agent"
10 "reasonix/internal/config"
11 "reasonix/internal/history"
12 "reasonix/internal/historycatalog"
13 "reasonix/internal/provider"
14 "reasonix/internal/sessioncatalog"
15 )
16
17 type HistorySessionPageRequest struct {
18 Scope string `json:"scope"`
19 WorkspaceRoot string `json:"workspaceRoot,omitempty"`
20 Status string `json:"status"`
21 TimeFilter string `json:"timeFilter"`
22 Query string `json:"query"`
23 Cursor string `json:"cursor"`
24 Limit int `json:"limit"`
25 }
26
27 type HistorySessionPage struct {
28 SnapshotID string `json:"snapshotId,omitempty"`
29 SnapshotExpiresAt int64 `json:"snapshotExpiresAt,omitempty"`
30 ReadError *ReadError `json:"readError,omitempty"`
31 Items []SessionMeta `json:"items"`
32 NextCursor string `json:"nextCursor"`
33 Revision uint64 `json:"revision"`
34 Partial bool `json:"partial"`
35 StaleCursor bool `json:"staleCursor"`
36 }
37
38 type HistorySearchRequest struct {
39 Query string `json:"query"`
40 Scope string `json:"scope"`
41 WorkspaceRoot string `json:"workspaceRoot,omitempty"`
42 Status string `json:"status"`
43 TimeFilter string `json:"timeFilter"`
44 Kinds []string `json:"kinds"`
45 ToolName string `json:"toolName"`
46 Cursor string `json:"cursor"`
47 Limit int `json:"limit"`
48 }
49
50 type HistorySearchHit struct {
51 PartIndex int `json:"partIndex,omitempty"`
52 ContentDigest string `json:"contentDigest,omitempty"`
53 SessionPath string `json:"sessionPath"`
54 SessionID string `json:"sessionId"`
55 Source string `json:"source"`
56 MessageIndex int `json:"messageIndex"`
57 Role string `json:"role"`
58 Kind string `json:"kind"`
59 ToolName string `json:"toolName,omitempty"`
60 Snippet string `json:"snippet"`
61 Score float64 `json:"score"`
62 SessionTitle string `json:"sessionTitle,omitempty"`
63 TopicTitle string `json:"topicTitle,omitempty"`
64 WorkspaceRoot string `json:"workspaceRoot,omitempty"`
65 LastActivityAt int64 `json:"lastActivityAt"`
66 Open bool `json:"open"`
67 Running bool `json:"running"`
68 Current bool `json:"current"`
69 }
70
71 type HistoryIndexStatus = historycatalog.Status
72
73 type HistoryIndexChangedV1 struct {
74 Revision uint64 `json:"revision"`
75 Indexed int64 `json:"indexed"`
76 Total int64 `json:"total"`
77 Pending int64 `json:"pending"`
78 Roots []string `json:"roots"`
79 Reason string `json:"reason"`
80 }
81
82 func (a *App) registerHistoryIndexEvents() {
83 history.RegisterCatalogObserver(func(status historycatalog.Status, roots []string, reason string) {
84 if roots == nil {
85 roots = []string{}
86 }
87 a.emitRuntimeEvent("history-index:changed-v1", HistoryIndexChangedV1{Revision: status.Revision,
88 Indexed: status.Indexed, Total: status.Total, Pending: status.Pending, Roots: roots, Reason: reason})
89 })
90 }
91
92 type HistorySearchPage struct {
93 SnapshotID string `json:"snapshotId,omitempty"`
94 SnapshotExpiresAt int64 `json:"snapshotExpiresAt,omitempty"`
95 ReadError *ReadError `json:"readError,omitempty"`
96 Items []HistorySearchHit `json:"items"`
97 NextCursor string `json:"nextCursor"`
98 Revision uint64 `json:"revision"`
99 Partial bool `json:"partial"`
100 StaleCursor bool `json:"staleCursor"`
101 Status HistoryIndexStatus `json:"status"`
102 }
103
104 type HistorySearchContextRequest struct {
105 ContentDigest string `json:"contentDigest,omitempty"`
106 SessionPath string `json:"sessionPath"`
107 MessageIndex int `json:"messageIndex"`
108 Before int `json:"before"`
109 After int `json:"after"`
110 }
111
112 type HistorySearchContextLine struct {
113 Index int `json:"index"`
114 Role string `json:"role"`
115 Text string `json:"text"`
116 }
117
118 func historyCatalogRoots(targets []sessioncatalog.DirectoryTarget) []historycatalog.Root {
119 roots := make([]historycatalog.Root, 0, len(targets)*2+1)
120 for _, target := range targets {
121 source := target.Scope
122 if source == "" {
123 source = "global"
124 }
125 root := historycatalog.Root{Path: target.Path, Source: source, Scope: target.Scope, WorkspaceRoot: target.WorkspaceRoot}
126 roots = append(roots, root)
127 root.Path = filepath.Join(target.Path, "subagents")
128 root.Subagents = true
129 roots = append(roots, root)
130 }
131 roots = append(roots, historycatalog.Root{Path: config.ArchiveDir(), Source: "archive", Scope: "global", Archive: true})
132 return roots
133 }
134
135 func sessionMetaFromCatalog(record sessioncatalog.SessionRecord, current, open bool) SessionMeta {
136 title := strings.TrimSpace(record.CustomTitle)
137 preview := record.Preview
138 if strings.TrimSpace(preview) == "" && record.TurnsState == sessioncatalog.TurnsUnknown {
139 preview = "History is being indexed — " + filepath.Base(record.Path)
140 }
141 recovered := record.Recovered || strings.TrimSpace(record.RecoveryDigest) != "" || isAutomaticRecoverySessionPath(record.Path)
142 // RecoveryCopy comes from the catalog projection, which re-proves coverage
143 // from real content at index time. History uses it for the dedicated
144 // recovery-copy group and safe bulk cleanup entry points.
145 return SessionMeta{Path: record.Path, Preview: preview, Title: title, Turns: record.Turns,
146 TurnsState: string(record.TurnsState), CreatedAt: record.CreatedAt, LastActivityAt: record.LastActivityAt,
147 ModTime: record.LastActivityAt, Current: current, Open: open, Scope: record.Scope,
148 WorkspaceRoot: record.WorkspaceRoot, TopicID: record.TopicID, TopicTitle: record.TopicTitle,
149 Recovered: recovered, RecoveryCopy: record.RecoveryCopy,
150 RecoveryGroupID: record.RecoveryGroupID, RecoveryRole: record.RecoveryRole,
151 RecoveryCanonical: record.RecoveryCanonical}
152 }
153
154 func (a *App) GetHistoryIndexStatus() HistoryIndexStatus {
155 if catalog := history.SharedCatalog(); catalog != nil {
156 return catalog.Status()
157 }
158 return HistoryIndexStatus{State: "opening", Pending: 1, Mode: "memory"}
159 }
160
161 func desktopHistoryText(messages []provider.Message, candidate historycatalog.Candidate) (string, bool) {
162 if candidate.MessageIndex < 0 || candidate.MessageIndex >= len(messages) {
163 return "", false
164 }
165 message := messages[candidate.MessageIndex]
166 switch candidate.Kind {
167 case "user_text", "assistant_text":
168 return message.Content, true
169 case "tool_input":
170 if candidate.PartIndex < 0 || candidate.PartIndex >= len(message.ToolCalls) {
171 return "", false
172 }
173 call := message.ToolCalls[candidate.PartIndex]
174 return strings.TrimSpace(call.Name + " " + call.Arguments), true
175 case "tool_error", "tool_output":
176 return strings.TrimSpace(message.Name + " " + message.Content), true
177 default:
178 return "", false
179 }
180 }
181
182 func historyTimeMatchesAt(timestamp int64, filter string, now time.Time) bool {
183 if strings.TrimSpace(filter) == "" || filter == "all" {
184 return true
185 }
186 startToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
187 value := time.UnixMilli(timestamp)
188 switch filter {
189 case "today":
190 return !value.Before(startToday)
191 case "yesterday":
192 return !value.Before(startToday.AddDate(0, 0, -1)) && value.Before(startToday)
193 case "older":
194 return value.Before(startToday.AddDate(0, 0, -1))
195 default:
196 return true
197 }
198 }
199
200 func (a *App) GetHistorySearchContext(req HistorySearchContextRequest) []HistorySearchContextLine {
201 var options history.Options
202 bestLength := -1
203 for _, root := range historyCatalogRoots(a.sessionCatalogTargets()) {
204 if !desktopHistoryPathWithin(req.SessionPath, root.Path) || len(root.Path) <= bestLength {
205 continue
206 }
207 bestLength = len(root.Path)
208 options = history.Options{}
209 switch {
210 case root.Archive:
211 options.ArchiveDir = root.Path
212 case root.Subagents:
213 options.SessionDir = filepath.Dir(root.Path)
214 default:
215 options.SessionDir = root.Path
216 }
217 }
218 if bestLength < 0 {
219 return []HistorySearchContextLine{}
220 }
221 if req.ContentDigest != "" {
222 messages, state, intact, err := agent.LoadSessionDisplayMessages(req.SessionPath)
223 if err != nil || !intact || state.DigestHex != req.ContentDigest {
224 return []HistorySearchContextLine{}
225 }
226 out := []HistorySearchContextLine{}
227 for i := max(0, req.MessageIndex-min(max(req.Before, 0), 20)); i < min(len(messages), req.MessageIndex+min(max(req.After, 0), 20)+1); i++ {
228 out = append(out, HistorySearchContextLine{Index: i, Role: string(messages[i].Role), Text: messages[i].Content})
229 }
230 return out
231 }
232 searcher := history.NewSearcher(options)
233 lines, err := searcher.Around(a.bootContext(), history.AroundRequest{SessionPath: req.SessionPath, MessageIndex: req.MessageIndex, Before: req.Before, After: req.After})
234 if err != nil {
235 return []HistorySearchContextLine{}
236 }
237 out := make([]HistorySearchContextLine, 0, len(lines))
238 for _, line := range lines {
239 role := ""
240 if fields := strings.Fields(line.Text); len(fields) > 1 {
241 role = strings.TrimSuffix(fields[1], "]")
242 }
243 out = append(out, HistorySearchContextLine{Index: line.Index, Role: role, Text: line.Text})
244 }
245 return out
246 }
247
248 func desktopHistoryPathWithin(path, root string) bool {
249 absPath, err := filepath.Abs(filepath.Clean(strings.TrimSpace(path)))
250 if err != nil {
251 return false
252 }
253 absRoot, err := filepath.Abs(filepath.Clean(strings.TrimSpace(root)))
254 if err != nil {
255 return false
256 }
257 rel, err := filepath.Rel(absRoot, absPath)
258 return err == nil && (rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))))
259 }
260
261 func (a *App) RebuildHistoryIndex() error {
262 if a == nil || a.shuttingDown.Load() {
263 return errors.New("application is shutting down")
264 }
265 return history.RebuildSharedCatalog(a.bootContext(), historyCatalogRoots(a.sessionCatalogTargets()))
266 }
267
267 lines GO