Compare commits

...

1 Commits

Author SHA1 Message Date
claude[bot] 240c1e2c8a feat: add TypingMind import with multi-step UX
Implements client-side TypingMind chat import:
- Zod schemas with looseObject for forward compatibility
- Multi-step modal: parse → validate → confirm → import → results
- Comprehensive warnings for unsupported features
- No tRPC router (client-side file parsing only)
- Direct imports (no index files)

Co-authored-by: Enrico Ros <enricoros@users.noreply.github.com>
2025-11-23 22:04:59 +00:00
6 changed files with 631 additions and 0 deletions
@@ -0,0 +1,314 @@
import * as React from 'react';
import { Box, Button, Modal, ModalClose, ModalDialog, Typography } from '@mui/joy';
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import { DConversation, DConversationId } from '~/common/stores/chat/chat.conversation';
import { useChatStore } from '~/common/stores/chat/store-chats';
import { parseTypingMindExport } from './typingmind.parser';
import { convertTypingMindChatToConversation } from './typingmind.transformer';
import type { ImportedOutcome } from '../../trade/ImportOutcomeModal';
interface ImportWarning {
type: 'warning' | 'error';
message: string;
chatId?: string;
}
interface ParsedData {
totalChats: number;
validChats: number;
warnings: ImportWarning[];
conversations: DConversation[];
}
type ImportStep = 'confirm' | 'importing' | 'complete';
export interface TypingMindImportModalProps {
open: boolean;
jsonContent: string;
fileName: string;
onClose: () => void;
onConversationActivate: (conversationId: DConversationId) => void;
}
/**
* Multi-step modal for importing TypingMind exports
* Steps: Parse → Validate → Confirm → Import → Results
*/
export function TypingMindImportModal(props: TypingMindImportModalProps) {
const [step, setStep] = React.useState<ImportStep>('confirm');
const [parsedData, setParsedData] = React.useState<ParsedData | null>(null);
const [importOutcome, setImportOutcome] = React.useState<ImportedOutcome | null>(null);
// Parse and validate on mount
React.useEffect(() => {
if (!props.open || parsedData) return;
try {
// Parse the export
const exportData = parseTypingMindExport(props.jsonContent);
const warnings: ImportWarning[] = [];
const conversations: DConversation[] = [];
// Check for unsupported features
if (exportData.data.folders && Array.isArray(exportData.data.folders) && exportData.data.folders.length > 0) {
warnings.push({
type: 'warning',
message: `${exportData.data.folders.length} folder(s) will not be imported - Big-AGI uses a different organization system`,
});
}
if (exportData.data.userPrompts && Array.isArray(exportData.data.userPrompts) && exportData.data.userPrompts.length > 0) {
warnings.push({
type: 'warning',
message: `${exportData.data.userPrompts.length} custom prompt(s) will not be imported - please recreate in Big-AGI if needed`,
});
}
if (exportData.data.userCharacters && Array.isArray(exportData.data.userCharacters) && exportData.data.userCharacters.length > 0) {
warnings.push({
type: 'warning',
message: `${exportData.data.userCharacters.length} AI character(s) will not be imported - please recreate in Big-AGI's persona system`,
});
}
// Convert each chat
let validChats = 0;
for (const chat of exportData.data.chats) {
try {
const conversation = convertTypingMindChatToConversation(chat);
if (conversation.messages.length === 0) {
warnings.push({
type: 'warning',
message: `Chat "${chat.chatTitle || chat.chatID}" has no messages and will be skipped`,
chatId: chat.chatID,
});
continue;
}
// Check for ID conflicts
const existingConversation = useChatStore.getState().conversations.find(c => c.id === conversation.id);
if (existingConversation) {
warnings.push({
type: 'warning',
message: `Chat "${conversation.autoTitle || conversation.id}" already exists and will be overwritten`,
chatId: conversation.id,
});
}
conversations.push(conversation);
validChats++;
} catch (error: any) {
warnings.push({
type: 'error',
message: `Failed to convert chat "${chat.chatTitle || chat.chatID}": ${error?.message || 'Unknown error'}`,
chatId: chat.chatID,
});
}
}
setParsedData({
totalChats: exportData.data.chats.length,
validChats,
warnings,
conversations,
});
} catch (error: any) {
setParsedData({
totalChats: 0,
validChats: 0,
warnings: [{
type: 'error',
message: `Failed to parse TypingMind export: ${error?.message || 'Invalid format'}`,
}],
conversations: [],
});
}
}, [props.open, props.jsonContent, parsedData]);
const handleConfirmImport = () => {
if (!parsedData || parsedData.conversations.length === 0) return;
setStep('importing');
// Import conversations
const outcome: ImportedOutcome = {
conversations: [],
activateConversationId: null,
};
try {
for (const conversation of parsedData.conversations) {
try {
useChatStore.getState().importConversation(conversation, false);
outcome.conversations.push({
success: true,
fileName: conversation.autoTitle || conversation.id,
conversation,
});
} catch (error: any) {
outcome.conversations.push({
success: false,
fileName: conversation.autoTitle || conversation.id,
error: error?.message || 'Import failed',
});
}
}
// Activate the last imported conversation
const lastSuccess = outcome.conversations.findLast(c => c.success);
if (lastSuccess && 'conversation' in lastSuccess) {
outcome.activateConversationId = lastSuccess.conversation.id;
props.onConversationActivate(lastSuccess.conversation.id);
}
setImportOutcome(outcome);
setStep('complete');
} catch (error: any) {
outcome.conversations.push({
success: false,
fileName: props.fileName,
error: error?.message || 'Unknown error',
});
setImportOutcome(outcome);
setStep('complete');
}
};
const handleClose = () => {
setParsedData(null);
setImportOutcome(null);
setStep('confirm');
props.onClose();
};
if (!parsedData) {
return null;
}
const hasErrors = parsedData.warnings.some(w => w.type === 'error');
const canImport = parsedData.validChats > 0 && !hasErrors;
return (
<Modal open={props.open} onClose={handleClose}>
<ModalDialog sx={{ minWidth: 500, maxWidth: 700 }}>
<ModalClose />
{/* Confirmation Step */}
{step === 'confirm' && (
<>
<Typography level='h4'>
Import from TypingMind
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }}>
<Typography level='body-md'>
<strong>File:</strong> {props.fileName}
</Typography>
<Typography level='body-md'>
<strong>Chats found:</strong> {parsedData.totalChats}
{parsedData.validChats < parsedData.totalChats && (
<> ({parsedData.validChats} valid)</>
)}
</Typography>
{parsedData.warnings.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography level='body-sm' fontWeight='bold'>
{hasErrors ? 'Errors:' : 'Warnings:'}
</Typography>
{parsedData.warnings.map((warning, idx) => (
<Box key={idx} sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
{warning.type === 'error' ? (
<ErrorOutlineIcon color='error' sx={{ fontSize: 20, mt: 0.25 }} />
) : (
<WarningRoundedIcon color='warning' sx={{ fontSize: 20, mt: 0.25 }} />
)}
<Typography level='body-sm' color={warning.type === 'error' ? 'danger' : 'warning'}>
{warning.message}
</Typography>
</Box>
))}
</Box>
)}
<Typography level='body-sm' color='neutral'>
Note: Folders, custom prompts, AI characters, plugins, and attachments will not be imported.
See documentation for details.
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, mt: 3 }}>
<Button variant='soft' onClick={handleClose}>
Cancel
</Button>
<Button
color='primary'
disabled={!canImport}
onClick={handleConfirmImport}
sx={{ ml: 'auto' }}
>
Import {parsedData.validChats} Chat{parsedData.validChats !== 1 ? 's' : ''}
</Button>
</Box>
</>
)}
{/* Importing Step */}
{step === 'importing' && (
<>
<Typography level='h4'>
Importing...
</Typography>
<Typography level='body-md' sx={{ mt: 2 }}>
Please wait while we import your chats.
</Typography>
</>
)}
{/* Complete Step */}
{step === 'complete' && importOutcome && (
<>
<Typography level='h4'>
Import Complete
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }}>
<Typography level='body-md'>
<strong>Successful:</strong> {importOutcome.conversations.filter(c => c.success).length}
</Typography>
<Typography level='body-md'>
<strong>Failed:</strong> {importOutcome.conversations.filter(c => !c.success).length}
</Typography>
{importOutcome.conversations.some(c => !c.success) && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography level='body-sm' fontWeight='bold'>
Failures:
</Typography>
{importOutcome.conversations
.filter(c => !c.success)
.map((conv, idx) => (
<Typography key={idx} level='body-sm' color='danger'>
{conv.fileName}: {conv.error}
</Typography>
))}
</Box>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1, mt: 3 }}>
<Button color='primary' onClick={handleClose} sx={{ ml: 'auto' }}>
Close
</Button>
</Box>
</>
)}
</ModalDialog>
</Modal>
);
}
@@ -0,0 +1,49 @@
import { TRPCError } from '@trpc/server';
import { typingMindExportSchema, TypingMindExport } from './typingmind.schema';
/**
* Parse and validate a TypingMind export JSON string
*/
export function parseTypingMindExport(jsonString: string): TypingMindExport {
// Parse JSON
let jsonObject: unknown;
try {
jsonObject = JSON.parse(jsonString);
} catch (error: any) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Invalid JSON: ${error?.message || 'Parse error'}`,
});
}
// Validate with Zod schema
const result = typingMindExportSchema.safeParse(jsonObject);
if (!result.success) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Invalid TypingMind export format: ${result.error.message}`,
});
}
return result.data;
}
/**
* Extract text content from TypingMind message content
* Handles both string and array formats
*/
export function extractMessageText(content: string | Array<{ type?: string; text?: string }>): string {
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
return content
.map(block => block.text || '')
.filter(text => text.length > 0)
.join('\n');
}
return '';
}
@@ -0,0 +1,64 @@
import * as z from 'zod/v4';
/**
* TypingMind Export Schema
*
* Uses looseObject() to allow optional/unknown fields for forward compatibility.
* The schema requires minimal fields and parses as much as possible.
*/
// Message content can be either string or array of content blocks
const typingMindMessageContentBlockSchema = z.looseObject({
type: z.string().optional(),
text: z.string().optional(),
}).passthrough();
const typingMindMessageContentSchema = z.union([
z.string(), // Simple string content
z.array(typingMindMessageContentBlockSchema), // Array of content blocks
]);
// Individual message
export const typingMindMessageSchema = z.looseObject({
uuid: z.string(),
role: z.enum(['user', 'assistant', 'system']),
content: typingMindMessageContentSchema,
createdAt: z.string().optional(), // ISO timestamp
}).passthrough();
// Chat/Conversation
export const typingMindChatSchema = z.looseObject({
id: z.string().optional(),
chatID: z.string(),
chatTitle: z.string().optional(),
preview: z.string().optional(),
createdAt: z.string().optional(), // ISO timestamp
updatedAt: z.string().optional(), // ISO timestamp
messages: z.array(typingMindMessageSchema),
// Other fields we don't use but allow
folderID: z.string().optional(),
model: z.string().optional(),
modelInfo: z.unknown().optional(),
linkedPlugins: z.unknown().optional(),
chatParams: z.unknown().optional(),
selectedMultimodelIDs: z.unknown().optional(),
tokenUsage: z.unknown().optional(),
}).passthrough();
// Top-level export structure
export const typingMindExportSchema = z.looseObject({
data: z.looseObject({
chats: z.array(typingMindChatSchema),
// Other top-level arrays we don't import
folders: z.unknown().optional(),
userPrompts: z.unknown().optional(),
userCharacters: z.unknown().optional(),
installedPlugins: z.unknown().optional(),
promptSettings: z.unknown().optional(),
characterSettings: z.unknown().optional(),
}).passthrough(),
}).passthrough();
export type TypingMindExport = z.infer<typeof typingMindExportSchema>;
export type TypingMindChat = z.infer<typeof typingMindChatSchema>;
export type TypingMindMessage = z.infer<typeof typingMindMessageSchema>;
@@ -0,0 +1,76 @@
import { createDConversation, DConversation } from '~/common/stores/chat/chat.conversation';
import { createDMessageTextContent, DMessage } from '~/common/stores/chat/chat.message';
import { TypingMindChat, TypingMindMessage } from './typingmind.schema';
import { extractMessageText } from './typingmind.parser';
/**
* Convert TypingMind chat to DConversation
*/
export function convertTypingMindChatToConversation(chat: TypingMindChat): DConversation {
const conversation = createDConversation();
// Use the original chat ID
conversation.id = chat.chatID;
// Set title
if (chat.chatTitle) {
conversation.autoTitle = chat.chatTitle;
} else if (chat.preview) {
conversation.autoTitle = chat.preview;
}
// Set timestamps - convert ISO strings to Unix milliseconds
if (chat.createdAt) {
const createdDate = new Date(chat.createdAt);
if (!isNaN(createdDate.getTime())) {
conversation.created = createdDate.getTime();
}
}
if (chat.updatedAt) {
const updatedDate = new Date(chat.updatedAt);
if (!isNaN(updatedDate.getTime())) {
conversation.updated = updatedDate.getTime();
}
}
// Convert messages
conversation.messages = chat.messages
.map(msg => convertTypingMindMessageToDMessage(msg))
.filter((msg): msg is DMessage => msg !== null);
return conversation;
}
/**
* Convert TypingMind message to DMessage
*/
function convertTypingMindMessageToDMessage(message: TypingMindMessage): DMessage | null {
// Extract text content
const text = extractMessageText(message.content);
if (!text || text.length === 0) {
return null;
}
// Only support user and assistant roles
if (message.role !== 'user' && message.role !== 'assistant') {
return null;
}
// Create message
const dMessage = createDMessageTextContent(message.role, text);
// Use original UUID
dMessage.id = message.uuid;
// Set timestamp
if (message.createdAt) {
const createdDate = new Date(message.createdAt);
if (!isNaN(createdDate.getTime())) {
dMessage.created = createdDate.getTime();
}
}
return dMessage;
}
@@ -0,0 +1,79 @@
# TypingMind Import - Unsupported Features
This document explains what TypingMind features are not imported and why.
## Folders
**TypingMind Feature:** Chats can be organized into folders with hierarchical structure.
**Why Not Imported:** Big-AGI currently uses a different organization system. All chats are imported to the root level. The folder structure from TypingMind is not preserved.
**Future:** If Big-AGI implements folder/tagging functionality, this could be revisited.
## User Prompts
**TypingMind Feature:** Custom prompt library with variables, tags, and favorites.
**Why Not Imported:** Big-AGI has a different prompt/persona system. TypingMind prompts use a different variable syntax (`{field}`, `{{action}}`) and organization model.
**Alternative:** Users can manually recreate important prompts in Big-AGI's persona system.
## User Characters / AI Personas
**TypingMind Feature:** Custom AI characters with system instructions, conversation starters, default models, training data, and voice settings.
**Why Not Imported:** Big-AGI has its own persona system with different capabilities and structure. TypingMind characters include features like:
- Enforced model selection
- Speech synthesis settings (ElevenLabs, OpenAI TTS)
- Plugin assignments
- Training file attachments
These don't have direct equivalents in Big-AGI's current architecture.
**Alternative:** Users can manually recreate character behaviors using Big-AGI's persona system.
## Installed Plugins / Tools
**TypingMind Feature:** Custom plugins with JavaScript code, HTTP actions, and OpenAI function specs (web search, image generation, research tools, etc.).
**Why Not Imported:** Plugin architectures are fundamentally incompatible:
- TypingMind uses JavaScript execution and HTTP actions
- Big-AGI has its own tool/integration system
- Security model differences
**Result:** Only text messages are imported. Any tool/plugin invocations or results in the chat history are preserved as text content.
## Attachments and Images
**TypingMind Feature:** File attachments and images uploaded to chats, with blob storage.
**Why Not Imported:** TypingMind's export feature has a known bug where attachments are not included in the export, despite being referenced in the JSON. The JSON contains public URLs to images, but:
- URLs may expire or become inaccessible
- Downloading from external URLs introduces privacy/security concerns
- No guarantee the files still exist
**Current Behavior:** Image and file references appear as text mentions in the imported messages (e.g., `[Image: url]` or `[File: filename]`).
**Future:** If TypingMind fixes their export to include attachments, this feature could be implemented with:
- User consent for downloading external resources
- Rate limiting to respect server constraints
- Local storage of downloaded assets
## Token Usage and Cost Tracking
**TypingMind Feature:** Detailed token usage and cost tracking per chat, including cached tokens and reasoning tokens.
**Why Not Imported:** Big-AGI has its own token counting and cost tracking system. Historical cost data from TypingMind is not carried over.
**Result:** Imported chats will have token counts recalculated by Big-AGI's system.
## Model Parameters and Settings
**TypingMind Feature:** Per-chat model parameters (context limit, streaming, output settings, system message templates with variables).
**Why Not Imported:** These are session-specific settings that don't transfer meaningfully:
- Big-AGI users select models interactively
- System messages use a different variable system
- Output tone/language/style/format settings are TypingMind-specific
**Result:** Imported chats use Big-AGI's default model and settings.
+49
View File
@@ -20,6 +20,7 @@ import { importConversationsFromFilesAtRest, openConversationsAtRestPicker } fro
import { FlashRestore } from './BackupRestore';
import { ImportedOutcome, ImportOutcomeModal } from './ImportOutcomeModal';
import { TypingMindImportModal } from '../data/typingmind/TypingMindImportModal';
export type ImportConfig = { dir: 'import' };
@@ -43,6 +44,8 @@ export function ImportChats(props: { onConversationActivate: (conversationId: DC
const [chatGptSource, setChatGptSource] = React.useState('');
const [importJson, setImportJson] = React.useState<string | null>(null);
const [importOutcome, setImportOutcome] = React.useState<ImportedOutcome | null>(null);
const [typingMindModalOpen, setTypingMindModalOpen] = React.useState(false);
const [typingMindData, setTypingMindData] = React.useState<{ content: string; fileName: string } | null>(null);
// derived state
const isUrl = importMedia === 'link';
@@ -122,6 +125,32 @@ export function ImportChats(props: { onConversationActivate: (conversationId: DC
props.onClose();
};
const handleTypingMindImport = () => {
// Create a file input element
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,application/json';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
const content = await file.text();
setTypingMindData({ content, fileName: file.name });
setTypingMindModalOpen(true);
} catch (error) {
console.error('Failed to read file:', error);
}
};
input.click();
};
const handleTypingMindModalClose = () => {
setTypingMindModalOpen(false);
setTypingMindData(null);
props.onClose();
};
return <>
@@ -150,6 +179,15 @@ export function ImportChats(props: { onConversationActivate: (conversationId: DC
</Button>
)}
{!chatGptEdit && (
<Button
variant='soft' endDecorator={<FileUploadIcon />} sx={{ minWidth: 240, justifyContent: 'space-between' }}
onClick={handleTypingMindImport}
>
TypingMind · Export
</Button>
)}
{/* Insert to Restore a Flash */}
<FlashRestore unlockRestore={true} />
@@ -193,5 +231,16 @@ export function ImportChats(props: { onConversationActivate: (conversationId: DC
{/* import outcome */}
{!!importOutcome && <ImportOutcomeModal outcome={importOutcome} rawJson={importJson} onClose={handleImportOutcomeClosed} />}
{/* TypingMind import modal */}
{typingMindModalOpen && typingMindData && (
<TypingMindImportModal
open={typingMindModalOpen}
jsonContent={typingMindData.content}
fileName={typingMindData.fileName}
onClose={handleTypingMindModalClose}
onConversationActivate={props.onConversationActivate}
/>
)}
</>;
}