458 lines
19 KiB
TypeScript
458 lines
19 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 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>
|
|
|
|
<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>
|
|
|
|
{/* 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>
|
|
);
|
|
};
|