Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
245 lines
6.0 KiB
Go
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"
|
|
|
|
"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()
|
|
}
|