This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/export/chatgpt.go
Jeffrey Smith 11fd8c1e57 step 1: rename module chat-switchboard → switchboard-core
- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
2026-03-25 19:48:04 -04:00

245 lines
6.0 KiB
Go

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()
}