first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
// This file implements session export/import (US-008, #124): a session can be
// exported to a self-contained JSONL or HTML file, and a JSONL export can be
// imported back as a fresh, resumable session. The JSONL form is the same
// role-discriminated schema the store persists, so an export → import round-trip
// is lossless (message contents and the id/parentId tree survive verbatim); the
// HTML form is a read-only, self-contained transcript with inline styles and no
// external network resources, suitable for sharing.
package session
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
)
// WriteJSONL writes header + entries as JSONL in the store's on-disk schema
// (header line first, then one entry line each, ids/parentIds preserved). It is
// the export counterpart to writeSessionEntries and the exact input ReadJSONL
// expects, so a WriteJSONL → ReadJSONL round-trip is lossless.
func WriteJSONL(w io.Writer, header SessionHeader, entries []Entry) error {
header.Version = SchemaVersion
return writeSessionEntries(w, header, entries)
}
// ReadJSONL decodes a JSONL export (as produced by WriteJSONL or a raw session
// file) into a header and entries, migrating v1/v2 bare-message files the same
// way LoadEntries does. It is the import counterpart to WriteJSONL.
func ReadJSONL(r io.Reader) (SessionHeader, []Entry, error) {
return readSession(r)
}
// Export writes the session identified by id to outPath. The format is chosen
// by outPath's extension: ".html"/".htm" produces a self-contained HTML
// transcript; anything else (including ".jsonl") produces JSONL. The parent
// directory of outPath must already exist. It returns the number of entries
// written so a caller can report progress.
func (s *Store) Export(id, outPath string) (int, error) {
header, entries, err := s.LoadEntries(id)
if err != nil {
return 0, err
}
f, err := os.Create(outPath)
if err != nil {
return 0, fmt.Errorf("session: create export %s: %w", outPath, err)
}
defer f.Close()
ext := strings.ToLower(filepath.Ext(outPath))
if ext == ".html" || ext == ".htm" {
if err := WriteHTML(f, header, entries); err != nil {
return 0, err
}
} else {
if err := WriteJSONL(f, header, entries); err != nil {
return 0, err
}
}
if err := f.Close(); err != nil {
return 0, fmt.Errorf("session: finalize export %s: %w", outPath, err)
}
return len(entries), nil
}
// Import reads a JSONL export at inPath and materializes it as a fresh session
// in the store: a new id (derived from now) is assigned, the original id is
// recorded as ParentSession for lineage, and the entries are written verbatim
// (ids/parentIds preserved) so the tree — and thus PathToLeaf/resume — behaves
// exactly as in the source. It returns the new header and the imported entries.
// An HTML file (or any non-JSONL input) fails to parse and returns an error
// rather than importing garbage.
func (s *Store) Import(inPath string, now time.Time) (SessionHeader, []Entry, error) {
f, err := os.Open(inPath)
if err != nil {
return SessionHeader{}, nil, fmt.Errorf("session: open import %s: %w", inPath, err)
}
defer f.Close()
srcHeader, entries, err := ReadJSONL(f)
if err != nil {
return SessionHeader{}, nil, err
}
newHeader := SessionHeader{
ID: NewID(now),
CreatedAt: now,
UpdatedAt: now,
Model: srcHeader.Model,
Provider: srcHeader.Provider,
SystemPrompt: srcHeader.SystemPrompt,
ParentSession: srcHeader.ID,
}
if err := s.SaveEntries(newHeader, entries); err != nil {
return SessionHeader{}, nil, err
}
return newHeader, entries, nil
}
// WriteHTML writes a self-contained HTML transcript of the session: inline CSS
// only (no external stylesheets, fonts, scripts, or network resources), role
// color-coding, and tool-call/result blocks. All message text is HTML-escaped
// so a transcript containing markup or a crafted "</script>" cannot break out of
// its container or inject active content (defensive against a hostile session).
func WriteHTML(w io.Writer, header SessionHeader, entries []Entry) error {
var b strings.Builder
b.WriteString(htmlHead(header))
for _, e := range entries {
b.WriteString(renderEntryHTML(e))
}
b.WriteString(htmlFoot())
_, err := io.WriteString(w, b.String())
return err
}