Files
ai-job-application/src/components/ProfileSettings.tsx
T

538 lines
22 KiB
TypeScript

import React, { useState } from 'react';
import { UserProfile } from '../types';
import {
User,
Mail,
Phone,
MapPin,
FileText,
Briefcase,
Plus,
Trash2,
Save,
CheckCircle2,
Tag,
Building2,
GraduationCap,
Sparkles,
Cpu,
Zap,
RefreshCw,
AlertCircle,
Server
} from 'lucide-react';
interface ProfileSettingsProps {
userProfile: UserProfile;
onSaveProfile: (profile: UserProfile) => Promise<void>;
}
export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
userProfile,
onSaveProfile
}) => {
const [profile, setProfile] = useState<UserProfile>(userProfile);
const [skillInput, setSkillInput] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [savedSuccess, setSavedSuccess] = useState(false);
// LM Studio Test state
const [isTestingLLM, setIsTestingLLM] = useState(false);
const [llmTestResult, setLlmTestResult] = useState<{ success: boolean; message: string } | null>(null);
const llmConfig = profile.llmConfig || {
primaryProvider: 'gemini',
fallbackToLocal: true,
lmStudioBaseUrl: 'http://localhost:1234/v1',
lmStudioModel: 'local-model'
};
const handleUpdateLLMConfig = (key: string, val: any) => {
setProfile(prev => ({
...prev,
llmConfig: {
...llmConfig,
[key]: val
}
}));
};
const handleTestLocalLLM = async () => {
setIsTestingLLM(true);
setLlmTestResult(null);
try {
const res = await fetch('/api/llm/test-connection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
baseUrl: llmConfig.lmStudioBaseUrl,
modelName: llmConfig.lmStudioModel,
apiKey: llmConfig.customApiKey
})
});
const data = await res.json();
if (data.success) {
setLlmTestResult({ success: true, message: data.message });
} else {
setLlmTestResult({ success: false, message: data.error || 'Could not connect to Local LLM server.' });
}
} catch (err: any) {
setLlmTestResult({ success: false, message: `Connection error: ${err.message}` });
} finally {
setIsTestingLLM(false);
}
};
const handleUpdateField = (key: keyof UserProfile, val: any) => {
setProfile(prev => ({ ...prev, [key]: val }));
};
const handleAddSkill = () => {
if (!skillInput.trim()) return;
const updated = [...profile.skills, skillInput.trim()];
handleUpdateField('skills', updated);
setSkillInput('');
};
const handleRemoveSkill = (skill: string) => {
const updated = profile.skills.filter(s => s !== skill);
handleUpdateField('skills', updated);
};
const [isParsingResume, setIsParsingResume] = useState(false);
const [parseError, setParseError] = useState<string | null>(null);
const handleResumeFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsParsingResume(true);
setParseError(null);
try {
let bodyData: any = {
fileName: file.name,
llmConfig: profile.llmConfig
};
if (file.name.endsWith('.docx') || file.name.endsWith('.doc')) {
const arrayBuffer = await file.arrayBuffer();
const base64 = btoa(
new Uint8Array(arrayBuffer).reduce((data, byte) => data + String.fromCharCode(byte), '')
);
bodyData.resumeBase64 = base64;
} else {
bodyData.resumeText = await file.text();
}
const res = await fetch('/api/gemini/parse-resume', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bodyData)
});
const data = await res.json();
if (data.success && data.profile) {
const parsed = data.profile;
setProfile(prev => ({
...prev,
fullName: parsed.fullName || prev.fullName,
email: parsed.email || prev.email,
phone: parsed.phone || prev.phone,
location: parsed.location || prev.location,
linkedinUrl: parsed.linkedinUrl || prev.linkedinUrl,
githubUrl: parsed.githubUrl || prev.githubUrl,
portfolioUrl: parsed.portfolioUrl || prev.portfolioUrl,
summary: parsed.summary || prev.summary,
skills: Array.isArray(parsed.skills) && parsed.skills.length > 0 ? parsed.skills : prev.skills,
experience: Array.isArray(parsed.experience) && parsed.experience.length > 0 ? parsed.experience : prev.experience,
education: Array.isArray(parsed.education) && parsed.education.length > 0 ? parsed.education : prev.education
}));
} else {
setParseError(data.error || 'Failed to parse resume content.');
}
} catch (err: any) {
setParseError(`Failed to read/parse file: ${err.message}`);
} finally {
setIsParsingResume(false);
}
};
const handleSave = async () => {
setIsSaving(true);
try {
await onSaveProfile(profile);
setSavedSuccess(true);
setTimeout(() => setSavedSuccess(false), 3000);
} catch (err) {
console.error('Error saving profile:', err);
} finally {
setIsSaving(false);
}
};
return (
<div className="space-y-6">
{/* Top Banner */}
<div className="p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h2 className="text-base font-bold text-white flex items-center gap-2">
<FileText className="w-5 h-5 text-indigo-400" /> Master Resume & Profile Configuration
</h2>
<p className="text-xs text-slate-400 mt-1">
This master profile serves as the foundational knowledge base that Gemini AI uses to tailor your resumes and cover letters for every application.
</p>
</div>
<div className="flex items-center gap-3">
<label className="px-4 py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 border border-slate-700 text-slate-200 font-semibold text-xs cursor-pointer flex items-center gap-2 transition-all">
<Sparkles className="w-4 h-4 text-amber-400" />
<span>{isParsingResume ? 'Parsing Resume with AI...' : 'Upload & Parse Resume'}</span>
<input
type="file"
accept=".txt,.md,.json,.pdf,.docx"
onChange={handleResumeFileUpload}
disabled={isParsingResume}
className="hidden"
/>
</label>
<button
onClick={handleSave}
disabled={isSaving}
className="px-5 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-xs shadow-lg shadow-indigo-600/30 disabled:opacity-50 flex items-center gap-2 transition-all shrink-0"
>
{savedSuccess ? <CheckCircle2 className="w-4 h-4 text-emerald-300" /> : <Save className="w-4 h-4" />}
<span>{isSaving ? 'Saving...' : savedSuccess ? 'Saved to Firestore!' : 'Save Master Profile'}</span>
</button>
</div>
</div>
{parseError && (
<div className="p-4 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-rose-400 shrink-0" />
<span>{parseError}</span>
</div>
)}
{/* Main Profile Forms */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Personal Details */}
<div className="p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl space-y-4">
<h3 className="text-sm font-bold text-white flex items-center gap-2 pb-3 border-b border-slate-800">
<User className="w-4 h-4 text-indigo-400" /> Personal Details
</h3>
<div className="space-y-3 text-xs">
<div>
<label className="block font-semibold text-slate-300 mb-1">Full Name</label>
<input
type="text"
value={profile.fullName}
onChange={(e) => handleUpdateField('fullName', e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
/>
</div>
<div>
<label className="block font-semibold text-slate-300 mb-1">Email Address</label>
<input
type="email"
value={profile.email}
onChange={(e) => handleUpdateField('email', e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
/>
</div>
<div>
<label className="block font-semibold text-slate-300 mb-1">Phone Number</label>
<input
type="text"
value={profile.phone}
onChange={(e) => handleUpdateField('phone', e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
/>
</div>
<div>
<label className="block font-semibold text-slate-300 mb-1">Location / Relocation Preferences</label>
<input
type="text"
value={profile.location}
onChange={(e) => handleUpdateField('location', e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
/>
</div>
<div>
<label className="block font-semibold text-slate-300 mb-1">LinkedIn Profile URL</label>
<input
type="text"
value={profile.linkedinUrl}
onChange={(e) => handleUpdateField('linkedinUrl', e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200"
/>
</div>
</div>
</div>
{/* Master Professional Summary & Technical Skills */}
<div className="lg:col-span-2 p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl space-y-5">
<div>
<h3 className="text-sm font-bold text-white flex items-center gap-2 pb-3 border-b border-slate-800 mb-3">
<Sparkles className="w-4 h-4 text-amber-400" /> Master Executive Summary
</h3>
<textarea
rows={4}
value={profile.summary}
onChange={(e) => handleUpdateField('summary', e.target.value)}
className="w-full p-3.5 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 text-xs leading-relaxed focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<h3 className="text-sm font-bold text-white flex items-center gap-2 pb-3 border-b border-slate-800 mb-3">
<Tag className="w-4 h-4 text-indigo-400" /> Technical & Professional Skills ({profile.skills.length})
</h3>
<div className="flex gap-2 mb-3">
<input
type="text"
placeholder="e.g. Gemini API, Docker, System Design..."
value={skillInput}
onChange={(e) => setSkillInput(e.target.value)}
className="flex-1 px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 text-xs focus:outline-none focus:border-indigo-500"
/>
<button
onClick={handleAddSkill}
className="px-4 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-xs"
>
Add Skill
</button>
</div>
<div className="flex flex-wrap gap-2">
{profile.skills.map((skill, idx) => (
<span key={idx} className="px-3 py-1 rounded-xl bg-slate-950 text-slate-200 border border-slate-800 text-xs font-medium flex items-center gap-2">
{skill}
<button onClick={() => handleRemoveSkill(skill)} className="text-slate-500 hover:text-rose-400">✕</button>
</span>
))}
</div>
</div>
</div>
</div>
{/* AI Engine & Local LLM Fallback Settings Card */}
<div className="p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-4 border-b border-slate-800">
<div>
<h3 className="text-sm font-bold text-white flex items-center gap-2">
<Cpu className="w-4 h-4 text-purple-400" /> AI LLM Provider & Local Fallback Engine
</h3>
<p className="text-xs text-slate-400 mt-0.5">
Configure Gemini API or automatically fail over to local LLMs (LM Studio, Ollama, vLLM, LocalAI) if Gemini quota is exhausted.
</p>
</div>
<div className="inline-flex items-center space-x-2 bg-purple-500/10 border border-purple-500/20 text-purple-300 text-xs px-3 py-1.5 rounded-xl font-medium shrink-0">
<Server className="w-3.5 h-3.5" />
<span>OpenAI-Compatible Local Endpoint</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-xs">
{/* Left Column: Provider & Fallback Toggles */}
<div className="space-y-4">
<div>
<label className="block font-semibold text-slate-300 mb-1.5">Primary AI Completion Provider</label>
<select
value={llmConfig.primaryProvider}
onChange={(e) => handleUpdateLLMConfig('primaryProvider', e.target.value)}
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 font-medium focus:outline-none focus:border-purple-500"
>
<option value="gemini">Gemini 3.6 Flash (Cloud API)</option>
<option value="lmstudio">Local LM Studio / Ollama (Local LLM)</option>
<option value="openai_compatible">Custom OpenAI-Compatible API Endpoint</option>
</select>
</div>
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-3">
<div className="flex items-center justify-between">
<div>
<span className="font-semibold text-slate-200">Automatic Fallback to Local LLM</span>
<p className="text-[11px] text-slate-400">If Gemini hits rate limit (429) or token quota runs out, auto-route to LM Studio.</p>
</div>
<button
type="button"
onClick={() => handleUpdateLLMConfig('fallbackToLocal', !llmConfig.fallbackToLocal)}
className={`w-12 h-6 rounded-full transition-colors relative p-1 shrink-0 ${
llmConfig.fallbackToLocal ? 'bg-purple-600' : 'bg-slate-800'
}`}
>
<div className={`w-4 h-4 rounded-full bg-white transition-transform ${
llmConfig.fallbackToLocal ? 'translate-x-6' : 'translate-x-0'
}`} />
</button>
</div>
</div>
</div>
{/* Right Column: LM Studio Endpoint Configuration */}
<div className="space-y-4">
<div>
<label className="block font-semibold text-slate-300 mb-1">Local LM Studio / OpenAI Endpoint Base URL</label>
<input
type="text"
placeholder="http://localhost:1234/v1"
value={llmConfig.lmStudioBaseUrl}
onChange={(e) => handleUpdateLLMConfig('lmStudioBaseUrl', e.target.value)}
className="w-full px-3.5 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 font-mono text-xs focus:outline-none focus:border-purple-500"
/>
<span className="text-[10px] text-slate-500 mt-1 block">Default LM Studio port is 1234. Ollama default port is 11434.</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block font-semibold text-slate-300 mb-1">Local Model ID / Alias</label>
<input
type="text"
placeholder="local-model"
value={llmConfig.lmStudioModel}
onChange={(e) => handleUpdateLLMConfig('lmStudioModel', e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 font-mono text-xs"
/>
</div>
<div>
<label className="block font-semibold text-slate-300 mb-1">API Key (Optional)</label>
<input
type="password"
placeholder="lm-studio / bearer token"
value={llmConfig.customApiKey || ''}
onChange={(e) => handleUpdateLLMConfig('customApiKey', e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 font-mono text-xs"
/>
</div>
</div>
<div className="pt-1 flex items-center justify-between">
<button
type="button"
onClick={handleTestLocalLLM}
disabled={isTestingLLM}
className="px-4 py-2 rounded-xl bg-purple-600/20 hover:bg-purple-600/30 text-purple-300 border border-purple-500/30 font-semibold text-xs flex items-center gap-2 transition-all cursor-pointer"
>
{isTestingLLM ? (
<>
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
<span>Pinging Local Server...</span>
</>
) : (
<>
<Zap className="w-3.5 h-3.5 text-purple-400" />
<span>Test Local LM Studio Connection</span>
</>
)}
</button>
</div>
{llmTestResult && (
<div className={`p-3 rounded-xl border text-xs flex items-center gap-2 ${
llmTestResult.success
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-300'
: 'bg-rose-500/10 border-rose-500/30 text-rose-300'
}`}>
{llmTestResult.success ? <CheckCircle2 className="w-4 h-4 shrink-0" /> : <AlertCircle className="w-4 h-4 shrink-0" />}
<span>{llmTestResult.message}</span>
</div>
)}
</div>
</div>
</div>
{/* Work Experience Section */}
<div className="p-6 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl space-y-4">
<h3 className="text-sm font-bold text-white flex items-center gap-2 pb-3 border-b border-slate-800">
<Briefcase className="w-4 h-4 text-indigo-400" /> Master Work Experience History
</h3>
<div className="space-y-4">
{profile.experience.map((exp, expIdx) => (
<div key={exp.id} className="p-4 rounded-xl bg-slate-950 border border-slate-800 text-xs space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 flex-1">
<div>
<label className="block font-semibold text-slate-400 mb-1">Title</label>
<input
type="text"
value={exp.title}
onChange={(e) => {
const copy = [...profile.experience];
copy[expIdx].title = e.target.value;
handleUpdateField('experience', copy);
}}
className="w-full px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-slate-200"
/>
</div>
<div>
<label className="block font-semibold text-slate-400 mb-1">Company</label>
<input
type="text"
value={exp.company}
onChange={(e) => {
const copy = [...profile.experience];
copy[expIdx].company = e.target.value;
handleUpdateField('experience', copy);
}}
className="w-full px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-slate-200"
/>
</div>
<div>
<label className="block font-semibold text-slate-400 mb-1">Period</label>
<input
type="text"
value={exp.period}
onChange={(e) => {
const copy = [...profile.experience];
copy[expIdx].period = e.target.value;
handleUpdateField('experience', copy);
}}
className="w-full px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-slate-200"
/>
</div>
</div>
</div>
<div>
<label className="block font-semibold text-slate-400 mb-1">Key Accomplishment Bullets</label>
<div className="space-y-2">
{exp.bullets.map((b, bulletIdx) => (
<input
key={bulletIdx}
type="text"
value={b}
onChange={(e) => {
const copy = [...profile.experience];
copy[expIdx].bullets[bulletIdx] = e.target.value;
handleUpdateField('experience', copy);
}}
className="w-full px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-slate-200 text-xs"
/>
))}
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
};