Changeset 0.34.0 (#208)

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
2026-03-20 00:02:37 +00:00
committed by xcaliber
parent ed3e9363f2
commit d16bb93177
20 changed files with 5623 additions and 1 deletions

244
server/export/chatgpt.go Normal file
View File

@@ -0,0 +1,244 @@
package export
// chatgpt.go — v0.34.0 CS4
//
// Parses ChatGPT conversation exports (conversations.json) and converts
// them to Switchboard channel/message models.
import (
"encoding/json"
"fmt"
"io"
"sort"
"time"
"chat-switchboard/models"
"chat-switchboard/store"
)
// ── ChatGPT format types ─────────────────────
// ChatGPTExport is the top-level conversations.json structure.
type ChatGPTExport []ChatGPTConversation
// ChatGPTConversation represents one conversation in the ChatGPT export.
type ChatGPTConversation struct {
Title string `json:"title"`
CreateTime float64 `json:"create_time"`
UpdateTime float64 `json:"update_time"`
Mapping map[string]ChatGPTMappingNode `json:"mapping"`
ConversationID string `json:"conversation_id"`
}
// ChatGPTMappingNode is a node in the conversation DAG.
type ChatGPTMappingNode struct {
ID string `json:"id"`
Parent *string `json:"parent"`
Children []string `json:"children"`
Message *ChatGPTMessage `json:"message"`
}
// ChatGPTMessage is a message within a mapping node.
type ChatGPTMessage struct {
ID string `json:"id"`
Author ChatGPTAuthor `json:"author"`
CreateTime *float64 `json:"create_time"`
Content ChatGPTContent `json:"content"`
Metadata json.RawMessage `json:"metadata"`
}
// ChatGPTAuthor identifies the message sender.
type ChatGPTAuthor struct {
Role string `json:"role"`
}
// ChatGPTContent holds the message content.
type ChatGPTContent struct {
ContentType string `json:"content_type"`
Parts []interface{} `json:"parts"`
}
// ── Parsing ──────────────────────────────────
// ParseChatGPTExport reads and parses a conversations.json file.
func ParseChatGPTExport(r io.Reader) (ChatGPTExport, error) {
var out ChatGPTExport
if err := json.NewDecoder(r).Decode(&out); err != nil {
return nil, fmt.Errorf("chatgpt: parse error: %w", err)
}
return out, nil
}
// ── Conversion ───────────────────────────────
// ChatGPTImportResult holds converted channels and messages.
type ChatGPTImportResult struct {
Channels []models.Channel
Messages []models.Message
}
// ConvertChatGPTExport converts ChatGPT conversations to Switchboard models.
func ConvertChatGPTExport(convs ChatGPTExport, userID string) *ChatGPTImportResult {
result := &ChatGPTImportResult{}
for _, conv := range convs {
ch, msgs := convertConversation(conv, userID)
if ch != nil {
result.Channels = append(result.Channels, *ch)
result.Messages = append(result.Messages, msgs...)
}
}
return result
}
func convertConversation(conv ChatGPTConversation, userID string) (*models.Channel, []models.Message) {
channelID := store.NewID()
title := conv.Title
if title == "" {
title = "Imported Conversation"
}
createdAt := floatToTime(conv.CreateTime)
updatedAt := floatToTime(conv.UpdateTime)
if updatedAt.IsZero() {
updatedAt = createdAt
}
ch := &models.Channel{
BaseModel: models.BaseModel{
ID: channelID,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
},
UserID: userID,
Title: title,
Type: "direct",
}
// Walk the DAG depth-first to produce ordered messages
msgs := walkDAG(conv.Mapping, channelID, userID)
return ch, msgs
}
func walkDAG(mapping map[string]ChatGPTMappingNode, channelID, userID string) []models.Message {
// Find root nodes (no parent or parent not in mapping)
var roots []string
for id, node := range mapping {
if node.Parent == nil {
roots = append(roots, id)
} else if _, ok := mapping[*node.Parent]; !ok {
roots = append(roots, id)
}
}
var msgs []models.Message
idMap := make(map[string]string) // chatgpt node ID → switchboard message ID
var walk func(nodeID string, parentMsgID *string, siblingIdx int)
walk = func(nodeID string, parentMsgID *string, siblingIdx int) {
node, ok := mapping[nodeID]
if !ok {
return
}
if node.Message != nil && node.Message.Content.ContentType != "" {
content := extractParts(node.Message.Content.Parts)
if content != "" {
msgID := store.NewID()
idMap[nodeID] = msgID
role := mapRole(node.Message.Author.Role)
createdAt := time.Now().UTC()
if node.Message.CreateTime != nil {
createdAt = floatToTime(*node.Message.CreateTime)
}
msg := models.Message{
BaseModel: models.BaseModel{
ID: msgID,
CreatedAt: createdAt,
UpdatedAt: createdAt,
},
ChannelID: channelID,
Role: role,
Content: content,
ParentID: parentMsgID,
SiblingIndex: siblingIdx,
ParticipantType: "user",
ParticipantID: userID,
}
if role == "assistant" || role == "system" || role == "tool" {
msg.ParticipantType = role
msg.ParticipantID = ""
}
msgs = append(msgs, msg)
parentMsgID = &msgID
}
}
// Sort children for deterministic order
children := make([]string, len(node.Children))
copy(children, node.Children)
sort.Strings(children)
for i, childID := range children {
walk(childID, parentMsgID, i)
}
}
sort.Strings(roots)
for _, root := range roots {
walk(root, nil, 0)
}
return msgs
}
func mapRole(role string) string {
switch role {
case "user":
return "user"
case "assistant":
return "assistant"
case "system":
return "system"
case "tool":
return "tool"
default:
return "user"
}
}
func extractParts(parts []interface{}) string {
var texts []string
for _, part := range parts {
switch v := part.(type) {
case string:
if v != "" {
texts = append(texts, v)
}
}
}
if len(texts) == 0 {
return ""
}
result := texts[0]
for i := 1; i < len(texts); i++ {
result += "\n" + texts[i]
}
return result
}
func floatToTime(f float64) time.Time {
if f <= 0 {
return time.Time{}
}
sec := int64(f)
nsec := int64((f - float64(sec)) * 1e9)
return time.Unix(sec, nsec).UTC()
}

216
server/export/entities.go Normal file
View File

@@ -0,0 +1,216 @@
package export
// entities.go — v0.34.0 CS0
//
// Per-entity sanitization: strips sensitive, instance-specific, or
// non-portable fields before writing to the export archive. Each
// Sanitize* function returns a clean map ready for JSON marshalling.
import (
"chat-switchboard/models"
)
// ── User ─────────────────────────────────────
// SanitizeUser strips password_hash (json:"-" already), vault fields,
// and external_id. The User struct's json tags already exclude
// password_hash, so standard marshalling is safe. We nil out a few
// more fields for cross-instance portability.
func SanitizeUser(u *models.User) *models.User {
out := *u // shallow copy
out.PasswordHash = ""
out.ExternalID = nil
return &out
}
// ── Channel ──────────────────────────────────
// ExportChannel is a Channel without instance-specific provider binding.
type ExportChannel struct {
models.Channel
ProviderConfigID *string `json:"provider_config_id,omitempty"` // always nil in export
}
// SanitizeChannel strips provider_config_id.
func SanitizeChannel(ch *models.Channel) ExportChannel {
return ExportChannel{
Channel: *ch,
ProviderConfigID: nil,
}
}
// SanitizeChannels batch-sanitizes a slice.
func SanitizeChannels(chs []models.Channel) []ExportChannel {
out := make([]ExportChannel, len(chs))
for i := range chs {
chs[i].ProviderConfigID = nil
out[i] = SanitizeChannel(&chs[i])
}
return out
}
// ── Message ──────────────────────────────────
// ExportMessage is a Message without provider_config_id.
type ExportMessage struct {
models.Message
ProviderConfigID *string `json:"provider_config_id,omitempty"` // always nil
}
// SanitizeMessages strips provider_config_id from each message.
func SanitizeMessages(msgs []models.Message) []ExportMessage {
out := make([]ExportMessage, len(msgs))
for i := range msgs {
msgs[i].ProviderConfigID = nil
out[i] = ExportMessage{Message: msgs[i]}
}
return out
}
// ── Channel Model ────────────────────────────
// ExportChannelModel strips provider_config_id and persona_id.
type ExportChannelModel struct {
ID string `json:"id"`
ChannelID string `json:"channel_id"`
ModelID string `json:"model_id"`
Handle string `json:"handle,omitempty"`
DisplayName string `json:"display_name,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
IsDefault bool `json:"is_default"`
}
// SanitizeChannelModels strips instance-specific refs.
func SanitizeChannelModels(cms []models.ChannelModel) []ExportChannelModel {
out := make([]ExportChannelModel, len(cms))
for i, cm := range cms {
out[i] = ExportChannelModel{
ID: cm.ID,
ChannelID: cm.ChannelID,
ModelID: cm.ModelID,
Handle: cm.Handle,
DisplayName: cm.DisplayName,
SystemPrompt: cm.SystemPrompt,
IsDefault: cm.IsDefault,
}
}
return out
}
// ── User Model Settings ──────────────────────
// ExportUserModelSetting strips provider_config_id.
type ExportUserModelSetting struct {
models.UserModelSetting
ProviderConfigID *string `json:"provider_config_id,omitempty"` // always nil
}
// SanitizeUserModelSettings strips instance-specific provider binding.
func SanitizeUserModelSettings(ums []models.UserModelSetting) []ExportUserModelSetting {
out := make([]ExportUserModelSetting, len(ums))
for i := range ums {
ums[i].ProviderConfigID = nil
out[i] = ExportUserModelSetting{UserModelSetting: ums[i]}
}
return out
}
// ── Usage Entry ──────────────────────────────
// ExportUsageEntry strips provider_config_id and routing_decision.
type ExportUsageEntry struct {
ID string `json:"id"`
ChannelID *string `json:"channel_id,omitempty"`
UserID string `json:"user_id"`
ModelID string `json:"model_id"`
Role *string `json:"role,omitempty"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
CacheCreationTokens int `json:"cache_creation_tokens"`
CacheReadTokens int `json:"cache_read_tokens"`
CostInput *float64 `json:"cost_input,omitempty"`
CostOutput *float64 `json:"cost_output,omitempty"`
CreatedAt string `json:"created_at"`
}
// SanitizeUsageEntries strips instance-specific fields.
func SanitizeUsageEntries(ues []models.UsageEntry) []ExportUsageEntry {
out := make([]ExportUsageEntry, len(ues))
for i, ue := range ues {
out[i] = ExportUsageEntry{
ID: ue.ID,
ChannelID: ue.ChannelID,
UserID: ue.UserID,
ModelID: ue.ModelID,
Role: ue.Role,
PromptTokens: ue.PromptTokens,
CompletionTokens: ue.CompletionTokens,
CacheCreationTokens: ue.CacheCreationTokens,
CacheReadTokens: ue.CacheReadTokens,
CostInput: ue.CostInput,
CostOutput: ue.CostOutput,
CreatedAt: ue.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
}
return out
}
// ── Workspace ────────────────────────────────
// ExportWorkspace strips root_path (json:"-" already) and git credentials.
type ExportWorkspace struct {
models.Workspace
GitCredentialID *string `json:"git_credential_id,omitempty"` // always nil
}
// SanitizeWorkspaces strips git credential refs.
func SanitizeWorkspaces(ws []models.Workspace) []ExportWorkspace {
out := make([]ExportWorkspace, len(ws))
for i := range ws {
ws[i].GitCredentialID = nil
ws[i].GitLastSync = nil
out[i] = ExportWorkspace{Workspace: ws[i]}
}
return out
}
// ── Persona ──────────────────────────────────
// ExportPersona strips provider_config_id.
type ExportPersona struct {
models.Persona
ProviderConfigID *string `json:"provider_config_id,omitempty"` // always nil
}
// SanitizePersonas strips instance-specific provider binding.
func SanitizePersonas(ps []models.Persona) []ExportPersona {
out := make([]ExportPersona, len(ps))
for i := range ps {
ps[i].ProviderConfigID = nil
out[i] = ExportPersona{Persona: ps[i]}
}
return out
}
// ── Workflow ─────────────────────────────────
// SanitizeWorkflows strips webhook secrets.
func SanitizeWorkflows(wfs []models.Workflow) []models.Workflow {
out := make([]models.Workflow, len(wfs))
for i := range wfs {
out[i] = wfs[i]
out[i].WebhookSecret = ""
}
return out
}
// Note links use models.ExportNoteLink (includes source_note_id).
// See models/models.go for the type definition.
// Note, Memory, Folder, NotificationPreference,
// ChannelParticipant, ChannelCursor, Project, ProjectChannel,
// ProjectKB, ProjectNote, WorkspaceFile, PersonaGroup,
// PersonaGroupMember, KnowledgeBase, KBDocument, Group,
// GroupMember, ResourceGrant, Team, TeamMember, WorkflowVersion,
// WorkflowStage — these are exported as-is (no sensitive fields
// after query-level exclusion of embeddings).

211
server/export/format.go Normal file
View File

@@ -0,0 +1,211 @@
package export
// format.go — v0.34.0 CS0
//
// Defines the .switchboard archive format: a zip file containing a
// manifest.json and per-entity JSON files under data/. File blobs
// live under files/{id}/{filename}.
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"time"
)
// ── Constants ────────────────────────────────
const (
FormatVersion = 1
MaxExportArchiveSize = 500 * 1024 * 1024 // 500 MB total
MaxExportFileSize = 100 * 1024 * 1024 // 100 MB per file blob
MaxExportFiles = 10000
ExportContentType = "application/zip"
ExportExtension = ".switchboard"
)
// ── Manifest ─────────────────────────────────
// Manifest is the top-level metadata written to manifest.json.
type Manifest struct {
Version string `json:"version"` // server version, e.g. "0.34.0"
FormatVersion int `json:"format_version"` // always 1 for now
ExportType string `json:"export_type"` // "user", "team"
CreatedAt time.Time `json:"created_at"`
ExportedBy string `json:"exported_by"` // user ID
EntityCounts map[string]int `json:"entity_counts"`
Scope *ManifestScope `json:"scope,omitempty"`
}
// ManifestScope narrows the export to a user or team.
type ManifestScope struct {
UserID string `json:"user_id,omitempty"`
TeamID string `json:"team_id,omitempty"`
}
// ── ArchiveWriter ────────────────────────────
// ArchiveWriter wraps zip.Writer with size tracking and convenience
// methods for writing manifest and entity JSON files.
type ArchiveWriter struct {
zw *zip.Writer
written int64
maxBytes int64
}
// NewArchiveWriter creates an ArchiveWriter that streams to w.
func NewArchiveWriter(w io.Writer) *ArchiveWriter {
return &ArchiveWriter{
zw: zip.NewWriter(w),
maxBytes: MaxExportArchiveSize,
}
}
// WriteManifest serializes m as indented JSON into manifest.json.
func (aw *ArchiveWriter) WriteManifest(m *Manifest) error {
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return fmt.Errorf("export: marshal manifest: %w", err)
}
return aw.writeEntry("manifest.json", data)
}
// WriteEntityJSON serializes data as indented JSON into data/{name}.json.
func (aw *ArchiveWriter) WriteEntityJSON(name string, data interface{}) (int, error) {
buf, err := json.MarshalIndent(data, "", " ")
if err != nil {
return 0, fmt.Errorf("export: marshal %s: %w", name, err)
}
if err := aw.writeEntry("data/"+name+".json", buf); err != nil {
return 0, err
}
// Return entity count for manifest
switch v := data.(type) {
case []interface{}:
return len(v), nil
default:
// Use json.RawMessage length heuristic — caller tracks count
return 0, nil
}
}
// WriteFile streams a file blob into files/{zipPath}. Returns bytes
// written or an error if the file exceeds MaxExportFileSize.
func (aw *ArchiveWriter) WriteFile(zipPath string, r io.Reader) (int64, error) {
if aw.written+1 > aw.maxBytes {
return 0, fmt.Errorf("export: archive size limit exceeded")
}
f, err := aw.zw.Create("files/" + zipPath)
if err != nil {
return 0, fmt.Errorf("export: create zip entry %s: %w", zipPath, err)
}
lr := io.LimitReader(r, MaxExportFileSize+1)
n, err := io.Copy(f, lr)
if err != nil {
return n, fmt.Errorf("export: write file %s: %w", zipPath, err)
}
if n > MaxExportFileSize {
return n, fmt.Errorf("export: file %s exceeds %d bytes", zipPath, MaxExportFileSize)
}
aw.written += n
return n, nil
}
// Close finalizes the zip archive.
func (aw *ArchiveWriter) Close() error {
return aw.zw.Close()
}
// BytesWritten returns the approximate bytes written so far.
func (aw *ArchiveWriter) BytesWritten() int64 {
return aw.written
}
// ── ArchiveReader ────────────────────────────
// ArchiveReader wraps zip.ReadCloser with validation helpers.
type ArchiveReader struct {
zr *zip.ReadCloser
}
// OpenArchive opens and validates a .switchboard zip archive.
func OpenArchive(path string) (*ArchiveReader, error) {
zr, err := zip.OpenReader(path)
if err != nil {
return nil, fmt.Errorf("export: open archive: %w", err)
}
return &ArchiveReader{zr: zr}, nil
}
// ReadManifest reads and validates manifest.json from the archive.
func (ar *ArchiveReader) ReadManifest() (*Manifest, error) {
for _, f := range ar.zr.File {
if f.Name == "manifest.json" {
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("export: open manifest: %w", err)
}
defer rc.Close()
var m Manifest
if err := json.NewDecoder(rc).Decode(&m); err != nil {
return nil, fmt.Errorf("export: decode manifest: %w", err)
}
if m.FormatVersion > FormatVersion {
return nil, fmt.Errorf("export: unsupported format version %d (max %d)", m.FormatVersion, FormatVersion)
}
return &m, nil
}
}
return nil, fmt.Errorf("export: manifest.json not found in archive")
}
// ReadEntityJSON reads data/{name}.json into target.
func (ar *ArchiveReader) ReadEntityJSON(name string, target interface{}) error {
path := "data/" + name + ".json"
for _, f := range ar.zr.File {
if f.Name == path {
rc, err := f.Open()
if err != nil {
return fmt.Errorf("export: open %s: %w", path, err)
}
defer rc.Close()
if err := json.NewDecoder(rc).Decode(target); err != nil {
return fmt.Errorf("export: decode %s: %w", path, err)
}
return nil
}
}
// Entity file not present — not an error (sparse archives are valid)
return nil
}
// FileEntries returns zip entries under files/.
func (ar *ArchiveReader) FileEntries() []*zip.File {
var out []*zip.File
for _, f := range ar.zr.File {
if len(f.Name) > 6 && f.Name[:6] == "files/" && !f.FileInfo().IsDir() {
out = append(out, f)
}
}
return out
}
// Close closes the underlying zip reader.
func (ar *ArchiveReader) Close() error {
return ar.zr.Close()
}
// writeEntry writes raw bytes as a zip entry, tracking total size.
func (aw *ArchiveWriter) writeEntry(name string, data []byte) error {
if aw.written+int64(len(data)) > aw.maxBytes {
return fmt.Errorf("export: archive size limit exceeded writing %s", name)
}
f, err := aw.zw.Create(name)
if err != nil {
return fmt.Errorf("export: create entry %s: %w", name, err)
}
n, err := f.Write(data)
aw.written += int64(n)
return err
}