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" "switchboard-core/models" "switchboard-core/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() }