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 | |
|---|---|---|---|
| 74feb4964d |
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user