Files
vibn-frontend/components/chatgpt-import-card.tsx

644 lines
26 KiB
TypeScript

"use client";
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { auth } from '@/lib/firebase/config';
import { toast } from 'sonner';
import { Download, ExternalLink, Eye, EyeOff, MessageSquare, FolderKanban, Bot, Upload } from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { OpenAIIcon } from '@/components/icons/custom-icons';
interface ChatGPTImportCardProps {
projectId?: string;
onImportComplete?: (importData: any) => void;
}
export function ChatGPTImportCard({ projectId, onImportComplete }: ChatGPTImportCardProps) {
const [conversationUrl, setConversationUrl] = useState('');
const [openaiApiKey, setOpenaiApiKey] = useState('');
const [showApiKey, setShowApiKey] = useState(false);
const [loading, setLoading] = useState(false);
const [importedData, setImportedData] = useState<any>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [hasStoredKey, setHasStoredKey] = useState(false);
const [checkingKey, setCheckingKey] = useState(true);
const [importType, setImportType] = useState<'conversation' | 'project' | 'gpt' | 'file'>('file');
// Check for stored OpenAI key on mount
useEffect(() => {
const checkStoredKey = async () => {
try {
const user = auth.currentUser;
if (!user) {
setCheckingKey(false);
return;
}
const token = await user.getIdToken();
const response = await fetch('/api/keys', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const data = await response.json();
const hasOpenAI = data.keys.some((k: any) => k.service === 'openai');
setHasStoredKey(hasOpenAI);
}
} catch (error) {
console.error('Error checking for stored key:', error);
} finally {
setCheckingKey(false);
}
};
checkStoredKey();
}, []);
const extractConversationId = (urlOrId: string): { id: string | null; isShareLink: boolean } => {
// If it's already just an ID
if (!urlOrId.includes('/') && !urlOrId.includes('http')) {
const trimmed = urlOrId.trim();
// Check if it's a share link ID (UUID format)
const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trimmed);
return { id: trimmed, isShareLink: isUUID };
}
// Extract from URL patterns:
// https://chat.openai.com/c/{id} - regular conversation (old)
// https://chatgpt.com/c/{id} - regular conversation (new)
// https://chat.openai.com/share/{id} - shared conversation (not supported)
// https://chatgpt.com/share/{id} - shared conversation (not supported)
// Check for share links first
const sharePatterns = [
/chat\.openai\.com\/share\/([a-zA-Z0-9-]+)/,
/chatgpt\.com\/share\/([a-zA-Z0-9-]+)/,
];
for (const pattern of sharePatterns) {
const match = urlOrId.match(pattern);
if (match) {
return { id: match[1], isShareLink: true };
}
}
// Regular conversation patterns
const conversationPatterns = [
/chat\.openai\.com\/c\/([a-zA-Z0-9-]+)/,
/chatgpt\.com\/c\/([a-zA-Z0-9-]+)/,
// GPT project conversations: https://chatgpt.com/g/g-p-[id]-[name]/c/[conversation-id]
/chatgpt\.com\/g\/g-p-[a-zA-Z0-9-]+\/c\/([a-zA-Z0-9-]+)/,
];
for (const pattern of conversationPatterns) {
const match = urlOrId.match(pattern);
if (match) {
return { id: match[1], isShareLink: false };
}
}
return { id: null, isShareLink: false };
};
const handleImport = async () => {
if (!conversationUrl.trim()) {
toast.error('Please enter a conversation ID or project ID');
return;
}
// If no stored key and no manual key provided, show error
if (!hasStoredKey && !openaiApiKey) {
toast.error('Please enter your OpenAI API key or add one in Keys page');
return;
}
setLoading(true);
try {
const user = auth.currentUser;
if (!user) {
toast.error('Please sign in to import');
return;
}
const token = await user.getIdToken();
if (importType === 'file') {
// Import from uploaded conversations.json file
try {
const conversations = JSON.parse(conversationUrl);
const response = await fetch('/api/chatgpt/import-file', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
conversations,
projectId,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.details || error.error || 'Import failed');
}
const data = await response.json();
toast.success(`✅ Imported ${data.imported} conversations!`);
setImportedData({
messageCount: data.imported,
title: `${data.imported} conversations`
});
if (onImportComplete) {
onImportComplete(data);
}
} catch (error: any) {
throw new Error(`File import failed: ${error.message}`);
}
// Reset form
setConversationUrl('');
setDialogOpen(false);
return;
}
// Determine which key to use
let keyToUse = openaiApiKey;
// If no manual key provided, try to get stored key
if (!keyToUse && hasStoredKey) {
const keyResponse = await fetch('/api/keys/get', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ service: 'openai' }),
});
if (keyResponse.ok) {
const keyData = await keyResponse.json();
keyToUse = keyData.keyValue;
}
}
if (importType === 'project') {
// Import OpenAI Project
const response = await fetch('/api/openai/projects', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
openaiApiKey: keyToUse,
projectId: conversationUrl.trim(),
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.details || error.error || 'Failed to fetch project');
}
const data = await response.json();
toast.success(`Retrieved OpenAI Project: ${data.data.name || data.data.id}`);
setImportedData(data.data);
if (onImportComplete) {
onImportComplete(data.data);
}
} else {
// Import ChatGPT Conversation
const { id: conversationId, isShareLink } = extractConversationId(conversationUrl);
if (!conversationId) {
toast.error('Invalid ChatGPT URL or conversation ID');
return;
}
// Check if it's a share link
if (isShareLink) {
toast.error('Share links are not supported. Please use the direct conversation URL from your ChatGPT chat history.', {
description: 'Look for URLs like: chatgpt.com/c/... or chat.openai.com/c/...',
duration: 5000,
});
return;
}
const response = await fetch('/api/chatgpt/import', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
conversationId,
openaiApiKey: keyToUse,
projectId,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.details || error.error || 'Import failed');
}
const data = await response.json();
setImportedData(data);
toast.success(`Imported: ${data.title} (${data.messageCount} messages)`);
if (onImportComplete) {
onImportComplete(data);
}
}
// Reset form
setConversationUrl('');
setDialogOpen(false);
} catch (error) {
console.error('Import error:', error);
toast.error(error instanceof Error ? error.message : 'Failed to import');
} finally {
setLoading(false);
}
};
return (
<>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Import from OpenAI</CardTitle>
<CardDescription>
Import ChatGPT conversations or OpenAI Projects
</CardDescription>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button>
<Download className="mr-2 h-4 w-4" />
Import from OpenAI
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Import from OpenAI</DialogTitle>
<DialogDescription>
Import ChatGPT conversations or OpenAI Projects
</DialogDescription>
</DialogHeader>
<Tabs value={importType} onValueChange={(v) => setImportType(v as 'conversation' | 'project' | 'gpt' | 'file')} className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="file" className="flex items-center gap-2">
<Upload className="h-4 w-4" />
Upload File
</TabsTrigger>
<TabsTrigger value="conversation" className="flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
Single Chat
</TabsTrigger>
</TabsList>
<TabsContent value="file" className="space-y-4 mt-4">
{/* File Upload */}
<div className="space-y-2">
<Label htmlFor="conversations-file">conversations.json File</Label>
<Input
id="conversations-file"
type="file"
accept=".json,application/json"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
try {
const conversations = JSON.parse(event.target?.result as string);
setConversationUrl(JSON.stringify(conversations)); // Store in existing state
toast.success(`Loaded ${conversations.length} conversations`);
} catch (error) {
toast.error('Invalid JSON file');
}
};
reader.readAsText(file);
}
}}
/>
<p className="text-xs text-muted-foreground">
Upload your ChatGPT exported conversations.json file
</p>
</div>
{/* Instructions */}
<div className="rounded-lg border bg-muted/50 p-4">
<p className="text-sm font-medium mb-2">How to export your ChatGPT data:</p>
<ol className="text-sm text-muted-foreground space-y-1 list-decimal list-inside">
<li>Go to <a href="https://chatgpt.com/settings/data-controls" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">ChatGPT Settings Data Controls</a></li>
<li>Click "Export data"</li>
<li>Wait for the email from OpenAI (can take up to 24 hours)</li>
<li>Download and extract the ZIP file</li>
<li>Upload the <code className="text-xs">conversations.json</code> file here</li>
</ol>
</div>
{/* Info banner */}
<div className="rounded-lg bg-blue-500/10 border border-blue-500/20 p-4">
<p className="text-sm font-medium text-blue-700 dark:text-blue-400 mb-2">
💡 Privacy-friendly import
</p>
<p className="text-sm text-muted-foreground">
Your conversations are processed locally and only stored in your Vibn account.
We never send your data to third parties.
</p>
</div>
</TabsContent>
<TabsContent value="conversation" className="space-y-4 mt-4">
{/* Show stored key status */}
{hasStoredKey && (
<div className="rounded-lg border border-green-500/20 bg-green-500/5 p-3">
<p className="text-sm text-green-700 dark:text-green-400">
Using your stored OpenAI API key
</p>
</div>
)}
{/* OpenAI API Key (optional if stored) */}
{!hasStoredKey && (
<div className="space-y-2">
<Label htmlFor="openai-key">OpenAI API Key</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="openai-key"
type={showApiKey ? 'text' : 'password'}
placeholder="sk-..."
value={openaiApiKey}
onChange={(e) => setOpenaiApiKey(e.target.value)}
className="pr-10"
/>
<Button
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="flex items-center justify-between text-xs">
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
Get your API key <ExternalLink className="h-3 w-3" />
</a>
<a
href="/keys"
className="text-primary hover:underline"
>
Or save it in Keys page
</a>
</div>
</div>
)}
{/* Conversation URL */}
<div className="space-y-2">
<Label htmlFor="conversation-url">ChatGPT Conversation URL or ID</Label>
<Input
id="conversation-url"
placeholder="https://chatgpt.com/c/abc-123 or just abc-123"
value={conversationUrl}
onChange={(e) => setConversationUrl(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Copy the URL from your ChatGPT conversation or paste the conversation ID
</p>
</div>
{/* Instructions */}
<div className="rounded-lg border bg-muted/50 p-4">
<p className="text-sm font-medium mb-2">How to find your conversation URL:</p>
<ol className="text-sm text-muted-foreground space-y-1 list-decimal list-inside">
<li>Open the ChatGPT conversation you want to import</li>
<li>Look at the URL in your browser (must show <code className="text-xs">/c/</code>)</li>
<li>Copy the full URL: <code className="text-xs">chatgpt.com/c/...</code></li>
<li><strong>Note:</strong> Share links (<code className="text-xs">/share/</code>) won&apos;t work - you need the direct conversation URL</li>
</ol>
</div>
</TabsContent>
<TabsContent value="gpt" className="space-y-4 mt-4">
{/* GPT URL Input */}
<div className="space-y-2">
<Label htmlFor="gpt-url">ChatGPT GPT URL</Label>
<Input
id="gpt-url"
placeholder="https://chatgpt.com/g/g-p-abc123-your-gpt-name/project"
value={conversationUrl}
onChange={(e) => setConversationUrl(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Paste the full URL of your custom GPT
</p>
</div>
{/* Instructions */}
<div className="rounded-lg border bg-muted/50 p-4">
<p className="text-sm font-medium mb-2">How to find your GPT URL:</p>
<ol className="text-sm text-muted-foreground space-y-1 list-decimal list-inside">
<li>Open your custom GPT in ChatGPT</li>
<li>Copy the full URL from your browser</li>
<li>Paste it here</li>
<li>To import conversations with this GPT, switch to the "Chat" tab</li>
</ol>
</div>
{/* Info banner */}
<div className="rounded-lg bg-blue-500/10 border border-blue-500/20 p-4">
<p className="text-sm font-medium text-blue-700 dark:text-blue-400 mb-2">
💡 About GPT imports
</p>
<p className="text-sm text-muted-foreground">
This saves a reference to your custom GPT. To import actual conversations
with this GPT, go to a specific chat and use the "Chat" tab to import
that conversation.
</p>
</div>
</TabsContent>
<TabsContent value="project" className="space-y-4 mt-4">
{/* Show stored key status */}
{hasStoredKey && (
<div className="rounded-lg border border-green-500/20 bg-green-500/5 p-3">
<p className="text-sm text-green-700 dark:text-green-400">
Using your stored OpenAI API key
</p>
</div>
)}
{/* OpenAI API Key (optional if stored) */}
{!hasStoredKey && (
<div className="space-y-2">
<Label htmlFor="openai-key-project">OpenAI API Key</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="openai-key-project"
type={showApiKey ? 'text' : 'password'}
placeholder="sk-..."
value={openaiApiKey}
onChange={(e) => setOpenaiApiKey(e.target.value)}
className="pr-10"
/>
<Button
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="flex items-center justify-between text-xs">
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
Get your API key <ExternalLink className="h-3 w-3" />
</a>
<a
href="/keys"
className="text-primary hover:underline"
>
Or save it in Keys page
</a>
</div>
</div>
)}
{/* Project ID */}
<div className="space-y-2">
<Label htmlFor="project-id">OpenAI Project ID</Label>
<Input
id="project-id"
placeholder="proj_abc123..."
value={conversationUrl}
onChange={(e) => setConversationUrl(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Enter your OpenAI Project ID
</p>
</div>
{/* Instructions */}
<div className="rounded-lg border bg-muted/50 p-4">
<p className="text-sm font-medium mb-2">How to find your Project ID:</p>
<ol className="text-sm text-muted-foreground space-y-1 list-decimal list-inside">
<li>Go to <a href="https://platform.openai.com/settings/organization/projects" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">OpenAI Projects</a></li>
<li>Click on the project you want to import</li>
<li>Copy the Project ID (starts with <code className="text-xs">proj_</code>)</li>
<li>Or use the API to list all projects</li>
</ol>
</div>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleImport}
disabled={loading || !conversationUrl || (importType !== 'file' && !hasStoredKey && !openaiApiKey)}
>
{loading
? (importType === 'file' ? 'Importing...' : importType === 'project' ? 'Fetching Project...' : 'Importing Conversation...')
: (importType === 'file' ? 'Import Conversations' : importType === 'project' ? 'Fetch Project' : 'Import Conversation')
}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2 text-sm text-muted-foreground">
<p className="font-medium text-foreground">What you can import:</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li><strong>Conversations:</strong> Full ChatGPT chat history with planning & brainstorming</li>
<li><strong>Projects:</strong> OpenAI Platform projects with API keys, usage, and settings</li>
<li>Requirements, specifications, and design decisions</li>
<li>Architecture notes and technical discussions</li>
</ul>
</div>
{importedData && (
<div className="rounded-lg border border-green-500/20 bg-green-500/5 p-4">
<div className="flex items-start gap-3">
{importedData.messageCount ? (
<MessageSquare className="h-5 w-5 text-green-600 mt-0.5" />
) : (
<FolderKanban className="h-5 w-5 text-green-600 mt-0.5" />
)}
<div className="flex-1">
<p className="text-sm font-medium text-green-700 dark:text-green-400">
Recently imported: {importedData.title || importedData.name || importedData.id}
</p>
<p className="text-xs text-muted-foreground mt-1">
{importedData.messageCount
? `${importedData.messageCount} messages • Conversation ID: ${importedData.conversationId}`
: `Project ID: ${importedData.id}`
}
</p>
</div>
</div>
</div>
)}
<div className="rounded-lg bg-blue-500/10 border border-blue-500/20 p-4">
<p className="text-sm font-medium text-blue-700 dark:text-blue-400 mb-2">
💡 Why import from OpenAI?
</p>
<p className="text-sm text-muted-foreground">
Connect your planning discussions from ChatGPT and your OpenAI Platform projects
with your actual coding sessions in Vibn. Our AI can reference everything to provide
better context and insights.
</p>
</div>
</div>
</CardContent>
</Card>
</>
);
}