返回 DeepSeek-Reasonix
document.go
根目录 / internal / sessionexport / document.go
1 // Package sessionexport renders a fixed authoritative display snapshot. It is
2 // shared by Desktop and Serve and never reads a provider workset or UI window.
3 package sessionexport
4
5 import (
6 "bufio"
7 "bytes"
8 "context"
9 "crypto/sha256"
10 "encoding/hex"
11 "encoding/json"
12 "errors"
13 "fmt"
14 "io"
15 "os"
16 "path/filepath"
17 "strings"
18 "unicode/utf8"
19
20 "reasonix/internal/agent"
21 "reasonix/internal/attachment"
22 "reasonix/internal/provider"
23 "reasonix/internal/session"
24 "reasonix/internal/transcript"
25 )
26
27 type Item map[string]any
28
29 type Document struct {
30 Snapshot session.ExportSnapshot `json:"snapshot"`
31 Records int `json:"records"`
32 Directory string `json:"-"`
33 }
34
35 type Block struct {
36 Kind string `json:"kind"`
37 Text string `json:"text"`
38 Label string `json:"label,omitempty"`
39 }
40
41 func text(item Item, key string) string { value, _ := item[key].(string); return value }
42 func resultPath(dir, id string) string {
43 sum := sha256.Sum256([]byte(id))
44 return filepath.Join(dir, "result-"+hex.EncodeToString(sum[:]))
45 }
46
47 // Build stages files privately. Only a successful caller may publish them.
48 // Bodies are retained for one record at a time; cross-page tool matching uses
49 // disk rather than an unbounded map of tool output strings.
50 func Build(ctx context.Context, q *session.Query, snapshot session.ExportSnapshot, dir string, progress func(int)) (*Document, error) {
51 return BuildForRef(ctx, q, snapshot.Ref, snapshot, dir, progress)
52 }
53
54 // BuildForRef keeps an authenticated session identity separate from snapshot
55 // metadata supplied by a remote export client.
56 func BuildForRef(ctx context.Context, q *session.Query, ref session.SessionRef, snapshot session.ExportSnapshot, dir string, progress func(int)) (*Document, error) {
57 if err := os.MkdirAll(dir, 0700); err != nil {
58 return nil, err
59 }
60 if err := stageToolResults(ctx, q, ref, snapshot, dir); err != nil {
61 return nil, err
62 }
63 items, err := os.CreateTemp(dir, "items-")
64 if err != nil {
65 return nil, err
66 }
67 defer items.Close()
68 markdown, err := os.OpenFile(filepath.Join(dir, "markdown"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
69 if err != nil {
70 return nil, err
71 }
72 defer markdown.Close()
73 blocks, err := os.OpenFile(filepath.Join(dir, "blocks"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
74 if err != nil {
75 return nil, err
76 }
77 defer blocks.Close()
78 md := bufio.NewWriter(markdown)
79 enc := json.NewEncoder(items)
80 blockEncoder := json.NewEncoder(blocks)
81 doc := &Document{Snapshot: snapshot, Directory: dir}
82 title := strings.NewReplacer("\r", " ", "\n", " ").Replace(snapshot.Title)
83 if title == "" {
84 title = "Reasonix session"
85 }
86 heading := fmt.Sprintf("# %s\n\nSnapshot: %s · sequence %d\n\n", title, snapshot.CapturedAt.Format("2006-01-02T15:04:05Z07:00"), snapshot.SnapshotSequence)
87 if _, err = io.WriteString(md, heading); err != nil {
88 return nil, err
89 }
90 if err = blockEncoder.Encode(Block{Kind: "markdown", Text: heading}); err != nil {
91 return nil, err
92 }
93 attribution := map[string]int{"sharedHost": 0, "diskCache": 0, "remote": 0, "networkCalls": 0}
94 w := &documentWriter{ctx: ctx, query: q, sessionRef: ref, items: enc, md: md, blocks: blockEncoder, doc: doc, attribution: attribution, progress: progress}
95 err = q.VisitExportMessagesForRef(ctx, ref, snapshot, func(record session.PersistentMessage) error { return w.writeRecord(record, dir) })
96 if err != nil {
97 return nil, err
98 }
99 if err = md.Flush(); err != nil {
100 return nil, err
101 }
102 if err = writeJSONDocument(items, snapshot, dir, attribution); err != nil {
103 return nil, err
104 }
105 for _, file := range []*os.File{markdown, blocks} {
106 if err := errors.Join(file.Sync(), file.Close()); err != nil {
107 return nil, err
108 }
109 }
110 return doc, nil
111 }
112
113 type documentWriter struct {
114 ctx context.Context
115 query *session.Query
116 sessionRef session.SessionRef
117 items *json.Encoder
118 md io.Writer
119 blocks *json.Encoder
120 doc *Document
121 attribution map[string]int
122 progress func(int)
123 }
124
125 func stageToolResults(ctx context.Context, q *session.Query, ref session.SessionRef, snapshot session.ExportSnapshot, dir string) error {
126 err := q.VisitExportMessagesForRef(ctx, ref, snapshot, func(record session.PersistentMessage) error {
127 var m provider.Message
128 if err := json.Unmarshal(record.Inline, &m); err != nil {
129 return err
130 }
131 for _, row := range transcript.History([]provider.Message{m}, transcript.HistoryOptions{}) {
132 for _, call := range row.ToolCalls {
133 if call.ID != "" {
134 if err := os.WriteFile(resultPath(dir, call.ID)+".claimed", nil, 0600); err != nil {
135 return err
136 }
137 }
138 }
139 }
140 if m.Role == provider.RoleTool && m.ToolCallID != "" {
141 return os.WriteFile(resultPath(dir, m.ToolCallID), record.Inline, 0600)
142 }
143 return nil
144 })
145 return err
146 }
147 func (w *documentWriter) write(item Item) error {
148 if err := w.items.Encode(item); err != nil {
149 return err
150 }
151 if err := WriteItemMarkdown(w.md, item); err != nil {
152 return err
153 }
154 if err := writeBlocks(w.blocks, item); err != nil {
155 return err
156 }
157 if text(item, "kind") == "notice" && (text(item, "code") == "mcp_tools_list" || strings.EqualFold(strings.TrimSpace(text(item, "text")), "mcp tools/list")) {
158 var detail struct {
159 Source string `json:"source"`
160 Network bool `json:"network_call"`
161 }
162 if json.Unmarshal([]byte(text(item, "detail")), &detail) == nil {
163 switch detail.Source {
164 case "shared_host":
165 w.attribution["sharedHost"]++
166 case "disk_cache":
167 w.attribution["diskCache"]++
168 case "remote":
169 w.attribution["remote"]++
170 }
171 if detail.Network {
172 w.attribution["networkCalls"]++
173 }
174 }
175 }
176 w.doc.Records++
177 if w.progress != nil {
178 w.progress(w.doc.Records)
179 }
180 return nil
181 }
182 func (w *documentWriter) writeRecord(record session.PersistentMessage, dir string) error {
183 var m provider.Message
184 if err := json.Unmarshal(record.Inline, &m); err != nil {
185 return err
186 }
187 rows := transcript.History([]provider.Message{m}, transcript.HistoryOptions{})
188 if record.Role == "notice" {
189 var row transcript.Message
190 if err := json.Unmarshal(record.Inline, &row); err != nil {
191 return err
192 }
193 row.MessageID = record.MessageID
194 row.RecordID = "m:" + record.MessageID
195 rows = []transcript.Message{row}
196 }
197 for _, row := range rows {
198 if err := w.writeRow(record, m, row, dir); err != nil {
199 return err
200 }
201 }
202 return nil
203 }
204 func (w *documentWriter) writeRow(record session.PersistentMessage, m provider.Message, row transcript.Message, dir string) error {
205 encodedRow, err := json.Marshal(row)
206 if err != nil {
207 return err
208 }
209 item := Item{}
210 if err = json.Unmarshal(encodedRow, &item); err != nil {
211 return err
212 }
213 delete(item, "role")
214 delete(item, "content")
215 delete(item, "toolCalls")
216 item["id"], item["kind"], item["text"] = row.RecordID, row.Role, row.Content
217 item["messageId"], item["submissionId"] = record.MessageID, record.SubmissionID
218 switch row.Role {
219 case "user":
220 item["text"] = agent.UserMessageText(m)
221 images, err := w.exportImages(m, dir)
222 if err != nil {
223 return err
224 }
225 if len(images) > 0 {
226 item["images"] = images
227 }
228 case "assistant":
229 return w.writeAssistant(record, row, item, dir)
230 case "tool":
231 if m.ToolCallID != "" {
232 if _, err := os.Stat(resultPath(dir, m.ToolCallID) + ".claimed"); err == nil {
233 return nil
234 } else if !os.IsNotExist(err) {
235 return err
236 }
237 }
238 item = Item{"kind": "tool", "id": row.RecordID, "name": m.Name, "args": "", "readOnly": false}
239 applyResult(item, m)
240 case "notice":
241 item["code"] = row.Code
242 item["level"] = row.Level
243 item["detail"] = row.Detail
244 }
245 if err := w.write(item); err != nil {
246 return err
247 }
248 return nil
249 }
250
251 func (w *documentWriter) exportImages(m provider.Message, dir string) ([]string, error) {
252 images := append([]string(nil), m.Images...)
253 for _, input := range m.ImageInputs {
254 if err := w.ctx.Err(); err != nil {
255 return nil, err
256 }
257 switch input.Kind {
258 case attachment.KindURL:
259 if err := input.Validate(); err != nil {
260 return nil, err
261 }
262 images = append(images, input.URL)
263 case attachment.KindAttachment:
264 if err := input.Validate(); err != nil {
265 return nil, err
266 }
267 dataURL, err := w.stageAttachment(input.Attachment, dir)
268 if err != nil {
269 return nil, err
270 }
271 images = append(images, dataURL)
272 case attachment.KindFiles:
273 if err := input.Validate(); err != nil {
274 return nil, err
275 }
276 return nil, fmt.Errorf("session export: provider file image %q has no portable original", input.FilesID)
277 default:
278 return nil, fmt.Errorf("session export: unsupported image input kind %q", input.Kind)
279 }
280 }
281 return images, nil
282 }
283
284 func (w *documentWriter) stageAttachment(ref *attachment.AttachmentRef, dir string) (string, error) {
285 if ref == nil {
286 return "", errors.New("session export: missing attachment reference")
287 }
288 attachmentsDir := filepath.Join(dir, "attachments")
289 if err := os.MkdirAll(attachmentsDir, 0700); err != nil {
290 return "", err
291 }
292 path := filepath.Join(attachmentsDir, ref.Content.Digest)
293 file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
294 if errors.Is(err, os.ErrExist) {
295 body, readErr := os.ReadFile(path)
296 if readErr != nil {
297 return "", readErr
298 }
299 if int64(len(body)) != ref.Content.Bytes {
300 return "", errors.New("session export: staged attachment size changed")
301 }
302 return attachment.DataURL(ref.MIME(), body), nil
303 }
304 if err != nil {
305 return "", err
306 }
307 success := false
308 defer func() {
309 _ = file.Close()
310 if !success {
311 _ = os.Remove(path)
312 }
313 }()
314 var body bytes.Buffer
315 for offset := int64(0); offset < ref.Content.Bytes; {
316 chunk, total, readErr := w.query.ReadSessionAttachment(w.ctx, w.sessionRef, ref.Content.Digest, offset, min(int64(1<<20), ref.Content.Bytes-offset))
317 if readErr != nil {
318 return "", fmt.Errorf("session export: read attachment %q: %w", attachment.NormalizeDisplayName(ref.DisplayName), readErr)
319 }
320 if total != ref.Content.Bytes || len(chunk) == 0 {
321 return "", errors.New("session export: attachment size changed")
322 }
323 if _, err = file.Write(chunk); err != nil {
324 return "", err
325 }
326 if _, err = body.Write(chunk); err != nil {
327 return "", err
328 }
329 offset += int64(len(chunk))
330 }
331 if err = errors.Join(file.Sync(), file.Close()); err != nil {
332 return "", err
333 }
334 success = true
335 return attachment.DataURL(ref.MIME(), body.Bytes()), nil
336 }
337 func (w *documentWriter) writeAssistant(record session.PersistentMessage, row transcript.Message, item Item, dir string) error {
338 if err := w.writeSearch(row, item); err != nil {
339 return err
340 }
341 item["reasoning"] = row.Reasoning
342 item["streaming"] = false
343 item["turnFinal"] = record.TurnFinal
344 item["turnDurationMs"] = record.TurnDurationMs
345 if record.SamplingCount != nil {
346 item["samplingCount"] = *record.SamplingCount
347 }
348 if record.ToolCount != nil {
349 item["toolCount"] = *record.ToolCount
350 }
351 item["workDurationMs"] = row.WorkDurationMs
352 item["createdAt"] = row.CreatedAt
353 if len(row.MemoryCitations) > 0 {
354 item["memoryCitations"] = row.MemoryCitations
355 }
356 if row.Content != "" || row.Reasoning != "" {
357 if err := w.write(item); err != nil {
358 return err
359 }
360 }
361 return w.writeCalls(record, row, dir)
362 }
363 func (w *documentWriter) writeSearch(row transcript.Message, item Item) error {
364 for _, search := range row.ServerSearch {
365 if search.ID == "" {
366 continue
367 }
368 args, err := json.Marshal(map[string]string{"query": search.Query})
369 if err != nil {
370 return err
371 }
372 sources := make([]map[string]string, 0, len(search.Results))
373 for _, hit := range search.Results {
374 sources = append(sources, map[string]string{"title": hit.Title, "url": hit.URL})
375 }
376 if err := w.write(Item{"kind": "tool", "id": search.ID, "name": "web_search", "args": string(args), "readOnly": true, "status": "done", "searchSourcesStatus": provider.ServerSearchSourcesStatus(search), "searchSources": sources, "output": provider.ServerSearchDisplayOutput(search), "contentState": "ready"}); err != nil {
377 return err
378 }
379 item["searchSources"] = sources
380 }
381 return nil
382 }
383 func (w *documentWriter) writeCalls(record session.PersistentMessage, row transcript.Message, dir string) error {
384 for index, call := range row.ToolCalls {
385 tool := Item{"kind": "tool", "id": call.ID, "name": call.Name, "args": call.Arguments, "readOnly": false, "status": "unknown", "resultMissing": true, "contentState": "unloaded", "fileDiff": call.Diff, "added": call.Added, "removed": call.Removed}
386 if observation, ok := record.ToolObservations[call.ID]; ok {
387 tool["status"] = exportToolState(observation.State)
388 }
389 if call.ID == "" {
390 tool["id"] = fmt.Sprintf("%s:call:%d", record.MessageID, index)
391 }
392 if call.ResolvedReadOnly != nil {
393 tool["readOnly"] = *call.ResolvedReadOnly
394 }
395 if call.ResolvedName != "" {
396 tool["resolvedName"] = call.ResolvedName
397 }
398 if call.CapabilityID != "" {
399 tool["capabilityId"] = call.CapabilityID
400 }
401 if call.ID != "" {
402 body, err := os.ReadFile(resultPath(dir, call.ID))
403 if err == nil {
404 var result provider.Message
405 if err = json.Unmarshal(body, &result); err != nil {
406 return err
407 }
408 applyResult(tool, result)
409 } else if !os.IsNotExist(err) {
410 return err
411 }
412
413 }
414 if err := w.write(tool); err != nil {
415 return err
416 }
417 }
418 return nil
419 }
420 func writeJSONDocument(items *os.File, snapshot session.ExportSnapshot, dir string, attribution map[string]int) error {
421 var err error
422 if _, err = items.Seek(0, io.SeekStart); err != nil {
423 return err
424 }
425 output, err := os.OpenFile(filepath.Join(dir, "json"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
426 if err != nil {
427 return err
428 }
429 defer output.Close()
430 header := map[string]any{"title": snapshot.Title, "exportedAt": snapshot.CapturedAt, "mcpList": attribution, "exportMetadata": map[string]any{"schemaVersion": 1, "snapshot": snapshot, "complete": true, "mcpAttributionScope": "persisted display notices; older unrecorded observations are unavailable"}}
431 data, err := json.Marshal(header)
432 if err != nil {
433 return err
434 }
435 if _, err = output.Write(data[:len(data)-1]); err != nil {
436 return err
437 }
438 if _, err = io.WriteString(output, ",\"items\":["); err != nil {
439 return err
440 }
441 decoder := json.NewDecoder(items)
442 first := true
443 for {
444 var item json.RawMessage
445 err = decoder.Decode(&item)
446 if errors.Is(err, io.EOF) {
447 break
448 }
449 if err != nil {
450 return err
451 }
452 if !first {
453 if _, err = io.WriteString(output, ","); err != nil {
454 return err
455 }
456 }
457 if _, err = output.Write(item); err != nil {
458 return err
459 }
460 first = false
461 }
462 if _, err = io.WriteString(output, "]}\n"); err != nil {
463 return err
464 }
465 return errors.Join(output.Sync(), output.Close())
466 }
467
468 func applyResult(item Item, m provider.Message) {
469 output := m.Content
470 if m.RawContent != "" {
471 output = m.RawContent
472 }
473 item["output"] = output
474 item["resultMissing"] = false
475 item["contentState"] = "ready"
476 item["status"] = "done"
477 switch provider.ToolResultRunState(m) {
478 case provider.ToolRunFailed:
479 item["status"] = "error"
480 item["error"] = output
481 case provider.ToolRunCancelled, provider.ToolRunNotStarted:
482 item["status"] = "stopped"
483 case provider.ToolRunUnknown:
484 item["status"] = "unknown"
485 case provider.ToolRunPending, provider.ToolRunStarted, provider.ToolRunRunning:
486 item["status"] = "running"
487 }
488 if m.ToolExecution != nil {
489 item["execution"] = m.ToolExecution
490 }
491 if m.MCPApp != nil {
492 item["mcpApp"] = m.MCPApp
493 }
494 if m.PresentedFiles != nil {
495 item["presentedFiles"] = m.PresentedFiles.Files
496 }
497 }
498
499 func Fence(body string) string {
500 longest, run := 2, 0
501 for _, r := range body {
502 if r == '`' {
503 run++
504 longest = max(longest, run)
505 } else {
506 run = 0
507 }
508 }
509 return strings.Repeat("`", longest+1)
510 }
511 func WriteItemMarkdown(dst io.Writer, item Item) error {
512 for _, block := range itemBlocks(item) {
513 if block.Kind == "code" {
514 fence := Fence(block.Text)
515 if _, err := fmt.Fprintf(dst, "%s\n%s\n%s\n%s\n\n", block.Label, fence, block.Text, fence); err != nil {
516 return err
517 }
518 } else if _, err := io.WriteString(dst, block.Text+"\n\n"); err != nil {
519 return err
520 }
521 }
522 return nil
523 }
524 func itemBlocks(item Item) []Block {
525 var blocks []Block
526 add := func(kind, label, body string) { blocks = append(blocks, Block{Kind: kind, Label: label, Text: body}) }
527 switch text(item, "kind") {
528 case "assistant":
529 add("markdown", "", "## Assistant")
530 if value := text(item, "reasoning"); value != "" {
531 add("markdown", "", "### Reasoning\n\n"+value)
532 }
533 if value := text(item, "text"); value != "" {
534 add("markdown", "", value)
535 }
536 case "tool":
537 add("markdown", "", "### Tool: "+text(item, "name")+"\n\nStatus: "+text(item, "status"))
538 add("code", "Args", text(item, "args"))
539 if value, ok := item["output"]; ok {
540 if value == "" {
541 add("markdown", "", "Output: 无输出 / No output")
542 } else {
543 add("code", "Output", text(item, "output"))
544 }
545 }
546 if value := text(item, "error"); value != "" {
547 add("code", "Error", value)
548 }
549 default:
550 add("markdown", "", "## "+text(item, "kind")+"\n\n"+text(item, "text"))
551 if value := text(item, "detail"); value != "" {
552 add("code", "Details", value)
553 }
554 for _, field := range []string{"decisionReceipt", "readPause", "readCompletion", "readiness", "diagnostic"} {
555 if value, ok := item[field]; ok {
556 if encoded, err := json.MarshalIndent(value, "", " "); err == nil {
557 add("code", field, string(encoded))
558 }
559 }
560 }
561 if images, ok := item["images"].([]string); ok {
562 for _, image := range images {
563 add("markdown", "", "![attachment]("+image+")")
564 }
565 }
566 }
567 return blocks
568 }
569 func writeBlocks(enc *json.Encoder, item Item) error {
570 for _, block := range itemBlocks(item) {
571 // Code bodies can be arbitrarily large. Split them at UTF-8/line boundaries;
572 // all bytes remain in the readable exports and in the visual continuation.
573 for len(block.Text) > 32<<10 && block.Kind == "code" {
574 cut := 32 << 10
575 if n := strings.LastIndexByte(block.Text[:cut], '\n'); n > 0 {
576 cut = n + 1
577 }
578 for !utf8.RuneStart(block.Text[cut]) {
579 cut--
580 }
581 part := block
582 part.Text = block.Text[:cut]
583 if err := enc.Encode(part); err != nil {
584 return err
585 }
586 block.Text = block.Text[cut:]
587 }
588 if err := enc.Encode(block); err != nil {
589 return err
590 }
591 }
592 return nil
593 }
594
595 func exportToolState(state string) string {
596 switch state {
597 case "completed", "user_confirmed":
598 return "done"
599 case "failed":
600 return "error"
601 case "cancelled", "not_started":
602 return "stopped"
603 case "pending", "started", "running":
604 return "running"
605 default:
606 return "unknown"
607 }
608 }
609
609 lines GO