Compare commits

...

1 Commits

Author SHA1 Message Date
claude[bot] 74feb4964d feat: add TypingMind JSON import support
Implements import functionality for TypingMind chat exports:

- Server-side parser with Zod validation (relaxed schema)
- tRPC endpoint for JSON parsing and validation
- UI integration with file picker and batch import
- UUID preservation for chats and messages
- Timestamp conversion (ISO → Unix ms)
- Per-chat error handling with detailed feedback
- Support for both string and array content formats

Limitations:
- Attachments/images referenced but not downloaded
- Folder structure not preserved (flat import)
- Plugins/tools ignored (text-only)

Co-authored-by: Enrico Ros <enricoros@users.noreply.github.com>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 20:35:03 +00:00
3 changed files with 279 additions and 1 deletions
+132 -1
View File
@@ -11,11 +11,14 @@ import { KeyStroke } from '~/common/components/KeyStroke';
import { OpenAIIcon } from '~/common/components/icons/vendors/OpenAIIcon';
import { apiAsyncNode } from '~/common/util/trpc.client';
import { createDConversation, DConversationId } from '~/common/stores/chat/chat.conversation';
import { createDMessageTextContent, DMessage } from '~/common/stores/chat/chat.message';
import { createDMessageTextContent, DMessage, createDMessageFromFragments } from '~/common/stores/chat/chat.message';
import { createTextContentFragment } from '~/common/stores/chat/chat.fragments';
import { useChatStore } from '~/common/stores/chat/store-chats';
import { useFormRadio } from '~/common/components/forms/useFormRadio';
import type { ChatGptSharedChatSchema } from './server/chatgpt';
import type { TypingMindExportSchema, TypingMindChatSchema } from './server/typingmind';
import { extractMessageText } from './server/typingmind';
import { importConversationsFromFilesAtRest, openConversationsAtRestPicker } from './trade.client';
import { FlashRestore } from './BackupRestore';
@@ -41,6 +44,7 @@ export function ImportChats(props: { onConversationActivate: (conversationId: DC
const [chatGptEdit, setChatGptEdit] = React.useState(false);
const [chatGptUrl, setChatGptUrl] = React.useState('');
const [chatGptSource, setChatGptSource] = React.useState('');
const [typingMindEdit, setTypingMindEdit] = React.useState(false);
const [importJson, setImportJson] = React.useState<string | null>(null);
const [importOutcome, setImportOutcome] = React.useState<ImportedOutcome | null>(null);
@@ -122,6 +126,72 @@ export function ImportChats(props: { onConversationActivate: (conversationId: DC
props.onClose();
};
const handleTypingMindToggleShown = () => setTypingMindEdit(!typingMindEdit);
const handleTypingMindImport = async () => {
setImportJson(null);
// user selects a file
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.json,application/json';
fileInput.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const outcome: ImportedOutcome = { conversations: [], activateConversationId: null };
try {
const jsonContent = await file.text();
// parse and validate with tRPC endpoint
const data: TypingMindExportSchema = await apiAsyncNode.trade.importTypingMindExport.mutate({ jsonContent });
// save raw JSON for inspection
setImportJson(jsonContent);
// convert chats to Big-AGI conversations
const chats = data.data.chats || [];
if (chats.length === 0) {
outcome.conversations.push({ fileName: file.name, success: false, error: 'No chats found in export file' });
setImportOutcome(outcome);
return;
}
for (const chat of chats) {
try {
const conversation = convertTypingMindChatToConversation(chat);
// import the conversation
useChatStore.getState().importConversation(conversation, false);
// set as activation candidate
outcome.activateConversationId = conversation.id;
outcome.conversations.push({ success: true, fileName: file.name, conversation });
} catch (error) {
outcome.conversations.push({
fileName: `${file.name} (${chat.chatTitle || chat.chatID})`,
success: false,
error: (error as any)?.message || error?.toString() || 'unknown error'
});
}
}
// activate the last imported conversation
if (outcome.activateConversationId)
props.onConversationActivate(outcome.activateConversationId);
} catch (error) {
outcome.conversations.push({ fileName: file.name, success: false, error: (error as any)?.message || error?.toString() || 'unknown error' });
}
setImportOutcome(outcome);
};
fileInput.click();
};
return <>
@@ -150,6 +220,13 @@ export function ImportChats(props: { onConversationActivate: (conversationId: DC
</Button>
)}
<Button
variant='soft' endDecorator={<FileUploadIcon />} sx={{ minWidth: 240, justifyContent: 'space-between' }}
onClick={handleTypingMindImport}
>
TypingMind · JSON
</Button>
{/* Insert to Restore a Flash */}
<FlashRestore unlockRestore={true} />
@@ -194,4 +271,58 @@ export function ImportChats(props: { onConversationActivate: (conversationId: DC
{!!importOutcome && <ImportOutcomeModal outcome={importOutcome} rawJson={importJson} onClose={handleImportOutcomeClosed} />}
</>;
}
/**
* Convert a TypingMind chat to a Big-AGI DConversation
*/
function convertTypingMindChatToConversation(chat: TypingMindChatSchema): DConversation {
const conversation = createDConversation();
// preserve the original ID if valid
conversation.id = chat.chatID;
// set title
conversation.autoTitle = chat.chatTitle || 'Untitled Chat';
// parse timestamps
conversation.created = new Date(chat.createdAt).getTime();
conversation.updated = new Date(chat.updatedAt).getTime();
// convert messages
conversation.messages = chat.messages
.map((tmMsg): DMessage | null => {
try {
// extract text from content (handles both string and array formats)
const messageText = extractMessageText(tmMsg.content);
if (!messageText || messageText.trim().length === 0)
return null;
// create message with text fragment
const message = createDMessageFromFragments(
tmMsg.role,
[createTextContentFragment(messageText)]
);
// preserve original ID
message.id = tmMsg.uuid;
// set timestamp
if (tmMsg.createdAt)
message.created = new Date(tmMsg.createdAt).getTime();
// mark as complete
message.updated = message.created;
return message;
} catch (error) {
console.warn('Failed to convert TypingMind message:', error, tmMsg);
return null;
}
})
.filter((msg): msg is DMessage => msg !== null);
return conversation;
}
+11
View File
@@ -6,6 +6,7 @@ import { fetchTextOrTRPCThrow } from '~/server/trpc/trpc.router.fetchers';
import { chatGptParseConversation, chatGptSharedChatSchema } from './chatgpt';
import { storageGetProcedure, storageMarkAsDeletedProcedure, storagePutProcedure, storageUpdateDeletionKeyProcedure } from './link';
import { typingMindExportSchema, typingMindParseExport } from './typingmind';
export const importChatGptShareInputSchema = z.union([
@@ -53,6 +54,16 @@ export const tradeRouter = createTRPCRouter({
};
}),
/** TypingMind Export Importer */
importTypingMindExport: publicProcedure
.input(z.object({
jsonContent: z.string(),
}))
.output(typingMindExportSchema)
.mutation(async ({ input }) => {
return typingMindParseExport(input.jsonContent);
}),
/**
* Write an object to storage, and return the ID, owner, and deletion key
*/
+136
View File
@@ -0,0 +1,136 @@
import { TRPCError } from '@trpc/server';
import * as z from 'zod/v4';
// TypingMind Message Content Schema
const typingMindContentBlockSchema = z.object({
type: z.string(), // 'text', 'image_url', etc.
text: z.string().optional(),
image_url: z.object({
url: z.string(),
}).optional(),
}).passthrough(); // allow extra fields for future compatibility
// TypingMind Message Schema
const typingMindMessageSchema = z.object({
uuid: z.string(),
role: z.enum(['user', 'assistant', 'system']),
content: z.union([
z.string(), // sometimes it's just a string
z.array(typingMindContentBlockSchema), // or array of content blocks
]),
createdAt: z.string(), // ISO timestamp
model: z.string().optional(),
usage: z.object({
prompt_tokens: z.number().optional(),
completion_tokens: z.number().optional(),
total_tokens: z.number().optional(),
}).passthrough().optional(),
}).passthrough();
// TypingMind Chat Schema
const typingMindChatSchema = z.object({
chatID: z.string(),
chatTitle: z.string().optional(),
messages: z.array(typingMindMessageSchema),
createdAt: z.string(), // ISO timestamp
updatedAt: z.string(), // ISO timestamp
model: z.string().optional(),
folderID: z.string().optional(),
}).passthrough();
// TypingMind Folder Schema (for context, but not imported)
const typingMindFolderSchema = z.object({
id: z.string(),
title: z.string(),
}).passthrough();
// Top-level export schema
export const typingMindExportSchema = z.object({
data: z.object({
chats: z.array(typingMindChatSchema),
folders: z.array(typingMindFolderSchema).optional(),
// other fields like userPrompts, userCharacters, etc. are ignored
}).passthrough(),
}).passthrough();
export type TypingMindExportSchema = z.infer<typeof typingMindExportSchema>;
export type TypingMindChatSchema = z.infer<typeof typingMindChatSchema>;
export type TypingMindMessageSchema = z.infer<typeof typingMindMessageSchema>;
/**
* Parse and validate TypingMind export JSON
*/
export function typingMindParseExport(jsonContent: string): TypingMindExportSchema {
let jsonObject: unknown;
try {
jsonObject = JSON.parse(jsonContent);
} catch (error: any) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Failed to parse JSON: ${error?.message}`,
});
}
// validate with relaxed schema (passthrough allows extra fields)
const safeJson = typingMindExportSchema.safeParse(jsonObject);
if (!safeJson.success) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Invalid TypingMind export format: ${safeJson.error.message}`,
});
}
return safeJson.data;
}
/**
* Extract text from message content (handles both string and array formats)
*/
export function extractMessageText(content: string | z.infer<typeof typingMindContentBlockSchema>[]): string {
if (typeof content === 'string') {
return content;
}
// array of content blocks
return content
.map(block => {
if (block.text) return block.text;
if (block.image_url?.url) return `[Image: ${block.image_url.url}]`;
return '';
})
.filter(Boolean)
.join('\n');
}
/**
* Detect image/attachment references in message content
*/
export function detectAttachmentReferences(content: string | z.infer<typeof typingMindContentBlockSchema>[]): string[] {
const urls: string[] = [];
if (typeof content === 'string') {
// scan for markdown image syntax or URLs
const imageRegex = /!\[.*?\]\((https?:\/\/[^\)]+)\)/g;
const urlRegex = /https?:\/\/[^\s]+\.(png|jpg|jpeg|gif|webp|pdf|doc|docx)/gi;
let match;
while ((match = imageRegex.exec(content)) !== null) {
urls.push(match[1]);
}
while ((match = urlRegex.exec(content)) !== null) {
urls.push(match[0]);
}
} else {
// array of content blocks
content.forEach(block => {
if (block.image_url?.url) {
urls.push(block.image_url.url);
}
});
}
return urls;
}