Compare commits

...

1 Commits

Author SHA1 Message Date
claude[bot] a4794ae034 feat: add TypingMind JSON import support
- Add typingmind.ts parser with looseObject Zod schemas
- Minimal tRPC endpoint in existing trade router
- File picker UI integration in ImportChats.tsx
- Handles string and array message content formats
- Preserves UUIDs and converts ISO timestamps
- Batch import with per-chat error handling

Co-authored-by: Enrico Ros <enricoros@users.noreply.github.com>
2025-11-23 21:56:05 +00:00
3 changed files with 245 additions and 1 deletions
+123 -1
View File
@@ -16,6 +16,8 @@ import { useChatStore } from '~/common/stores/chat/store-chats';
import { useFormRadio } from '~/common/components/forms/useFormRadio';
import type { ChatGptSharedChatSchema } from './server/chatgpt';
import type { TypingMindChatSchema, TypingMindExportSchema } from './server/typingmind';
import { extractMessageText } from './server/typingmind';
import { importConversationsFromFilesAtRest, openConversationsAtRestPicker } from './trade.client';
import { FlashRestore } from './BackupRestore';
@@ -30,9 +32,66 @@ const chatGptMedia: FormRadioOption<'source' | 'link'>[] = [
{ label: 'Page Source', value: 'source' },
];
/**
* Convert a TypingMind chat to a Big-AGI conversation
*/
function convertTypingMindChatToConversation(chat: TypingMindChatSchema) {
const conversation = createDConversation();
// Use chat ID if available
if (chat.chatID || chat.id)
conversation.id = (chat.chatID || chat.id) as DConversationId;
// Set title
conversation.autoTitle = chat.chatTitle || chat.preview || undefined;
// Set timestamps (convert ISO to Unix ms)
if (chat.createdAt) {
const created = new Date(chat.createdAt).getTime();
if (!isNaN(created))
conversation.created = created;
}
if (chat.updatedAt) {
const updated = new Date(chat.updatedAt).getTime();
if (!isNaN(updated))
conversation.updated = updated;
}
// Convert messages
const messages = chat.messages || [];
conversation.messages = messages
.map(msg => {
const role = msg.role;
if (role !== 'user' && role !== 'assistant') return null;
const text = extractMessageText(msg.content);
if (!text || text.length === 0) return null;
const dMessage = createDMessageTextContent(role, text);
// Preserve message ID if available
if (msg.uuid)
dMessage.id = msg.uuid;
// Set timestamp
if (msg.createdAt) {
const created = new Date(msg.createdAt).getTime();
if (!isNaN(created))
dMessage.created = created;
}
return dMessage;
})
.filter(msg => !!msg) as DMessage[];
return conversation;
}
/**
* Components and functionality to import conversations
* Supports our own JSON files, and ChatGPT Share Links
* Supports our own JSON files, ChatGPT Share Links, and TypingMind exports
*/
export function ImportChats(props: { onConversationActivate: (conversationId: DConversationId) => void, onClose: () => void }) {
@@ -62,6 +121,62 @@ export function ImportChats(props: { onConversationActivate: (conversationId: DC
};
const handleTypingMindImport = async () => {
// Open file picker for JSON
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const outcome: ImportedOutcome = { conversations: [], activateConversationId: null };
try {
// Read file content
const jsonString = await file.text();
// Parse via tRPC
const exportData = await apiAsyncNode.trade.importTypingMindExport.mutate({ jsonExport: jsonString });
// Convert chats to conversations
const chats = exportData?.data?.chats || [];
if (chats.length === 0) {
outcome.conversations.push({ fileName: file.name, success: false, error: 'No chats found in export' });
setImportOutcome(outcome);
return;
}
// Import each chat
for (const chat of chats) {
try {
const conversation = convertTypingMindChatToConversation(chat);
if (conversation.messages.length >= 1) {
useChatStore.getState().importConversation(conversation, false);
outcome.conversations.push({ success: true, fileName: chat.chatTitle || chat.chatID || 'Untitled', conversation });
outcome.activateConversationId = conversation.id; // Last imported will be activated
} else {
outcome.conversations.push({ success: false, fileName: chat.chatTitle || chat.chatID || 'Untitled', error: 'No messages found' });
}
} catch (error) {
outcome.conversations.push({ success: false, fileName: chat.chatTitle || chat.chatID || 'Untitled', 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);
};
input.click();
};
const handleChatGptToggleShown = () => setChatGptEdit(!chatGptEdit);
const handleChatGptLoad = async () => {
@@ -140,6 +255,13 @@ export function ImportChats(props: { onConversationActivate: (conversationId: DC
</Button>
</GoodTooltip>
<Button
variant='soft' endDecorator={<FileUploadIcon />} sx={{ minWidth: 240, justifyContent: 'space-between' }}
onClick={handleTypingMindImport}
>
TypingMind · Export
</Button>
{!chatGptEdit && (
<Button
variant='soft' endDecorator={<OpenAIIcon />} sx={{ minWidth: 240, justifyContent: 'space-between' }}
+9
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,14 @@ export const tradeRouter = createTRPCRouter({
};
}),
/** TypingMind Export Importer */
importTypingMindExport: publicProcedure
.input(z.object({ jsonExport: z.string() }))
.output(typingMindExportSchema)
.mutation(async ({ input }) => {
return typingMindParseExport(input.jsonExport);
}),
/**
* Write an object to storage, and return the ID, owner, and deletion key
*/
+113
View File
@@ -0,0 +1,113 @@
import { TRPCError } from '@trpc/server';
import * as z from 'zod/v4';
/**
* TypingMind Message Content Block
* Can be a text block or other types (images, etc.)
*/
const typingMindContentBlockSchema = z.looseObject({
type: z.string().optional(), // 'text', etc.
text: z.string().optional(),
});
/**
* TypingMind Message
* Messages can have content as string OR array of content blocks
*/
const typingMindMessageSchema = z.looseObject({
uuid: z.string().optional(),
role: z.enum(['user', 'assistant', 'system', 'tool']).optional(),
content: z.union([
z.string(), // Simple string format
z.array(typingMindContentBlockSchema), // Array of content blocks
]).optional(),
createdAt: z.string().optional(), // ISO timestamp
model: z.string().optional(),
usage: z.any().optional(),
});
/**
* TypingMind Chat/Conversation
*/
const typingMindChatSchema = z.looseObject({
id: z.string().optional(),
chatID: z.string().optional(),
chatTitle: z.string().optional(),
preview: z.string().optional(),
createdAt: z.string().optional(), // ISO timestamp
updatedAt: z.string().optional(), // ISO timestamp
messages: z.array(typingMindMessageSchema).optional(),
model: z.string().optional(),
folderID: z.string().optional(),
// Allow all other fields (plugins, settings, etc.)
});
/**
* TypingMind Export Format
* Top-level structure contains data object with various collections
*/
export const typingMindExportSchema = z.looseObject({
data: z.looseObject({
chats: z.array(typingMindChatSchema).optional(),
folders: z.array(z.any()).optional(), // Not imported
userPrompts: z.array(z.any()).optional(), // Not imported
userCharacters: z.array(z.any()).optional(), // Not imported
installedPlugins: z.array(z.any()).optional(), // Not imported
// Allow all other fields
}).optional(),
});
export type TypingMindExportSchema = z.infer<typeof typingMindExportSchema>;
export type TypingMindChatSchema = z.infer<typeof typingMindChatSchema>;
export type TypingMindMessageSchema = z.infer<typeof typingMindMessageSchema>;
/**
* Extract text content from a TypingMind message
* Handles both string content and array of content blocks
*/
export function extractMessageText(content: string | Array<{ type?: string; text?: string }> | undefined): string {
if (!content) return '';
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
return content
.map(block => block.text || '')
.filter(text => text.length > 0)
.join('\n');
}
return '';
}
/**
* Parse and validate a TypingMind export JSON
*/
export function typingMindParseExport(jsonString: string): TypingMindExportSchema {
// Parse the string to JSON
let jsonObject: unknown;
try {
jsonObject = JSON.parse(jsonString);
} catch (error: any) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Failed to parse JSON: ${error?.message}`,
});
}
// Validate the JSON object
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;
}