Skip to content

Instantly share code, notes, and snippets.

@iamkahvi
Created March 21, 2025 03:21
Show Gist options
  • Save iamkahvi/2938720a34e451510c45aa2d44eb1092 to your computer and use it in GitHub Desktop.
Save iamkahvi/2938720a34e451510c45aa2d44eb1092 to your computer and use it in GitHub Desktop.
A script to convert between ChatGPT formatted logs and T3.chat logs
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