返回 DeepSeek-Reasonix
session_history_native_search.go
根目录 / desktop / session_history_native_search.go
1 package main
2
3 import (
4 "context"
5 "crypto/sha256"
6 "database/sql"
7 "encoding/base64"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "path/filepath"
12 "strings"
13 "unicode/utf8"
14
15 "reasonix/internal/config"
16 "reasonix/internal/projectiondb"
17 "reasonix/internal/session"
18 )
19
20 // The native preparation owns both databases. Search is admitted only after
21 // an explicit request and joins that owner's retirement barrier before close.
22 type nativeHistorySearch struct {
23 done chan struct{}
24 db *sql.DB
25 err error
26 }
27
28 func (p *nativeHistoryPreparation) closeSearch() {
29 p.searchMu.Lock()
30 search := p.search
31 p.searchMu.Unlock()
32 if search != nil {
33 <-search.done
34 if search.db != nil {
35 _ = search.db.Close()
36 }
37 }
38 }
39
40 func (a *App) prepareNativeHistorySearch(p *nativeHistoryPreparation) *nativeHistorySearch {
41 p.searchMu.Lock()
42 defer p.searchMu.Unlock()
43 if p.search != nil {
44 return p.search
45 }
46 search := &nativeHistorySearch{done: make(chan struct{})}
47 if err := p.ctx.Err(); err != nil {
48 search.err = err
49 close(search.done)
50 return search
51 }
52 p.search = search
53 go func() {
54 defer close(search.done)
55 release, err := a.historyMaintenance.Foreground(p.ctx)
56 if err != nil {
57 search.err = err
58 return
59 }
60 defer release()
61 search.db, search.err = openNativeSearch(p)
62 }()
63 return search
64 }
65
66 type nativeSearchCursor struct {
67 Version int `json:"v"`
68 Generation string `json:"g"`
69 Query string `json:"q"`
70 Before int64 `json:"b"`
71 }
72
73 func (a *App) searchNativeHistory(r *desktopHistoryReader, text, cursor string, limit int) (session.SearchHistoryPage, error) {
74 page := session.SearchHistoryPage{Hits: []session.SearchHistoryHit{}, Status: "failed"}
75 text = strings.TrimSpace(text)
76 if text == "" {
77 return page, errors.New("history search query is required")
78 }
79 p, err := nativeNavigationPager(r)
80 if err != nil {
81 page.Status = nativeNavigationStatus(r, err)
82 if page.Status != "failed" {
83 err = nil
84 }
85 return page, err
86 }
87 page.SnapshotSequence, page.CoverageSequence = nativeHistorySequence(p), nativeHistorySequence(p)
88 before := int64(p.Header.MessageCount)
89 digest := fmt.Sprintf("%x", sha256.Sum256([]byte(text)))
90 if cursor != "" {
91 var cut nativeSearchCursor
92 body, err := base64.RawURLEncoding.DecodeString(cursor)
93 if err != nil || json.Unmarshal(body, &cut) != nil || cut.Version != 1 || cut.Generation != r.native.key || cut.Query != digest || cut.Before <= 0 || cut.Before > before {
94 page.Status = "stale_cursor"
95 return page, nil
96 }
97 before = cut.Before
98 }
99 search := a.prepareNativeHistorySearch(r.native)
100 select {
101 case <-search.done:
102 if search.err != nil {
103 return page, search.err
104 }
105 default:
106 page.Status, page.CoverageSequence = "preparing", 0
107 return page, nil
108 }
109 if limit <= 0 {
110 limit = 50
111 }
112 limit = min(limit, 200)
113 var rows *sql.Rows
114 if utf8.RuneCountInString(text) >= 3 {
115 match := `"` + strings.ReplaceAll(text, `"`, `""`) + `"`
116 rows, err = search.db.QueryContext(r.ctx, `SELECT d.position,d.role,d.preview FROM documents_fts JOIN documents d ON d.position=documents_fts.rowid WHERE documents_fts MATCH ? AND instr(d.text,?)>0 AND d.position<? ORDER BY d.position DESC LIMIT ?`, match, text, before, limit+1)
117 } else {
118 rows, err = search.db.QueryContext(r.ctx, `SELECT position,role,preview FROM documents WHERE instr(text,?)>0 AND position<? ORDER BY position DESC LIMIT ?`, text, before, limit+1)
119 }
120 if err != nil {
121 return page, err
122 }
123 defer rows.Close()
124 for rows.Next() {
125 var hit session.SearchHistoryHit
126 if err := rows.Scan(&hit.Position, &hit.Role, &hit.Preview); err != nil {
127 return page, err
128 }
129 if len(page.Hits) == limit {
130 page.HasMore = true
131 break
132 }
133 hit.MessageID, hit.EventSequence = nativeHistoryEntryID(r, p, int(hit.Position)), page.SnapshotSequence
134 page.Hits = append(page.Hits, hit)
135 }
136 if err := rows.Err(); err != nil {
137 return page, err
138 }
139 if err := p.Validate(); err != nil {
140 return page, err
141 }
142 if page.HasMore {
143 body, err := json.Marshal(nativeSearchCursor{Version: 1, Generation: r.native.key, Query: digest, Before: page.Hits[len(page.Hits)-1].Position})
144 if err != nil {
145 return page, err
146 }
147 page.NextCursor = base64.RawURLEncoding.EncodeToString(body)
148 }
149 page.Status = "ready"
150 return page, nil
151 }
152
153 var nativeSearchMigrations = []projectiondb.Migration{{Version: 1, Apply: func(ctx context.Context, tx *sql.Tx) error {
154 _, err := tx.ExecContext(ctx, `CREATE TABLE metadata(key TEXT PRIMARY KEY,value TEXT NOT NULL);
155 CREATE TABLE documents(position INTEGER PRIMARY KEY,role TEXT NOT NULL,preview TEXT NOT NULL,text TEXT NOT NULL);
156 CREATE VIRTUAL TABLE documents_fts USING fts5(text,content='documents',content_rowid='position',tokenize='trigram');`)
157 return err
158 }}}
159
160 func openNativeSearch(p *nativeHistoryPreparation) (*sql.DB, error) {
161 root := config.CacheDir()
162 if root == "" {
163 return nil, errors.New("history cache unavailable")
164 }
165 // This versioned cache is disposable and never writes into the source's
166 // session directory. Older readers can ignore it without a migration.
167 opts := projectiondb.OpenOptions{Path: filepath.Join(root, "history-search-v1", p.cacheKey+".sqlite"), Migrations: nativeSearchMigrations, RequireDisk: true, MaxOpenConns: 1}
168 if handle, err := projectiondb.Open(p.ctx, opts); err == nil {
169 var generation string
170 err = handle.DB.QueryRowContext(p.ctx, `SELECT value FROM metadata WHERE key='complete'`).Scan(&generation)
171 if err == nil && generation == p.key {
172 if err := p.pager.Validate(); err == nil {
173 return handle.DB, nil
174 }
175 }
176 _ = handle.DB.Close()
177 }
178 if err := projectiondb.Rebuild(p.ctx, opts, func(ctx context.Context, db *sql.DB) error {
179 return buildNativeSearch(ctx, db, p)
180 }); err != nil {
181 return nil, err
182 }
183 handle, err := projectiondb.Open(p.ctx, opts)
184 if err != nil {
185 return nil, err
186 }
187 return handle.DB, nil
188 }
189
189 lines GO