返回 DeepSeek-Reasonix
session_export.go
根目录 / desktop / session_export.go
1 package main
2
3 import (
4 "context"
5 "encoding/base64"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "image"
10 _ "image/jpeg"
11 _ "image/png"
12 "io"
13 "net/http"
14 "net/url"
15 "os"
16 "path/filepath"
17 "strconv"
18 "strings"
19 "sync"
20
21 "reasonix/internal/control"
22 "reasonix/internal/servecontract"
23 "reasonix/internal/session"
24 "reasonix/internal/sessionexport"
25 )
26
27 type SessionExportHandle struct {
28 ExportID string `json:"exportId"`
29 Snapshot session.ExportSnapshot `json:"snapshot"`
30 Format string `json:"format"`
31 }
32 type SessionExportChunk struct {
33 Data string `json:"data"`
34 NextOffset int64 `json:"nextOffset"`
35 Done bool `json:"done"`
36 }
37 type SessionExportPage struct {
38 Index int `json:"index"`
39 Offset int64 `json:"offset"`
40 Data string `json:"data"`
41 Done bool `json:"done"`
42 Width int `json:"width"`
43 Height int `json:"height"`
44 }
45 type SessionExportResult struct {
46 Paths []string `json:"paths"`
47 Records int `json:"records"`
48 Pages int `json:"pages"`
49 }
50 type sessionExportJob struct {
51 mu sync.Mutex
52 handle SessionExportHandle
53 ctx context.Context
54 cancel context.CancelFunc
55 dir, path string
56 workspaceRoot string
57 sourceHostID string
58 query *session.Query
59 controller *control.Controller
60 client *http.Client
61 base, route string
62 observation json.RawMessage
63 prepared bool
64 records, pages int
65 pageOffset int64
66 }
67
68 func (a *App) exportJob(id string) (*sessionExportJob, error) {
69 a.sessionExportMu.Lock()
70 defer a.sessionExportMu.Unlock()
71 job := a.sessionExports[id]
72 if job == nil {
73 return nil, errors.New("session export is no longer available")
74 }
75 return job, nil
76 }
77
78 // BeginSessionExportForTarget captures the source before the native dialog. A
79 // tab is only a compatibility resolver; no later operation consults that tab.
80 func (a *App) BeginSessionExportForTarget(selector SessionSelector, tabID, format, title, observation string) (SessionExportHandle, error) {
81 switch format {
82 case "markdown", "json", "pdf", "image", "clipboard", "diagnostic":
83 default:
84 return SessionExportHandle{}, errors.New("unsupported export format")
85 }
86 ctx, cancel := context.WithCancel(a.bootContext())
87 job := &sessionExportJob{ctx: ctx, cancel: cancel, observation: json.RawMessage(observation)}
88 success := false
89 defer func() {
90 if !success {
91 cancel()
92 if job.dir != "" {
93 _ = os.RemoveAll(job.dir)
94 }
95 }
96 }()
97 if len(observation) > 64<<10 || (observation != "" && !json.Valid(job.observation)) {
98 return SessionExportHandle{}, errors.New("invalid export observation")
99 }
100 if err := a.captureSessionExportSource(job, selector, tabID, format); err != nil {
101 return SessionExportHandle{}, err
102 }
103 if title != "" {
104 job.handle.Snapshot.Title = title
105 }
106 job.handle.ExportID = "export-" + newTabID()
107 job.handle.Format = format
108 var err error
109 if format != "clipboard" {
110 extension, mime := ".md", "text/markdown"
111 switch format {
112 case "json", "diagnostic":
113 extension, mime = ".json", "application/json"
114 case "pdf":
115 extension, mime = ".pdf", "application/pdf"
116 case "image":
117 extension, mime = ".png", "image/png"
118 }
119 base := job.handle.Snapshot.Title
120 if format == "diagnostic" {
121 base += "-session-diagnostics"
122 }
123 job.path, err = a.nativeHost().SaveFileDialog(ctx, nativeDialogOptions{Title: "Export session", DefaultFilename: safeExportFilename(base + extension), CanCreateDirectories: true, Filters: exportFileFilters(mime, extension)})
124 if err != nil {
125 return SessionExportHandle{}, err
126 }
127 if job.path == "" {
128 return SessionExportHandle{}, nil
129 }
130 if filepath.Ext(job.path) == "" {
131 job.path += extension
132 }
133 }
134 job.dir, err = os.MkdirTemp("", "reasonix-desktop-export-")
135 if err != nil {
136 return SessionExportHandle{}, err
137 }
138 a.sessionExportMu.Lock()
139 if a.sessionExports == nil {
140 a.sessionExports = map[string]*sessionExportJob{}
141 }
142 a.sessionExports[job.handle.ExportID] = job
143 a.sessionExportMu.Unlock()
144 success = true
145 a.exportProgress(job, "preparing")
146 return job.handle, nil
147 }
148
149 func (a *App) exportProgress(job *sessionExportJob, phase string) {
150 a.emitRuntimeEvent("session_export_progress", map[string]any{"exportId": job.handle.ExportID, "title": job.handle.Snapshot.Title, "phase": phase, "records": job.records, "pages": job.pages})
151 }
152 func (a *App) prepareSessionExport(job *sessionExportJob) error {
153 if err := job.ctx.Err(); err != nil {
154 return err
155 }
156 if job.prepared {
157 return nil
158 }
159 a.exportProgress(job, "reading")
160 if job.client != nil {
161 format := job.handle.Format
162 if format == "clipboard" {
163 format = "markdown"
164 }
165 if format == "pdf" || format == "image" {
166 format = "blocks"
167 }
168 request, _ := json.Marshal(map[string]any{"snapshot": job.handle.Snapshot, "format": format})
169 resp, err := serveDoForSession(job.ctx, job.client, http.MethodPost, sessionExportURL(job.base, "/session-export/document", job.handle.Snapshot.Ref.SessionID, false), request, job.route)
170 if err != nil {
171 return err
172 }
173 defer resp.Body.Close()
174 if resp.StatusCode != http.StatusOK {
175 return errors.New("remote export failed or source changed; upgrade the remote service if the session was switched or taken over")
176 }
177 job.records, err = strconv.Atoi(resp.Header.Get("X-Reasonix-Export-Records"))
178 if err != nil || job.records < 0 {
179 return errors.New("remote export record count is invalid")
180 }
181 file, err := os.OpenFile(filepath.Join(job.dir, format), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
182 if err != nil {
183 return err
184 }
185 _, copyErr := io.Copy(file, resp.Body)
186 closeErr := file.Close()
187 if err = errors.Join(copyErr, closeErr); err != nil {
188 return err
189 }
190 } else {
191 doc, err := sessionexport.Build(job.ctx, job.query, job.handle.Snapshot, job.dir, func(count int) {
192 job.records = count
193 if count%100 == 0 {
194 a.exportProgress(job, "reading")
195 }
196 })
197 if err != nil {
198 return err
199 }
200 job.records = doc.Records
201 }
202 job.prepared = true
203 a.exportProgress(job, "rendering")
204 return nil
205 }
206
207 func (a *App) ReadSessionExportChunk(id string, offset int64) (SessionExportChunk, error) {
208 job, err := a.exportJob(id)
209 if err != nil {
210 return SessionExportChunk{}, err
211 }
212 job.mu.Lock()
213 defer job.mu.Unlock()
214 if offset < 0 {
215 return SessionExportChunk{}, errors.New("invalid export offset")
216 }
217 if err = a.prepareSessionExport(job); err != nil {
218 return SessionExportChunk{}, err
219 }
220 format := "blocks"
221 if job.handle.Format == "clipboard" {
222 format = "markdown"
223 }
224 file, err := os.Open(filepath.Join(job.dir, format))
225 if err != nil {
226 return SessionExportChunk{}, err
227 }
228 defer file.Close()
229 info, err := file.Stat()
230 if err != nil {
231 return SessionExportChunk{}, err
232 }
233 if offset > info.Size() {
234 return SessionExportChunk{}, errors.New("invalid export offset")
235 }
236 bytes := make([]byte, min(int64(1<<20), info.Size()-offset))
237 n, err := file.ReadAt(bytes, offset)
238 if err != nil && !errors.Is(err, io.EOF) {
239 return SessionExportChunk{}, err
240 }
241 return SessionExportChunk{Data: base64.StdEncoding.EncodeToString(bytes[:n]), NextOffset: offset + int64(n), Done: offset+int64(n) == info.Size()}, nil
242 }
243
244 func (a *App) AppendSessionExportPage(id string, page SessionExportPage) error {
245 job, err := a.exportJob(id)
246 if err != nil {
247 return err
248 }
249 job.mu.Lock()
250 defer job.mu.Unlock()
251 if err = job.ctx.Err(); err != nil {
252 return err
253 }
254 if job.handle.Format != "pdf" && job.handle.Format != "image" {
255 return errors.New("export does not accept pages")
256 }
257 if page.Index != job.pages || page.Offset != job.pageOffset || len(page.Data) > 2<<20 {
258 return errors.New("export page order or size is invalid")
259 }
260 data, err := base64.StdEncoding.DecodeString(page.Data)
261 if len(data) > 1<<20 {
262 return errors.New("export page chunk exceeds one MiB")
263 }
264 if err != nil {
265 return err
266 }
267 file, err := os.OpenFile(filepath.Join(job.dir, fmt.Sprintf("page-%06d", page.Index)), os.O_CREATE|os.O_WRONLY, 0600)
268 if err != nil {
269 return err
270 }
271 n, writeErr := file.WriteAt(data, page.Offset)
272 closeErr := file.Close()
273 if err = errors.Join(writeErr, closeErr); err != nil {
274 return err
275 }
276 job.pageOffset += int64(n)
277 if page.Done {
278 if page.Width <= 0 || page.Height <= 0 || page.Width > 8192 || page.Height > 8192 {
279 return errors.New("invalid export page dimensions")
280 }
281 source, openErr := os.Open(filepath.Join(job.dir, fmt.Sprintf("page-%06d", page.Index)))
282 if openErr != nil {
283 return openErr
284 }
285 config, encoding, decodeErr := image.DecodeConfig(source)
286 expected := "png"
287 if job.handle.Format == "pdf" {
288 expected = "jpeg"
289 }
290 if decodeErr != nil || encoding != expected || config.Width != page.Width || config.Height != page.Height {
291 source.Close()
292 return errors.New("export page encoding or dimensions do not match")
293 }
294 if _, err := source.Seek(0, io.SeekStart); err != nil {
295 source.Close()
296 return err
297 }
298 _, _, decodeErr = image.Decode(source)
299 source.Close()
300 if decodeErr != nil {
301 return fmt.Errorf("incomplete export page: %w", decodeErr)
302 }
303
304 meta, _ := json.Marshal(pageDimensions{Width: page.Width, Height: page.Height})
305 if err = os.WriteFile(filepath.Join(job.dir, fmt.Sprintf("page-%06d.json", page.Index)), meta, 0600); err != nil {
306 return err
307 }
308 job.pages++
309 job.pageOffset = 0
310 a.exportProgress(job, "rendering")
311 }
312 return nil
313 }
314
315 func (a *App) FinishSessionExport(id string) (SessionExportResult, error) {
316 result := SessionExportResult{Paths: []string{}}
317 job, err := a.exportJob(id)
318 if err != nil {
319 return result, err
320 }
321 job.mu.Lock()
322 defer job.mu.Unlock()
323 if err = job.ctx.Err(); err != nil {
324 return result, err
325 }
326 if err = a.validateSessionExportSource(job); err != nil {
327 return result, err
328 }
329
330 if job.pageOffset != 0 {
331 return result, errors.New("export has an incomplete page")
332 }
333 if job.handle.Format == "diagnostic" {
334 err = a.writeSessionDiagnosticExport(job)
335 } else {
336 if err = a.prepareSessionExport(job); err != nil {
337 return result, err
338 }
339 if err = a.validateSessionExportSource(job); err != nil {
340 return result, err
341 }
342 a.exportProgress(job, "saving")
343 switch job.handle.Format {
344 case "markdown", "json":
345 err = writeStreamingExport(job.path, func(dst io.Writer) error {
346 src, err := os.Open(filepath.Join(job.dir, job.handle.Format))
347 if err != nil {
348 return err
349 }
350 defer src.Close()
351 _, err = copyExportContext(job.ctx, dst, src)
352 return err
353 })
354 case "pdf":
355 err = writeStreamingExport(job.path, func(dst io.Writer) error {
356 return writeExportPDF(job.ctx, dst, job.dir, job.pages, job.handle.Snapshot.Title)
357 })
358 case "image":
359 result.Paths, err = publishExportImages(job.ctx, job.dir, job.path, job.pages)
360 case "clipboard":
361 }
362 }
363 if err != nil {
364 return result, err
365 }
366 if job.path != "" && len(result.Paths) == 0 {
367 result.Paths = append(result.Paths, job.path)
368 }
369 result.Records = job.records
370 result.Pages = job.pages
371 a.exportProgress(job, "complete")
372 a.sessionExportMu.Lock()
373 delete(a.sessionExports, id)
374 a.sessionExportMu.Unlock()
375 job.cancel()
376 _ = os.RemoveAll(job.dir)
377 return result, nil
378 }
379
380 func (a *App) CancelSessionExport(id string) error {
381 a.sessionExportMu.Lock()
382 job := a.sessionExports[id]
383 delete(a.sessionExports, id)
384 a.sessionExportMu.Unlock()
385 if job == nil {
386 return nil
387 }
388 job.cancel()
389 job.mu.Lock()
390 defer job.mu.Unlock()
391 _ = os.RemoveAll(job.dir)
392 a.exportProgress(job, "cancelled")
393 return nil
394 }
395 func (a *App) cancelSessionExports() {
396 a.sessionExportMu.Lock()
397 jobs := a.sessionExports
398 a.sessionExports = nil
399 a.sessionExportMu.Unlock()
400 for _, job := range jobs {
401 job.cancel()
402 go func(j *sessionExportJob) { j.mu.Lock(); defer j.mu.Unlock(); _ = os.RemoveAll(j.dir) }(job)
403 }
404 }
405
406 type exportContextReader struct {
407 ctx context.Context
408 r io.Reader
409 }
410
411 func (r exportContextReader) Read(p []byte) (int, error) {
412 if err := r.ctx.Err(); err != nil {
413 return 0, err
414 }
415 return r.r.Read(p)
416 }
417 func copyExportContext(ctx context.Context, dst io.Writer, src io.Reader) (int64, error) {
418 return io.Copy(dst, exportContextReader{ctx, src})
419 }
420
421 func (a *App) validateSessionExportSource(job *sessionExportJob) error {
422 if job.client == nil {
423 return job.query.ValidateExportSource(job.handle.Snapshot)
424 }
425 body, _ := json.Marshal(job.handle.Snapshot)
426 response, err := serveDoForSession(job.ctx, job.client, http.MethodPost, sessionExportURL(job.base, "/session-export/validate", job.handle.Snapshot.Ref.SessionID, false), body, job.route)
427 if err != nil {
428 return err
429 }
430 response.Body.Close()
431 if response.StatusCode != http.StatusNoContent {
432 return errors.New("export source changed or is unavailable; upgrade the remote service if the session was switched or taken over")
433 }
434 return nil
435 }
436
437 func (a *App) captureSessionExportSource(job *sessionExportJob, selector SessionSelector, tabID, format string) error {
438 ctx := job.ctx
439 if a.isRemoteTab(tabID) {
440 a.remoteTabMu.Lock()
441 tab := a.remoteTabs[tabID]
442 if tab == nil || !tab.capabilities[servecontract.SessionExportV1] {
443 a.remoteTabMu.Unlock()
444 return errors.New("remote service does not support session-export-v1; upgrade it to export the complete session")
445 }
446 if !remoteExportSelectorMatches(selector, tab) {
447 a.remoteTabMu.Unlock()
448 return errors.New("export target changed")
449 }
450 job.sourceHostID, job.workspaceRoot = tab.ref.HostID, tab.ref.Workspace
451 job.client, job.base, job.route = tab.client, tab.base, tab.routing.currentPath
452 a.remoteTabMu.Unlock()
453 if job.client == nil || job.route == "" {
454 return errors.New("remote session is unavailable")
455 }
456 sessionID, ok := strings.CutPrefix(job.route, remoteSessionIDRoutePrefix)
457 if !ok || sessionID == "" {
458 return errors.New("remote session has no canonical identity")
459 }
460 resp, err := serveDoForSession(ctx, job.client, http.MethodGet, sessionExportURL(job.base, "/session-export/snapshot", sessionID, format == "diagnostic"), nil, job.route)
461 if err != nil {
462 return err
463 }
464 defer resp.Body.Close()
465 if resp.StatusCode != http.StatusOK {
466 return errors.New("unable to capture remote export snapshot")
467 }
468 if err = json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&job.handle.Snapshot); err != nil {
469 return err
470 }
471 if selector.Ref != nil && selector.Ref.SessionID != job.handle.Snapshot.Ref.SessionID {
472 return errors.New("export target changed")
473 }
474 } else {
475 if selector.Ref == nil && selector.Source == nil && selector.SessionPath == "" && selector.TopicID == "" {
476 a.mu.RLock()
477 tab := a.tabByIDLocked(tabID)
478 if tab != nil {
479 selector.SessionPath = tab.currentSessionPath()
480 if tab.SessionID != "" {
481 selector.Ref = &session.SessionRef{HostID: localDesktopHostID, SessionID: tab.SessionID}
482 }
483 }
484 a.mu.RUnlock()
485 }
486 target, err := a.resolveSessionTargetWithArchived(selector, true)
487 if err != nil {
488 return err
489 }
490 if target.SessionRef.SessionID == "" {
491 return newSessionOperationError("unsupported", "This historical format cannot guarantee a complete export.")
492 }
493 job.query = a.desktopSessionService("").Query()
494 job.controller = target.Controller
495 job.workspaceRoot = target.WorkspaceRoot
496 if format == "diagnostic" {
497 job.handle.Snapshot, err = job.query.CaptureDiagnosticSnapshot(ctx, target.SessionRef)
498 if err != nil {
499 return err
500 }
501 } else {
502 job.handle.Snapshot, err = job.query.CaptureExportSnapshot(ctx, target.SessionRef)
503 if err != nil {
504 return err
505 }
506 }
507 }
508
509 return nil
510 }
511
512 func sessionExportURL(base, path, sessionID string, diagnostic bool) string {
513 endpoint, err := url.Parse(serveURL(base, path))
514 if err != nil {
515 return serveURL(base, path)
516 }
517 query := endpoint.Query()
518 if sessionID != "" {
519 query.Set("sessionId", sessionID)
520 }
521 if diagnostic {
522 query.Set("diagnostic", "1")
523 }
524 endpoint.RawQuery = query.Encode()
525 return endpoint.String()
526 }
527
528 // Called under remoteTabMu before any remote read or save dialog.
529 func remoteExportSelectorMatches(selector SessionSelector, tab *remoteTab) bool {
530 if selector.Ref != nil {
531 return selector.Ref.HostID == tab.ref.HostID && remoteSessionIDRoutePrefix+selector.Ref.SessionID == tab.routing.currentPath
532 }
533 if selector.Source != nil {
534 return selector.Source.HostID == tab.ref.HostID && (selector.Source.Path == tab.session.path || selector.Source.Path == tab.routing.currentPath)
535 }
536 return selector.SessionPath == "" || selector.SessionPath == tab.session.path || selector.SessionPath == tab.routing.currentPath
537 }
538
538 lines GO