Created
March 21, 2025 03:21
-
-
Save iamkahvi/2938720a34e451510c45aa2d44eb1092 to your computer and use it in GitHub Desktop.
A script to convert between ChatGPT formatted logs and T3.chat logs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as fs from 'fs'; | |
import * as path from 'path'; | |
// Type definitions for T3 Chat format | |
interface T3Thread { | |
title: string; | |
user_edited_title: boolean; | |
status: "done" | "deleted"; | |
model: string; | |
id: string; | |
created_at: string; | |
last_message_at: string; | |
updated_at: string; | |
} | |
interface T3Message { | |
threadId: string; | |
role: "user" | "assistant" | "system"; | |
content: string; | |
status: "done" | "deleted"; | |
model: string; | |
modelParams?: { | |
reasoningEffort: string; | |
includeSearch: boolean; | |
}; | |
id: string; | |
created_at: string; | |
providerMetadata?: any; | |
attachments?: any[]; | |
reasoning?: string; | |
updated_at?: string; | |
} | |
interface T3ChatData { | |
threads: T3Thread[]; | |
messages: T3Message[]; | |
} | |
// Type definitions for Conversation format | |
type AuthorRole = "system" | "user" | "assistant"; | |
interface Author { | |
role: AuthorRole; | |
name: null | string; | |
metadata: Record<string, any>; | |
} | |
interface BaseContent { | |
content_type: string; | |
} | |
interface TextContent extends BaseContent { | |
content_type: "text"; | |
parts: string[]; | |
} | |
interface UserEditableContent extends BaseContent { | |
content_type: "user_editable_context"; | |
user_profile: string; | |
user_instructions: string; | |
} | |
interface CodeContent extends BaseContent { | |
content_type: "code"; | |
language: string; | |
response_format_name: null | string; | |
text: string; | |
} | |
type Content = TextContent | UserEditableContent | CodeContent; | |
interface ConversationMessage { | |
id: string; | |
author: Author; | |
create_time: number | null; | |
update_time: number | null; | |
content: Content; | |
status: string; | |
end_turn: boolean | null; | |
weight: number; | |
metadata: Record<string, any>; | |
recipient: "all" | "web"; | |
channel: null | string; | |
} | |
interface MappingNode { | |
id: string; | |
message: ConversationMessage | null; | |
parent: string | null; | |
children: string[]; | |
} | |
interface Mapping { | |
[key: string]: MappingNode; | |
} | |
interface Conversation { | |
title: string; | |
create_time: number; | |
update_time: number; | |
mapping: Mapping; | |
moderation_results: any[]; | |
current_node: string; | |
plugin_ids: null | string[]; | |
conversation_id: string; | |
conversation_template_id: null | string; | |
gizmo_id: null | string; | |
gizmo_type: null | string; | |
is_archived: boolean; | |
is_starred: null | boolean; | |
safe_urls: string[]; | |
default_model_slug: string; | |
conversation_origin: null | string; | |
voice: null | string; | |
async_status: null | string; | |
disabled_tool_ids: string[]; | |
id: string; | |
} | |
type ConversationList = Conversation[]; | |
/** | |
* Converts T3 Chat format to Conversation format | |
*/ | |
function convertT3ToConversation(t3Data: T3ChatData): ConversationList { | |
const conversations: ConversationList = []; | |
// Group messages by thread | |
const messagesByThread = groupMessagesByThread(t3Data.messages); | |
// Process each thread | |
for (const thread of t3Data.threads) { | |
const threadMessages = messagesByThread[thread.id] || []; | |
// Skip empty threads | |
if (threadMessages.length === 0) { | |
continue; | |
} | |
// Build the mapping structure | |
const { mapping, currentNode } = buildMappingFromMessages(threadMessages); | |
// Create the conversation object | |
const conversation: Conversation = { | |
title: thread.title, | |
create_time: new Date(thread.created_at).getTime() / 1000, | |
update_time: new Date(thread.updated_at).getTime() / 1000, | |
mapping: mapping, | |
moderation_results: [], | |
current_node: currentNode, | |
plugin_ids: null, | |
conversation_id: thread.id, | |
conversation_template_id: null, | |
gizmo_id: null, | |
gizmo_type: null, | |
is_archived: thread.status === "deleted", | |
is_starred: null, | |
safe_urls: [], | |
default_model_slug: thread.model, | |
conversation_origin: null, | |
voice: null, | |
async_status: null, | |
disabled_tool_ids: [], | |
id: thread.id | |
}; | |
conversations.push(conversation); | |
} | |
return conversations; | |
} | |
/** | |
* Converts Conversation format to T3 Chat format | |
*/ | |
function convertConversationToT3(convData: ConversationList): T3ChatData { | |
const threads: T3Thread[] = []; | |
const messages: T3Message[] = []; | |
for (const conversation of convData) { | |
// Skip conversations with invalid mapping | |
if (!conversation.mapping || typeof conversation.mapping !== 'object') { | |
console.warn(`Skipping conversation "${conversation.title}" due to invalid mapping`); | |
continue; | |
} | |
// Create thread | |
const thread: T3Thread = { | |
id: conversation.id, | |
title: conversation.title || "Untitled Conversation", | |
user_edited_title: false, // Default value, can't determine from conversation format | |
status: conversation.is_archived ? "deleted" : "done", | |
model: conversation.default_model_slug || "unknown", | |
created_at: new Date(conversation.create_time * 1000).toISOString(), | |
updated_at: new Date(conversation.update_time * 1000).toISOString(), | |
last_message_at: new Date(conversation.update_time * 1000).toISOString() // Approximation | |
}; | |
threads.push(thread); | |
try { | |
// Extract messages from mapping | |
const extractedMessages = extractMessagesFromMapping(conversation.mapping, conversation.id); | |
messages.push(...extractedMessages); | |
} catch (error) { | |
console.warn(`Error extracting messages from conversation "${conversation.title}": ${error.message}`); | |
// Continue with next conversation instead of failing completely | |
} | |
} | |
return { threads, messages }; | |
} | |
/** | |
* Groups messages by their thread ID | |
*/ | |
function groupMessagesByThread(messages: T3Message[]): Record<string, T3Message[]> { | |
const result: Record<string, T3Message[]> = {}; | |
for (const message of messages) { | |
if (!result[message.threadId]) { | |
result[message.threadId] = []; | |
} | |
result[message.threadId].push(message); | |
} | |
return result; | |
} | |
/** | |
* Builds a mapping structure from T3 messages | |
*/ | |
function buildMappingFromMessages(messages: T3Message[]): { mapping: Mapping, currentNode: string } { | |
const mapping: Mapping = {}; | |
let currentNode = ""; | |
// Sort messages by creation time to establish order | |
const sortedMessages = [...messages].sort((a, b) => { | |
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime(); | |
}); | |
// Create root node | |
const rootId = "client-created-root"; | |
mapping[rootId] = { | |
id: rootId, | |
message: null, | |
parent: null, | |
children: [] | |
}; | |
let previousNodeId = rootId; | |
// Process each message and build the tree | |
for (const message of sortedMessages) { | |
const nodeId = message.id; | |
// Create the message content | |
const content: TextContent = { | |
content_type: "text", | |
parts: [message.content] | |
}; | |
// Create the conversation message | |
const conversationMessage: ConversationMessage = { | |
id: message.id, | |
author: { | |
role: message.role as AuthorRole, | |
name: null, | |
metadata: {} | |
}, | |
create_time: new Date(message.created_at).getTime() / 1000, | |
update_time: message.updated_at ? new Date(message.updated_at).getTime() / 1000 : null, | |
content: content, | |
status: message.status === "done" ? "finished_successfully" : message.status, | |
end_turn: message.role === "assistant", | |
weight: 1.0, | |
metadata: { | |
...(message.providerMetadata || {}), | |
model_slug: message.model, | |
default_model_slug: message.model | |
}, | |
recipient: "all", | |
channel: null | |
}; | |
// Create the mapping node | |
mapping[nodeId] = { | |
id: nodeId, | |
message: conversationMessage, | |
parent: previousNodeId, | |
children: [] | |
}; | |
// Add this node as a child of the previous node | |
mapping[previousNodeId].children.push(nodeId); | |
// Update previous node for next iteration | |
previousNodeId = nodeId; | |
// Update current node (last node in the conversation) | |
currentNode = nodeId; | |
} | |
return { mapping, currentNode }; | |
} | |
/** | |
* Extracts T3 messages from a conversation mapping | |
*/ | |
function extractMessagesFromMapping(mapping: Mapping, threadId: string): T3Message[] { | |
const messages: T3Message[] = []; | |
const rootId = "client-created-root"; | |
// Check if root node exists | |
if (!mapping[rootId]) { | |
console.warn(`Root node "client-created-root" not found in conversation ${threadId}. Using first available node.`); | |
// Use the first node as root if client-created-root doesn't exist | |
const firstNodeId = Object.keys(mapping)[0]; | |
if (!firstNodeId) { | |
throw new Error(`No nodes found in mapping for conversation ${threadId}`); | |
} | |
traverseNode(firstNodeId); | |
} else { | |
traverseNode(rootId); | |
} | |
// Traverse the tree in order | |
function traverseNode(nodeId: string) { | |
const node = mapping[nodeId]; | |
// Skip if node doesn't exist | |
if (!node) { | |
console.warn(`Node ${nodeId} referenced but not found in mapping`); | |
return; | |
} | |
// Skip the root node as it doesn't have a message | |
if (nodeId !== rootId && node.message) { | |
try { | |
const message = convertNodeToT3Message(node, threadId); | |
messages.push(message); | |
} catch (error) { | |
console.warn(`Error converting node ${nodeId}: ${error.message}`); | |
// Continue with next node | |
} | |
} | |
// Recursively process children | |
if (node.children && Array.isArray(node.children)) { | |
for (const childId of node.children) { | |
traverseNode(childId); | |
} | |
} | |
} | |
return messages; | |
} | |
/** | |
* Converts a mapping node to a T3 message | |
*/ | |
function convertNodeToT3Message(node: MappingNode, threadId: string): T3Message { | |
if (!node.message) { | |
throw new Error(`Node ${node.id} has no message`); | |
} | |
const message = node.message; | |
// Extract content from different content types | |
let content = ""; | |
if (message.content.content_type === "text") { | |
content = (message.content as TextContent).parts.join("\n"); | |
} else if (message.content.content_type === "user_editable_context") { | |
content = (message.content as UserEditableContent).user_instructions; | |
} else if (message.content.content_type === "code") { | |
content = (message.content as CodeContent).text; | |
} | |
// Create T3 message | |
const t3Message: T3Message = { | |
threadId: threadId, | |
role: message.author.role, | |
content: content, | |
status: message.status === "finished_successfully" ? "done" : "deleted", | |
model: message.metadata?.model_slug || "unknown", | |
id: message.id, | |
created_at: message.create_time | |
? new Date(message.create_time * 1000).toISOString() | |
: new Date().toISOString(), | |
providerMetadata: message.metadata || {} | |
}; | |
// Add optional fields if present | |
if (message.update_time) { | |
t3Message.updated_at = new Date(message.update_time * 1000).toISOString(); | |
} | |
if (message.metadata?.reasoning) { | |
t3Message.reasoning = message.metadata.reasoning; | |
} | |
return t3Message; | |
} | |
/** | |
* Detects the format of the input data | |
*/ | |
function detectFormat(data: any): 'T3' | 'Conversation' | 'Unknown' { | |
if (data && typeof data === 'object') { | |
// Check for T3 format | |
if (Array.isArray(data.threads) && Array.isArray(data.messages)) { | |
return 'T3'; | |
} | |
// Check for Conversation format (array of conversations) | |
if (Array.isArray(data) && data.length > 0 && | |
data[0].mapping && | |
(data[0].conversation_id || data[0].id)) { | |
return 'Conversation'; | |
} | |
} | |
return 'Unknown'; | |
} | |
/** | |
* Main function to handle file conversion | |
*/ | |
function main() { | |
// Check command line arguments | |
if (process.argv.length < 4) { | |
console.error('Usage: node chat-converter.js <input-file.json> <output-file.json>'); | |
process.exit(1); | |
} | |
const inputFile = process.argv[2]; | |
const outputFile = process.argv[3]; | |
try { | |
// Read input file | |
console.log(`Reading input file: ${inputFile}`); | |
const inputData = JSON.parse(fs.readFileSync(inputFile, 'utf8')); | |
// Detect format | |
const format = detectFormat(inputData); | |
let outputData; | |
// Convert based on detected format | |
if (format === 'T3') { | |
console.log('Detected T3 format. Converting to Conversation format...'); | |
outputData = convertT3ToConversation(inputData); | |
} else if (format === 'Conversation') { | |
console.log('Detected Conversation format. Converting to T3 format...'); | |
outputData = convertConversationToT3(inputData); | |
} else { | |
console.error('Unknown data format. Please provide valid T3 or Conversation format data.'); | |
process.exit(1); | |
} | |
// Write output file | |
console.log(`Writing output to: ${outputFile}`); | |
fs.writeFileSync(outputFile, JSON.stringify(outputData, null, 2)); | |
console.log(`Conversion complete. Output written to ${outputFile}`); | |
} catch (error) { | |
console.error('Error:', error.message); | |
process.exit(1); | |
} | |
} | |
// Run the main function | |
main(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment