mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4794ae034 |
@@ -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' }}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user