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:
244
server/export/chatgpt.go
Normal file
244
server/export/chatgpt.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user