-
Notifications
You must be signed in to change notification settings - Fork 45
Add AI endpoints, UI components, hooks, and job conversion update #181
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
|
The latest updates on your projects. Learn more about Vercel for Git ↗︎
|
|
Warning Rate limit exceeded@thomasdavis has exceeded the limit for the number of commits or files that can be reviewed per hour. Please wait 14 minutes and 25 seconds before requesting another review. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. 📒 Files selected for processing (1)
WalkthroughThe pull request introduces a comprehensive set of best practices across various technologies and expands functionality within the application. It adds new API endpoints for AI-assisted resume editing and audio transcription, introduces new React components for chat and resume editing (including GUI and mode switching), and implements custom hooks for managing settings and speech synthesis. Additionally, the OpenAI package dependency is upgraded, and a job-processing script is enhanced to convert job descriptions into a structured JSON schema. Changes
Sequence Diagram(s)sequenceDiagram
participant C as Client
participant API as Chat API Endpoint
participant OA as OpenAI API
C->>API: POST /api/chat with messages & resume
API->>API: Verify API key & parse request
API->>OA: Send chat completion request with system prompt
OA-->>API: Return answer (with possible tool call details)
API->>C: Respond with edited resume or plain message
sequenceDiagram
participant C as Client
participant API as Transcribe API Endpoint
participant FS as File System
participant OA as Whisper API
C->>API: POST /api/transcribe with audio file
API->>API: Validate and extract audio file
API->>FS: Write audio buffer to temporary file
API->>OA: Request transcription via Whisper model
OA-->>API: Return transcription result
API->>FS: Delete temporary file (async & sync)
API->>C: Respond with transcription result
Possibly related PRs
Poem
Thank you for using CodeRabbit. We offer it for free to the OSS community and would appreciate your support in helping us grow. If you find it useful, would you consider giving us a shout-out on your favorite social media? 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments. CodeRabbit Commands (Invoked using PR comments)
Other keywords and placeholders
CodeRabbit Configuration File (
|
|
✅ Meticulous spotted zero visual differences across 62 screens tested: view results. Meticulous simulated ~4 hours of user flows against your PR. Expected differences? Click here. Last updated for commit 3da5697. This comment will update as new commits are pushed. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 7
🧹 Nitpick comments (21)
apps/registry/app/api/chat/route.js (4)
8-22: Validate or sanitize the systemPrompt content.
While the system prompt is a simple user instruction, there's a security consideration around dynamic prompt generation. If user-provided data is ever concatenated, consider sanitizing it to prevent prompt injection.
24-82: Consider dynamic JSON schema validation.
You define a strongresumeSchemahere, but it’s unused in runtime checks. To enforce data integrity, apply a JSON schema validator or a similar check against the changes returned from the tool calls. This helps catch invalid or malicious updates.
220-234: Handle multiple tool calls more explicitly.
You currently stop after processing the first'update_resume'function call. If multiple calls or tool names ever appear, they’ll be ignored. Consider either processing them all or ending early with a clear reason in the response.
242-245: Strengthen error handling for debugging.
When an error occurs, only the message is returned in JSON. Capturing stack traces or using a structured logging approach can help with debugging in production (while taking care not to leak sensitive data).apps/registry/app/components/ResumeEditor.js (2)
190-198: Add user feedback on JSON parse errors.
When JSON parsing fails inhandleJsonChange, you log the error but do not notify the user in the UI. Providing feedback, such as an error toast, would improve usability and debugging for end users.
300-306: Handle validation conflicts with AI suggestions.
TheAIChatEditormight suggest changes conflicting with the existing schema. Consider hooking in a validation check before applying theonApplyChangesto prevent partial or invalid merges.apps/registry/scripts/jobs/gpted.js (2)
218-270: Establish consistent naming for schema functions.
jobDescriptionToSchemaFunctionis clear, but be consistent with other naming conventions (e.g.,update_resumein your chat route). It helps maintain code readability and uniform patterns.
445-474: Consolidate multiple AI calls or track partial responses.
You have three sequential calls to OpenAI for the same job. If one fails, the overall job might remain incomplete. Consider a single orchestrated flow that can gracefully resume or store partial states. This reduces repeated context consumption and potential tokens usage.apps/registry/app/components/GuiEditor.js (3)
7-26: Consider adding prop-type validations or TypeScript.While
FormSectionis clearly structured, adding runtime checks via prop-types or using TypeScript would help ensure correct usage of props and enhance maintainability.
28-59: Propagate accessibility attributes in form fields.Providing
aria-labelor more descriptivenameattributes could improve screen reader support, especially fortextareainputs, ensuring an accessible form design.
61-86: Streamline array operations for better maintainability.
ArrayFieldis well-structured, but consider factoring out the array manipulation logic (e.g., slicing, splicing) into reusable utility functions to reduce repetition and improve readability across multiple sections.apps/registry/app/hooks/useSettings.js (2)
9-19: Robust error handling for settings loading.The try/catch block is good. For better fault tolerance, you could define a default fallback or inform users when corrupted data is detected, rather than silently catching the error.
21-29: Potential separation of concerns for local storage updates.Consider decoupling local storage logic from the hook's core so the hook just manages state. This can make testing more straightforward, as you can swap in different storage solutions later.
apps/registry/app/components/ErrorBoundary.js (3)
5-9: Optional: Integrate with a monitoring system.Capturing
hasErroranderrorin state is good for local handling. For production, consider integrating a logging or monitoring system (e.g., Sentry) to track these errors remotely.
15-17: Include user-friendly error reporting details.While
componentDidCatchlogs the error to the console, you may want to provide an option for users to submit the error details (or aggregated logs) to further help debugging.
19-36: Enable customization of the fallback UI.Currently, the fallback message is hardcoded. Exposing an optional fallback component or message prop can allow more flexible error presentation and a customized user experience.
apps/registry/app/api/transcribe/route.js (1)
34-46: Improve file cleanup reliability.The current implementation attempts file cleanup twice, which is redundant. Consider using a more reliable cleanup approach.
- // Clean up the temporary file - fs.unlink(tmpFilePath, (err) => { - if (err) console.error('Error deleting temporary file:', err); - }); - return Response.json({ text: transcription }); } finally { // Ensure we try to clean up the temp file even if transcription fails try { fs.unlinkSync(tmpFilePath); } catch (e) { - // Ignore errors during cleanup + console.error('Error during file cleanup:', e); } }apps/registry/app/hooks/useSpeech.js (2)
38-46: Consider using a more efficient retry strategy.The current implementation uses fixed retry intervals. Consider using an exponential backoff strategy for better efficiency.
- const retryTimes = [100, 500, 1000, 2000]; - retryTimes.forEach(time => { - setTimeout(loadVoices, time); - }); + let retryCount = 0; + const maxRetries = 4; + const retryWithBackoff = () => { + if (retryCount < maxRetries) { + const delay = Math.pow(2, retryCount) * 100; + setTimeout(() => { + loadVoices(); + retryCount++; + retryWithBackoff(); + }, delay); + } + }; + retryWithBackoff();
100-108: Document the Chrome workaround.The Chrome-specific workaround should be documented to explain why it's necessary.
- // Chrome workaround: speak a short text first + // Chrome bug workaround: Speaking an empty utterance first prevents the main utterance + // from being cut off at the beginning. See: https://bugs.chromium.org/p/chromium/issues/detail?id=335907 const warmup = new SpeechSynthesisUtterance('');apps/registry/app/components/AIChatEditor.js (2)
8-15: Consider using a dedicated storage hook.The localStorage logic could be extracted into a custom hook for better reusability.
+const useLocalStorage = (key, initialValue, maxItems) => { + const [storedValue, setStoredValue] = useState(() => { + try { + const item = localStorage.getItem(key); + if (item) { + const parsed = JSON.parse(item); + return maxItems ? parsed.slice(-maxItems) : parsed; + } + return initialValue; + } catch (error) { + console.error(error); + return initialValue; + } + }); + + useEffect(() => { + try { + localStorage.setItem(key, JSON.stringify(storedValue)); + } catch (error) { + console.error(error); + } + }, [key, storedValue]); + + return [storedValue, setStoredValue]; +}; - const [messages, setMessages] = useState(() => { - const savedMessages = localStorage.getItem('resumeAiChatMessages'); - if (savedMessages) { - const parsed = JSON.parse(savedMessages); - return parsed.slice(-30); // Keep only last 30 messages - } - return []; - }); + const [messages, setMessages] = useLocalStorage('resumeAiChatMessages', [], 30);
272-278: Add ARIA labels for better accessibility.The input field should have proper ARIA labels for screen readers.
<input type="text" value={inputMessage} onChange={(e) => setInputMessage(e.target.value)} onKeyPress={(e) => e.key === 'Enter' && handleSubmitMessage(inputMessage)} placeholder="Type your message..." + aria-label="Message input" + role="textbox" className="flex-1 p-2 border rounded" />
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (11)
.windsurfrules(1 hunks)apps/registry/app/api/chat/route.js(1 hunks)apps/registry/app/api/transcribe/route.js(1 hunks)apps/registry/app/components/AIChatEditor.js(1 hunks)apps/registry/app/components/ErrorBoundary.js(1 hunks)apps/registry/app/components/GuiEditor.js(1 hunks)apps/registry/app/components/ResumeEditor.js(2 hunks)apps/registry/app/hooks/useSettings.js(1 hunks)apps/registry/app/hooks/useSpeech.js(1 hunks)apps/registry/package.json(1 hunks)apps/registry/scripts/jobs/gpted.js(3 hunks)
🔇 Additional comments (8)
apps/registry/app/api/chat/route.js (2)
1-6: Consider adding checks for missing environment variables.
Currently, ifprocess.env.OPENAI_API_KEYis undefined, theOpenAIinstance setup might fail without a clear error message. Adding a fallback or explicit error throw could improve stability and troubleshooting.Would you like me to generate a script or additional logic to confirm that
OPENAI_API_KEYis set at deployment?
84-108: Add a request body shape mention or TypeScript interface.
The code expects{ messages, currentResume }fromreq.json(). Profile your code for potential missing fields. Using TypeScript or runtime checks ensures robust request handling.apps/registry/app/components/ResumeEditor.js (1)
14-43: Validate or sanitize default values.
ThedefaultResumeprovides blank fields, which is good as a fallback. However, ensure that further down the pipeline, empty strings do not propagate incorrectly. For instance, certain required fields might inadvertently remain empty in final exports.apps/registry/scripts/jobs/gpted.js (2)
280-280: Evaluate performance impacts of a 20-day window.
Changing from 24 hours to 20 days (line 280) might result in retrieving significantly more data. Consider caching, pagination, or chunked processing to avoid timeouts or crashes on large datasets.Would you like me to generate a script to measure how many rows are being fetched from your Supabase table over a 20-day period?
379-385: Watch out for custom model naming.
You’re using'gpt-4o-mini', which might be an internal or custom name. Ensure it matches a real or authorized model in your environment. If this is a placeholder, add fallback logic or documentation.apps/registry/app/hooks/useSettings.js (1)
3-7: Handle SSR scenarios gracefully.When using Next.js,
localStorageisn't available during SSR. Consider using defensive checks (e.g.,typeof window !== 'undefined') or a fallback to maintain consistent behavior on the server and client..windsurfrules (1)
1-90: LGTM! Comprehensive best practices guide.The best practices guide covers essential aspects of each technology and provides valuable guidelines for maintaining code quality and performance.
apps/registry/package.json (1)
99-99: Dependency Upgrade: OpenAI Package Version UpdatedThe
openaidependency is now upgraded to"^4.28.0", which aligns with the new API endpoints and functionalities introduced in this PR. Please verify that this version is fully compatible with all modules that interact with the OpenAI API and review the official release notes for any potential breaking changes or new features that might affect the application.
| const handleApplyChanges = useCallback( | ||
| (changes) => { | ||
| const newResume = { ...resume }; | ||
|
|
||
| // Helper function to intelligently merge arrays | ||
| const mergeArrays = (existingArray = [], newArray = [], key = 'name') => { | ||
| const result = [...existingArray]; | ||
|
|
||
| newArray.forEach((newItem) => { | ||
| // For deletion (marked with _delete flag) | ||
| if (newItem._delete) { | ||
| const index = result.findIndex( | ||
| (item) => | ||
| item[key] === newItem[key] || | ||
| (item.startDate === newItem.startDate && | ||
| item.endDate === newItem.endDate), | ||
| ); | ||
| if (index !== -1) { | ||
| result.splice(index, 1); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| // For updates or additions | ||
| const existingIndex = result.findIndex( | ||
| (item) => | ||
| item[key] === newItem[key] || | ||
| (item.startDate === newItem.startDate && | ||
| item.endDate === newItem.endDate), | ||
| ); | ||
|
|
||
| if (existingIndex !== -1) { | ||
| // Update existing item | ||
| result[existingIndex] = { ...result[existingIndex], ...newItem }; | ||
| } else { | ||
| // Add new item | ||
| result.push(newItem); | ||
| } | ||
| }); | ||
|
|
||
| return result; | ||
| }; | ||
|
|
||
| // Process each section of changes | ||
| Object.entries(changes).forEach(([section, value]) => { | ||
| if (Array.isArray(value)) { | ||
| // Handle array sections (work, education, etc.) | ||
| newResume[section] = mergeArrays( | ||
| resume[section], | ||
| value, | ||
| section === 'skills' ? 'name' : 'name', // Use appropriate key for matching | ||
| ); | ||
| } else if (typeof value === 'object' && value !== null) { | ||
| // Handle nested objects | ||
| newResume[section] = { ...resume[section], ...value }; | ||
| } else { | ||
| // Handle primitive values | ||
| newResume[section] = value; | ||
| } | ||
| }); | ||
|
|
||
| setResume(newResume); | ||
| }, | ||
| [resume], | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Refine the array merge logic.
The mergeArrays method is useful, but it’s keyed to certain fields like name, startDate, and endDate. If the user wants to remove or rename those fields, or if the resume schema changes significantly, edge cases could arise. Consider making the matching criteria more explicit or configurable so that future schema changes don’t break the function.
| if (iframeRef.current) { | ||
| iframeRef.current.srcdoc = htmlString; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider sanitizing HTML for security.
Using iframeRef.current.srcdoc = htmlString can introduce an XSS risk if htmlString is ever user-provided or untrusted. Implement a sanitization step (e.g., DOMPurify) if there's any chance that the content might come from untrusted sources.
- iframeRef.current.srcdoc = htmlString;
+ iframeRef.current.srcdoc = sanitizeHtmlString(htmlString);Committable suggestion skipped: line range outside the PR's diff.
| const { data: companyData, error: companyError } = await supabase | ||
| .from('companies') | ||
| .select() | ||
| .eq('name', company); | ||
|
|
||
| const parsedCompanyData = JSON.parse(companyData[0].data); | ||
|
|
||
| // exit if no company data | ||
| if (companyError || !parsedCompanyData) { | ||
| // exit node | ||
| process.exit(1); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Avoid abrupt process.exit(1) in production.
Terminating the Node.js process can disrupt server uptime. Instead, handle errors gracefully, log them, or move this logic to a script exit only if this code is guaranteed to run as a one-off job.
| export default function GuiEditor({ resume, onChange }) { | ||
| const updateBasics = (field, value) => { | ||
| onChange({ | ||
| ...resume, | ||
| basics: { | ||
| ...resume.basics, | ||
| [field]: value, | ||
| }, | ||
| }); | ||
| }; | ||
|
|
||
| const updateLocation = (field, value) => { | ||
| onChange({ | ||
| ...resume, | ||
| basics: { | ||
| ...resume.basics, | ||
| location: { | ||
| ...resume.basics?.location, | ||
| [field]: value, | ||
| }, | ||
| }, | ||
| }); | ||
| }; | ||
|
|
||
| const updateWorkExperience = (index, field, value) => { | ||
| const newWork = [...(resume.work || [])]; | ||
| newWork[index] = { ...newWork[index], [field]: value }; | ||
| onChange({ ...resume, work: newWork }); | ||
| }; | ||
|
|
||
| const addWorkExperience = () => { | ||
| onChange({ | ||
| ...resume, | ||
| work: [ | ||
| ...(resume.work || []), | ||
| { | ||
| name: '', | ||
| position: '', | ||
| startDate: '', | ||
| endDate: '', | ||
| location: '', | ||
| url: '', | ||
| summary: '', | ||
| description: '', | ||
| highlights: [], | ||
| }, | ||
| ], | ||
| }); | ||
| }; | ||
|
|
||
| const removeWorkExperience = (index) => { | ||
| const newWork = [...(resume.work || [])]; | ||
| newWork.splice(index, 1); | ||
| onChange({ ...resume, work: newWork }); | ||
| }; | ||
|
|
||
| const addHighlight = (workIndex) => { | ||
| const newWork = [...(resume.work || [])]; | ||
| newWork[workIndex] = { | ||
| ...newWork[workIndex], | ||
| highlights: [...(newWork[workIndex].highlights || []), ''], | ||
| }; | ||
| onChange({ ...resume, work: newWork }); | ||
| }; | ||
|
|
||
| const updateHighlight = (workIndex, highlightIndex, value) => { | ||
| const newWork = [...(resume.work || [])]; | ||
| newWork[workIndex].highlights[highlightIndex] = value; | ||
| onChange({ ...resume, work: newWork }); | ||
| }; | ||
|
|
||
| const removeHighlight = (workIndex, highlightIndex) => { | ||
| const newWork = [...(resume.work || [])]; | ||
| newWork[workIndex].highlights.splice(highlightIndex, 1); | ||
| onChange({ ...resume, work: newWork }); | ||
| }; | ||
|
|
||
| const updateProfiles = (index, field, value) => { | ||
| const newProfiles = [...(resume.basics?.profiles || [])]; | ||
| newProfiles[index] = { ...newProfiles[index], [field]: value }; | ||
| onChange({ | ||
| ...resume, | ||
| basics: { | ||
| ...resume.basics, | ||
| profiles: newProfiles, | ||
| }, | ||
| }); | ||
| }; | ||
|
|
||
| const addProfile = () => { | ||
| onChange({ | ||
| ...resume, | ||
| basics: { | ||
| ...resume.basics, | ||
| profiles: [ | ||
| ...(resume.basics?.profiles || []), | ||
| { network: '', username: '', url: '' }, | ||
| ], | ||
| }, | ||
| }); | ||
| }; | ||
|
|
||
| const removeProfile = (index) => { | ||
| const newProfiles = [...(resume.basics?.profiles || [])]; | ||
| newProfiles.splice(index, 1); | ||
| onChange({ | ||
| ...resume, | ||
| basics: { | ||
| ...resume.basics, | ||
| profiles: newProfiles, | ||
| }, | ||
| }); | ||
| }; | ||
|
|
||
| const updateVolunteer = (index, field, value) => { | ||
| const newVolunteer = [...(resume.volunteer || [])]; | ||
| newVolunteer[index] = { ...newVolunteer[index], [field]: value }; | ||
| onChange({ ...resume, volunteer: newVolunteer }); | ||
| }; | ||
|
|
||
| const updateEducation = (index, field, value) => { | ||
| const newEducation = [...(resume.education || [])]; | ||
| newEducation[index] = { ...newEducation[index], [field]: value }; | ||
| onChange({ ...resume, education: newEducation }); | ||
| }; | ||
|
|
||
| const updateAwards = (index, field, value) => { | ||
| const newAwards = [...(resume.awards || [])]; | ||
| newAwards[index] = { ...newAwards[index], [field]: value }; | ||
| onChange({ ...resume, awards: newAwards }); | ||
| }; | ||
|
|
||
| const updateCertificates = (index, field, value) => { | ||
| const newCertificates = [...(resume.certificates || [])]; | ||
| newCertificates[index] = { ...newCertificates[index], [field]: value }; | ||
| onChange({ ...resume, certificates: newCertificates }); | ||
| }; | ||
|
|
||
| const updatePublications = (index, field, value) => { | ||
| const newPublications = [...(resume.publications || [])]; | ||
| newPublications[index] = { ...newPublications[index], [field]: value }; | ||
| onChange({ ...resume, publications: newPublications }); | ||
| }; | ||
|
|
||
| const updateSkills = (index, field, value) => { | ||
| const newSkills = [...(resume.skills || [])]; | ||
| newSkills[index] = { ...newSkills[index], [field]: value }; | ||
| onChange({ ...resume, skills: newSkills }); | ||
| }; | ||
|
|
||
| const updateLanguages = (index, field, value) => { | ||
| const newLanguages = [...(resume.languages || [])]; | ||
| newLanguages[index] = { ...newLanguages[index], [field]: value }; | ||
| onChange({ ...resume, languages: newLanguages }); | ||
| }; | ||
|
|
||
| const updateInterests = (index, field, value) => { | ||
| const newInterests = [...(resume.interests || [])]; | ||
| newInterests[index] = { ...newInterests[index], [field]: value }; | ||
| onChange({ ...resume, interests: newInterests }); | ||
| }; | ||
|
|
||
| const updateReferences = (index, field, value) => { | ||
| const newReferences = [...(resume.references || [])]; | ||
| newReferences[index] = { ...newReferences[index], [field]: value }; | ||
| onChange({ ...resume, references: newReferences }); | ||
| }; | ||
|
|
||
| const updateProjects = (index, field, value) => { | ||
| const newProjects = [...(resume.projects || [])]; | ||
| newProjects[index] = { ...newProjects[index], [field]: value }; | ||
| onChange({ ...resume, projects: newProjects }); | ||
| }; | ||
|
|
||
| const addArrayItem = (section, template) => { | ||
| onChange({ | ||
| ...resume, | ||
| [section]: [...(resume[section] || []), template], | ||
| }); | ||
| }; | ||
|
|
||
| const removeArrayItem = (section, index) => { | ||
| const newArray = [...(resume[section] || [])]; | ||
| newArray.splice(index, 1); | ||
| onChange({ ...resume, [section]: newArray }); | ||
| }; | ||
|
|
||
| return ( | ||
| <div className="h-full overflow-auto p-4"> | ||
| <FormSection title="Basic Information" defaultOpen={true}> | ||
| <div className="grid grid-cols-2 gap-4"> | ||
| <FormField | ||
| label="Full Name" | ||
| value={resume.basics?.name} | ||
| onChange={(value) => updateBasics('name', value)} | ||
| placeholder="John Doe" | ||
| /> | ||
| <FormField | ||
| label="Label" | ||
| value={resume.basics?.label} | ||
| onChange={(value) => updateBasics('label', value)} | ||
| placeholder="Software Engineer" | ||
| /> | ||
| <FormField | ||
| label="Email" | ||
| type="email" | ||
| value={resume.basics?.email} | ||
| onChange={(value) => updateBasics('email', value)} | ||
| placeholder="john@example.com" | ||
| /> | ||
| <FormField | ||
| label="Phone" | ||
| value={resume.basics?.phone} | ||
| onChange={(value) => updateBasics('phone', value)} | ||
| placeholder="+1 (123) 456-7890" | ||
| /> | ||
| <FormField | ||
| label="Website" | ||
| type="url" | ||
| value={resume.basics?.url} | ||
| onChange={(value) => updateBasics('url', value)} | ||
| placeholder="https://example.com" | ||
| /> | ||
| <FormField | ||
| label="Image URL" | ||
| type="url" | ||
| value={resume.basics?.image} | ||
| onChange={(value) => updateBasics('image', value)} | ||
| placeholder="https://example.com/photo.jpg" | ||
| /> | ||
| </div> | ||
| <FormField | ||
| label="Summary" | ||
| type="textarea" | ||
| value={resume.basics?.summary} | ||
| onChange={(value) => updateBasics('summary', value)} | ||
| placeholder="A brief summary about yourself..." | ||
| /> | ||
| </FormSection> | ||
|
|
||
| <FormSection title="Location"> | ||
| <div className="grid grid-cols-2 gap-4"> | ||
| <FormField | ||
| label="Address" | ||
| type="textarea" | ||
| value={resume.basics?.location?.address} | ||
| onChange={(value) => updateLocation('address', value)} | ||
| placeholder="123 Main St" | ||
| /> | ||
| <FormField | ||
| label="Postal Code" | ||
| value={resume.basics?.location?.postalCode} | ||
| onChange={(value) => updateLocation('postalCode', value)} | ||
| placeholder="12345" | ||
| /> | ||
| <FormField | ||
| label="City" | ||
| value={resume.basics?.location?.city} | ||
| onChange={(value) => updateLocation('city', value)} | ||
| placeholder="San Francisco" | ||
| /> | ||
| <FormField | ||
| label="Region" | ||
| value={resume.basics?.location?.region} | ||
| onChange={(value) => updateLocation('region', value)} | ||
| placeholder="California" | ||
| /> | ||
| <FormField | ||
| label="Country Code" | ||
| value={resume.basics?.location?.countryCode} | ||
| onChange={(value) => updateLocation('countryCode', value)} | ||
| placeholder="US" | ||
| /> | ||
| </div> | ||
| </FormSection> | ||
|
|
||
| <FormSection title="Work Experience"> | ||
| <ArrayField | ||
| items={resume.work || []} | ||
| onAdd={addWorkExperience} | ||
| onRemove={removeWorkExperience} | ||
| addLabel="Add Work Experience" | ||
| renderItem={(item, index) => ( | ||
| <div className="space-y-4"> | ||
| <div className="grid grid-cols-2 gap-4"> | ||
| <FormField | ||
| label="Company Name" | ||
| value={item.name} | ||
| onChange={(value) => | ||
| updateWorkExperience(index, 'name', value) | ||
| } | ||
| placeholder="Company Name" | ||
| /> | ||
| <FormField | ||
| label="Position" | ||
| value={item.position} | ||
| onChange={(value) => | ||
| updateWorkExperience(index, 'position', value) | ||
| } | ||
| placeholder="Job Title" | ||
| /> | ||
| <FormField | ||
| label="Start Date" | ||
| value={item.startDate} | ||
| onChange={(value) => | ||
| updateWorkExperience(index, 'startDate', value) | ||
| } | ||
| placeholder="YYYY-MM" | ||
| /> | ||
| <FormField | ||
| label="End Date" | ||
| value={item.endDate} | ||
| onChange={(value) => | ||
| updateWorkExperience(index, 'endDate', value) | ||
| } | ||
| placeholder="YYYY-MM or Present" | ||
| /> | ||
| <FormField | ||
| label="Location" | ||
| value={item.location} | ||
| onChange={(value) => | ||
| updateWorkExperience(index, 'location', value) | ||
| } | ||
| placeholder="City, Country" | ||
| /> | ||
| <FormField | ||
| label="Website" | ||
| type="url" | ||
| value={item.url} | ||
| onChange={(value) => | ||
| updateWorkExperience(index, 'url', value) | ||
| } | ||
| placeholder="https://company.com" | ||
| /> | ||
| </div> | ||
| <FormField | ||
| label="Summary" | ||
| type="textarea" | ||
| value={item.summary} | ||
| onChange={(value) => | ||
| updateWorkExperience(index, 'summary', value) | ||
| } | ||
| placeholder="Brief summary of your role and responsibilities..." | ||
| /> | ||
| <FormField | ||
| label="Description" | ||
| type="textarea" | ||
| value={item.description} | ||
| onChange={(value) => | ||
| updateWorkExperience(index, 'description', value) | ||
| } | ||
| placeholder="Detailed description of your work experience..." | ||
| /> | ||
| <div className="space-y-2"> | ||
| <label className="block text-sm font-medium text-gray-700"> | ||
| Highlights | ||
| </label> | ||
| {(item.highlights || []).map((highlight, hIndex) => ( | ||
| <div key={hIndex} className="flex gap-2"> | ||
| <input | ||
| type="text" | ||
| value={highlight} | ||
| onChange={(e) => | ||
| updateHighlight(index, hIndex, e.target.value) | ||
| } | ||
| className="flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" | ||
| placeholder="Achievement or responsibility..." | ||
| /> | ||
| <Button | ||
| variant="outline" | ||
| size="sm" | ||
| onClick={() => removeHighlight(index, hIndex)} | ||
| > | ||
| <Trash2 className="w-4 h-4" /> | ||
| </Button> | ||
| </div> | ||
| ))} | ||
| <Button | ||
| variant="outline" | ||
| size="sm" | ||
| onClick={() => addHighlight(index)} | ||
| className="w-full flex items-center justify-center gap-2" | ||
| > | ||
| <Plus className="w-4 h-4" /> | ||
| Add Highlight | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| )} | ||
| /> | ||
| </FormSection> | ||
|
|
||
| <FormSection title="Profiles"> | ||
| <ArrayField | ||
| items={resume.basics?.profiles || []} | ||
| onAdd={addProfile} | ||
| onRemove={removeProfile} | ||
| addLabel="Add Profile" | ||
| renderItem={(item, index) => ( | ||
| <div className="grid grid-cols-2 gap-4"> | ||
| <FormField | ||
| label="Network" | ||
| value={item.network} | ||
| onChange={(value) => updateProfiles(index, 'network', value)} | ||
| placeholder="Twitter, LinkedIn, etc." | ||
| /> | ||
| <FormField | ||
| label="Username" | ||
| value={item.username} | ||
| onChange={(value) => updateProfiles(index, 'username', value)} | ||
| placeholder="johndoe" | ||
| /> | ||
| <FormField | ||
| label="URL" | ||
| type="url" | ||
| value={item.url} | ||
| onChange={(value) => updateProfiles(index, 'url', value)} | ||
| placeholder="https://twitter.com/johndoe" | ||
| /> | ||
| </div> | ||
| )} | ||
| /> | ||
| </FormSection> | ||
|
|
||
| <FormSection title="Volunteer Experience"> | ||
| <ArrayField | ||
| items={resume.volunteer || []} | ||
| onAdd={() => | ||
| addArrayItem('volunteer', { | ||
| organization: '', | ||
| position: '', | ||
| url: '', | ||
| startDate: '', | ||
| endDate: '', | ||
| summary: '', | ||
| highlights: [], | ||
| }) | ||
| } | ||
| onRemove={(index) => removeArrayItem('volunteer', index)} | ||
| addLabel="Add Volunteer Experience" | ||
| renderItem={(item, index) => ( | ||
| <div className="space-y-4"> | ||
| <div className="grid grid-cols-2 gap-4"> | ||
| <FormField | ||
| label="Organization" | ||
| value={item.organization} | ||
| onChange={(value) => | ||
| updateVolunteer(index, 'organization', value) | ||
| } | ||
| placeholder="Organization Name" | ||
| /> | ||
| <FormField | ||
| label="Position" | ||
| value={item.position} | ||
| onChange={(value) => | ||
| updateVolunteer(index, 'position', value) | ||
| } | ||
| placeholder="Volunteer Position" | ||
| /> | ||
| <FormField | ||
| label="Start Date" | ||
| value={item.startDate} | ||
| onChange={(value) => | ||
| updateVolunteer(index, 'startDate', value) | ||
| } | ||
| placeholder="YYYY-MM" | ||
| /> | ||
| <FormField | ||
| label="End Date" | ||
| value={item.endDate} | ||
| onChange={(value) => updateVolunteer(index, 'endDate', value)} | ||
| placeholder="YYYY-MM or Present" | ||
| /> | ||
| <FormField | ||
| label="URL" | ||
| type="url" | ||
| value={item.url} | ||
| onChange={(value) => updateVolunteer(index, 'url', value)} | ||
| placeholder="https://organization.com" | ||
| /> | ||
| </div> | ||
| <FormField | ||
| label="Summary" | ||
| type="textarea" | ||
| value={item.summary} | ||
| onChange={(value) => updateVolunteer(index, 'summary', value)} | ||
| placeholder="Description of your volunteer work..." | ||
| /> | ||
| <ArrayField | ||
| items={item.highlights || []} | ||
| onAdd={() => { | ||
| const newVolunteer = [...(resume.volunteer || [])]; | ||
| newVolunteer[index] = { | ||
| ...newVolunteer[index], | ||
| highlights: [...(newVolunteer[index].highlights || []), ''], | ||
| }; | ||
| onChange({ ...resume, volunteer: newVolunteer }); | ||
| }} | ||
| onRemove={(highlightIndex) => { | ||
| const newVolunteer = [...(resume.volunteer || [])]; | ||
| newVolunteer[index].highlights.splice(highlightIndex, 1); | ||
| onChange({ ...resume, volunteer: newVolunteer }); | ||
| }} | ||
| addLabel="Add Highlight" | ||
| renderItem={(highlight, highlightIndex) => ( | ||
| <FormField | ||
| value={highlight} | ||
| onChange={(value) => { | ||
| const newVolunteer = [...(resume.volunteer || [])]; | ||
| newVolunteer[index].highlights[highlightIndex] = value; | ||
| onChange({ ...resume, volunteer: newVolunteer }); | ||
| }} | ||
| placeholder="Achievement or responsibility..." | ||
| /> | ||
| )} | ||
| /> | ||
| </div> | ||
| )} | ||
| /> | ||
| </FormSection> | ||
|
|
||
| <FormSection title="Education"> | ||
| <ArrayField | ||
| items={resume.education || []} | ||
| onAdd={() => | ||
| addArrayItem('education', { | ||
| institution: '', | ||
| area: '', | ||
| studyType: '', | ||
| startDate: '', | ||
| endDate: '', | ||
| score: '', | ||
| courses: [], | ||
| }) | ||
| } | ||
| onRemove={(index) => removeArrayItem('education', index)} | ||
| addLabel="Add Education" | ||
| renderItem={(item, index) => ( | ||
| <div className="space-y-4"> | ||
| <div className="grid grid-cols-2 gap-4"> | ||
| <FormField | ||
| label="Institution" | ||
| value={item.institution} | ||
| onChange={(value) => | ||
| updateEducation(index, 'institution', value) | ||
| } | ||
| placeholder="University Name" | ||
| /> | ||
| <FormField | ||
| label="Area" | ||
| value={item.area} | ||
| onChange={(value) => updateEducation(index, 'area', value)} | ||
| placeholder="Field of Study" | ||
| /> | ||
| <FormField | ||
| label="Study Type" | ||
| value={item.studyType} | ||
| onChange={(value) => | ||
| updateEducation(index, 'studyType', value) | ||
| } | ||
| placeholder="Bachelor, Master, etc." | ||
| /> | ||
| <FormField | ||
| label="Start Date" | ||
| value={item.startDate} | ||
| onChange={(value) => | ||
| updateEducation(index, 'startDate', value) | ||
| } | ||
| placeholder="YYYY-MM" | ||
| /> | ||
| <FormField | ||
| label="End Date" | ||
| value={item.endDate} | ||
| onChange={(value) => updateEducation(index, 'endDate', value)} | ||
| placeholder="YYYY-MM or Present" | ||
| /> | ||
| <FormField | ||
| label="Score" | ||
| value={item.score} | ||
| onChange={(value) => updateEducation(index, 'score', value)} | ||
| placeholder="Grade or GPA" | ||
| /> | ||
| </div> | ||
| <ArrayField | ||
| items={item.courses || []} | ||
| onAdd={() => { | ||
| const newEducation = [...(resume.education || [])]; | ||
| newEducation[index] = { | ||
| ...newEducation[index], | ||
| courses: [...(newEducation[index].courses || []), ''], | ||
| }; | ||
| onChange({ ...resume, education: newEducation }); | ||
| }} | ||
| onRemove={(courseIndex) => { | ||
| const newEducation = [...(resume.education || [])]; | ||
| newEducation[index].courses.splice(courseIndex, 1); | ||
| onChange({ ...resume, education: newEducation }); | ||
| }} | ||
| addLabel="Add Course" | ||
| renderItem={(course, courseIndex) => ( | ||
| <FormField | ||
| value={course} | ||
| onChange={(value) => { | ||
| const newEducation = [...(resume.education || [])]; | ||
| newEducation[index].courses[courseIndex] = value; | ||
| onChange({ ...resume, education: newEducation }); | ||
| }} | ||
| placeholder="Course name or code" | ||
| /> | ||
| )} | ||
| /> | ||
| </div> | ||
| )} | ||
| /> | ||
| </FormSection> | ||
|
|
||
| <FormSection title="Awards"> | ||
| <ArrayField | ||
| items={resume.awards || []} | ||
| onAdd={() => | ||
| addArrayItem('awards', { | ||
| title: '', | ||
| date: '', | ||
| awarder: '', | ||
| summary: '', | ||
| }) | ||
| } | ||
| onRemove={(index) => removeArrayItem('awards', index)} | ||
| addLabel="Add Award" | ||
| renderItem={(item, index) => ( | ||
| <div className="grid grid-cols-2 gap-4"> | ||
| <FormField | ||
| label="Title" | ||
| value={item.title} | ||
| onChange={(value) => updateAwards(index, 'title', value)} | ||
| placeholder="Award Title" | ||
| /> | ||
| <FormField | ||
| label="Date" | ||
| value={item.date} | ||
| onChange={(value) => updateAwards(index, 'date', value)} | ||
| placeholder="YYYY-MM-DD" | ||
| /> | ||
| <FormField | ||
| label="Awarder" | ||
| value={item.awarder} | ||
| onChange={(value) => updateAwards(index, 'awarder', value)} | ||
| placeholder="Organization" | ||
| /> | ||
| <FormField | ||
| label="Summary" | ||
| type="textarea" | ||
| value={item.summary} | ||
| onChange={(value) => updateAwards(index, 'summary', value)} | ||
| placeholder="Description of the award..." | ||
| /> | ||
| </div> | ||
| )} | ||
| /> | ||
| </FormSection> | ||
|
|
||
| <FormSection title="Certificates"> | ||
| <ArrayField | ||
| items={resume.certificates || []} | ||
| onAdd={() => | ||
| addArrayItem('certificates', { | ||
| name: '', | ||
| date: '', | ||
| issuer: '', | ||
| url: '', | ||
| }) | ||
| } | ||
| onRemove={(index) => removeArrayItem('certificates', index)} | ||
| addLabel="Add Certificate" | ||
| renderItem={(item, index) => ( | ||
| <div className="grid grid-cols-2 gap-4"> | ||
| <FormField | ||
| label="Name" | ||
| value={item.name} | ||
| onChange={(value) => updateCertificates(index, 'name', value)} | ||
| placeholder="Certificate Name" | ||
| /> | ||
| <FormField | ||
| label="Date" | ||
| value={item.date} | ||
| onChange={(value) => updateCertificates(index, 'date', value)} | ||
| placeholder="YYYY-MM-DD" | ||
| /> | ||
| <FormField | ||
| label="Issuer" | ||
| value={item.issuer} | ||
| onChange={(value) => updateCertificates(index, 'issuer', value)} | ||
| placeholder="Issuing Organization" | ||
| /> | ||
| <FormField | ||
| label="URL" | ||
| type="url" | ||
| value={item.url} | ||
| onChange={(value) => updateCertificates(index, 'url', value)} | ||
| placeholder="https://certificate.com" | ||
| /> | ||
| </div> | ||
| )} | ||
| /> | ||
| </FormSection> | ||
|
|
||
| <FormSection title="Publications"> | ||
| <ArrayField | ||
| items={resume.publications || []} | ||
| onAdd={() => | ||
| addArrayItem('publications', { | ||
| name: '', | ||
| publisher: '', | ||
| releaseDate: '', | ||
| url: '', | ||
| summary: '', | ||
| }) | ||
| } | ||
| onRemove={(index) => removeArrayItem('publications', index)} | ||
| addLabel="Add Publication" | ||
| renderItem={(item, index) => ( | ||
| <div className="grid grid-cols-2 gap-4"> | ||
| <FormField | ||
| label="Name" | ||
| value={item.name} | ||
| onChange={(value) => updatePublications(index, 'name', value)} | ||
| placeholder="Publication Title" | ||
| /> | ||
| <FormField | ||
| label="Publisher" | ||
| value={item.publisher} | ||
| onChange={(value) => | ||
| updatePublications(index, 'publisher', value) | ||
| } | ||
| placeholder="Publisher Name" | ||
| /> | ||
| <FormField | ||
| label="Release Date" | ||
| value={item.releaseDate} | ||
| onChange={(value) => | ||
| updatePublications(index, 'releaseDate', value) | ||
| } | ||
| placeholder="YYYY-MM-DD" | ||
| /> | ||
| <FormField | ||
| label="URL" | ||
| type="url" | ||
| value={item.url} | ||
| onChange={(value) => updatePublications(index, 'url', value)} | ||
| placeholder="https://publication.com" | ||
| /> | ||
| <FormField | ||
| label="Summary" | ||
| type="textarea" | ||
| value={item.summary} | ||
| onChange={(value) => | ||
| updatePublications(index, 'summary', value) | ||
| } | ||
| placeholder="Description of the publication..." | ||
| /> | ||
| </div> | ||
| )} | ||
| /> | ||
| </FormSection> | ||
|
|
||
| <FormSection title="Skills"> | ||
| <ArrayField | ||
| items={resume.skills || []} | ||
| onAdd={() => | ||
| addArrayItem('skills', { | ||
| name: '', | ||
| level: '', | ||
| keywords: [], | ||
| }) | ||
| } | ||
| onRemove={(index) => removeArrayItem('skills', index)} | ||
| addLabel="Add Skill" | ||
| renderItem={(item, index) => ( | ||
| <div className="space-y-4"> | ||
| <div className="grid grid-cols-2 gap-4"> | ||
| <FormField | ||
| label="Name" | ||
| value={item.name} | ||
| onChange={(value) => updateSkills(index, 'name', value)} | ||
| placeholder="Skill Name" | ||
| /> | ||
| <FormField | ||
| label="Level" | ||
| value={item.level} | ||
| onChange={(value) => updateSkills(index, 'level', value)} | ||
| placeholder="Beginner, Intermediate, Expert" | ||
| /> | ||
| </div> | ||
| <ArrayField | ||
| items={item.keywords || []} | ||
| onAdd={() => { | ||
| const newSkills = [...(resume.skills || [])]; | ||
| newSkills[index] = { | ||
| ...newSkills[index], | ||
| keywords: [...(newSkills[index].keywords || []), ''], | ||
| }; | ||
| onChange({ ...resume, skills: newSkills }); | ||
| }} | ||
| onRemove={(keywordIndex) => { | ||
| const newSkills = [...(resume.skills || [])]; | ||
| newSkills[index].keywords.splice(keywordIndex, 1); | ||
| onChange({ ...resume, skills: newSkills }); | ||
| }} | ||
| addLabel="Add Keyword" | ||
| renderItem={(keyword, keywordIndex) => ( | ||
| <FormField | ||
| value={keyword} | ||
| onChange={(value) => { | ||
| const newSkills = [...(resume.skills || [])]; | ||
| newSkills[index].keywords[keywordIndex] = value; | ||
| onChange({ ...resume, skills: newSkills }); | ||
| }} | ||
| placeholder="Related technology or tool" | ||
| /> | ||
| )} | ||
| /> | ||
| </div> | ||
| )} | ||
| /> | ||
| </FormSection> | ||
|
|
||
| <FormSection title="Languages"> | ||
| <ArrayField | ||
| items={resume.languages || []} | ||
| onAdd={() => | ||
| addArrayItem('languages', { | ||
| language: '', | ||
| fluency: '', | ||
| }) | ||
| } | ||
| onRemove={(index) => removeArrayItem('languages', index)} | ||
| addLabel="Add Language" | ||
| renderItem={(item, index) => ( | ||
| <div className="grid grid-cols-2 gap-4"> | ||
| <FormField | ||
| label="Language" | ||
| value={item.language} | ||
| onChange={(value) => updateLanguages(index, 'language', value)} | ||
| placeholder="Language Name" | ||
| /> | ||
| <FormField | ||
| label="Fluency" | ||
| value={item.fluency} | ||
| onChange={(value) => updateLanguages(index, 'fluency', value)} | ||
| placeholder="Native Speaker, Fluent, Intermediate, etc." | ||
| /> | ||
| </div> | ||
| )} | ||
| /> | ||
| </FormSection> | ||
|
|
||
| <FormSection title="Interests"> | ||
| <ArrayField | ||
| items={resume.interests || []} | ||
| onAdd={() => | ||
| addArrayItem('interests', { | ||
| name: '', | ||
| keywords: [], | ||
| }) | ||
| } | ||
| onRemove={(index) => removeArrayItem('interests', index)} | ||
| addLabel="Add Interest" | ||
| renderItem={(item, index) => ( | ||
| <div className="space-y-4"> | ||
| <FormField | ||
| label="Name" | ||
| value={item.name} | ||
| onChange={(value) => updateInterests(index, 'name', value)} | ||
| placeholder="Interest Name" | ||
| /> | ||
| <ArrayField | ||
| items={item.keywords || []} | ||
| onAdd={() => { | ||
| const newInterests = [...(resume.interests || [])]; | ||
| newInterests[index] = { | ||
| ...newInterests[index], | ||
| keywords: [...(newInterests[index].keywords || []), ''], | ||
| }; | ||
| onChange({ ...resume, interests: newInterests }); | ||
| }} | ||
| onRemove={(keywordIndex) => { | ||
| const newInterests = [...(resume.interests || [])]; | ||
| newInterests[index].keywords.splice(keywordIndex, 1); | ||
| onChange({ ...resume, interests: newInterests }); | ||
| }} | ||
| addLabel="Add Keyword" | ||
| renderItem={(keyword, keywordIndex) => ( | ||
| <FormField | ||
| value={keyword} | ||
| onChange={(value) => { | ||
| const newInterests = [...(resume.interests || [])]; | ||
| newInterests[index].keywords[keywordIndex] = value; | ||
| onChange({ ...resume, interests: newInterests }); | ||
| }} | ||
| placeholder="Related activity or topic" | ||
| /> | ||
| )} | ||
| /> | ||
| </div> | ||
| )} | ||
| /> | ||
| </FormSection> | ||
|
|
||
| <FormSection title="References"> | ||
| <ArrayField | ||
| items={resume.references || []} | ||
| onAdd={() => | ||
| addArrayItem('references', { | ||
| name: '', | ||
| reference: '', | ||
| }) | ||
| } | ||
| onRemove={(index) => removeArrayItem('references', index)} | ||
| addLabel="Add Reference" | ||
| renderItem={(item, index) => ( | ||
| <div className="grid grid-cols-2 gap-4"> | ||
| <FormField | ||
| label="Name" | ||
| value={item.name} | ||
| onChange={(value) => updateReferences(index, 'name', value)} | ||
| placeholder="Reference Name" | ||
| /> | ||
| <FormField | ||
| label="Reference" | ||
| type="textarea" | ||
| value={item.reference} | ||
| onChange={(value) => | ||
| updateReferences(index, 'reference', value) | ||
| } | ||
| placeholder="Reference text..." | ||
| /> | ||
| </div> | ||
| )} | ||
| /> | ||
| </FormSection> | ||
|
|
||
| <FormSection title="Projects"> | ||
| <ArrayField | ||
| items={resume.projects || []} | ||
| onAdd={() => | ||
| addArrayItem('projects', { | ||
| name: '', | ||
| description: '', | ||
| highlights: [], | ||
| keywords: [], | ||
| startDate: '', | ||
| endDate: '', | ||
| url: '', | ||
| roles: [], | ||
| }) | ||
| } | ||
| onRemove={(index) => removeArrayItem('projects', index)} | ||
| addLabel="Add Project" | ||
| renderItem={(item, index) => ( | ||
| <div className="space-y-4"> | ||
| <div className="grid grid-cols-2 gap-4"> | ||
| <FormField | ||
| label="Name" | ||
| value={item.name} | ||
| onChange={(value) => updateProjects(index, 'name', value)} | ||
| placeholder="Project Name" | ||
| /> | ||
| <FormField | ||
| label="URL" | ||
| type="url" | ||
| value={item.url} | ||
| onChange={(value) => updateProjects(index, 'url', value)} | ||
| placeholder="https://project.com" | ||
| /> | ||
| <FormField | ||
| label="Start Date" | ||
| value={item.startDate} | ||
| onChange={(value) => | ||
| updateProjects(index, 'startDate', value) | ||
| } | ||
| placeholder="YYYY-MM" | ||
| /> | ||
| <FormField | ||
| label="End Date" | ||
| value={item.endDate} | ||
| onChange={(value) => updateProjects(index, 'endDate', value)} | ||
| placeholder="YYYY-MM or Present" | ||
| /> | ||
| </div> | ||
| <FormField | ||
| label="Description" | ||
| type="textarea" | ||
| value={item.description} | ||
| onChange={(value) => | ||
| updateProjects(index, 'description', value) | ||
| } | ||
| placeholder="Project description..." | ||
| /> | ||
| <ArrayField | ||
| items={item.highlights || []} | ||
| onAdd={() => { | ||
| const newProjects = [...(resume.projects || [])]; | ||
| newProjects[index] = { | ||
| ...newProjects[index], | ||
| highlights: [...(newProjects[index].highlights || []), ''], | ||
| }; | ||
| onChange({ ...resume, projects: newProjects }); | ||
| }} | ||
| onRemove={(highlightIndex) => { | ||
| const newProjects = [...(resume.projects || [])]; | ||
| newProjects[index].highlights.splice(highlightIndex, 1); | ||
| onChange({ ...resume, projects: newProjects }); | ||
| }} | ||
| addLabel="Add Highlight" | ||
| renderItem={(highlight, highlightIndex) => ( | ||
| <FormField | ||
| value={highlight} | ||
| onChange={(value) => { | ||
| const newProjects = [...(resume.projects || [])]; | ||
| newProjects[index].highlights[highlightIndex] = value; | ||
| onChange({ ...resume, projects: newProjects }); | ||
| }} | ||
| placeholder="Project achievement or feature..." | ||
| /> | ||
| )} | ||
| /> | ||
| </div> | ||
| )} | ||
| /> | ||
| </FormSection> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Reduce repetitive update logic with a higher-order utility.
Throughout the main GuiEditor component, numerous sections follow a similar pattern of copying an array or object, modifying it, and passing it back via onChange. Consider implementing a single function like updateSection(sectionKey, idx, field, value) or a specialized reducer to reduce code duplication and potential errors.
| const openai = new OpenAI({ | ||
| apiKey: process.env.OPENAI_API_KEY, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
Verify OpenAI API key configuration.
Ensure that the OPENAI_API_KEY environment variable is properly configured in your deployment environment.
🏁 Script executed:
#!/bin/bash
# Description: Check if OPENAI_API_KEY is configured in environment variables.
# Note: This script will only work if the environment variables are accessible.
if [ -z "$OPENAI_API_KEY" ]; then
echo "Warning: OPENAI_API_KEY is not set"
else
echo "OPENAI_API_KEY is configured"
fiLength of output: 94
OPENAI_API_KEY is missing in the current environment
The verification script output shows that the OPENAI_API_KEY environment variable is not set. Please configure this variable in both your deployment and local testing environments to prevent runtime issues. Ensure that your environment configuration (e.g., a .env file or deployment settings) provides a valid API key before running the application.
- Location:
apps/registry/app/api/transcribe/route.js(lines 6-8) - Action: Set a valid
OPENAI_API_KEYin your environment configuration
| // Create a temporary file | ||
| const bytes = await audioFile.arrayBuffer(); | ||
| const buffer = Buffer.from(bytes); | ||
| const tmpFilePath = join('/tmp', `audio-${Date.now()}.webm`); | ||
| await writeFile(tmpFilePath, buffer); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Consider adding file size and type validation.
The current implementation doesn't validate the audio file's size or type before processing, which could lead to unnecessary processing of invalid files.
const bytes = await audioFile.arrayBuffer();
const buffer = Buffer.from(bytes);
+const maxSize = 25 * 1024 * 1024; // 25MB limit
+if (buffer.length > maxSize) {
+ return Response.json({ error: 'Audio file too large. Maximum size is 25MB.' }, { status: 400 });
+}
+if (!audioFile.type.startsWith('audio/')) {
+ return Response.json({ error: 'Invalid file type. Only audio files are allowed.' }, { status: 400 });
+}
const tmpFilePath = join('/tmp', `audio-${Date.now()}.webm`);
await writeFile(tmpFilePath, buffer);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Create a temporary file | |
| const bytes = await audioFile.arrayBuffer(); | |
| const buffer = Buffer.from(bytes); | |
| const tmpFilePath = join('/tmp', `audio-${Date.now()}.webm`); | |
| await writeFile(tmpFilePath, buffer); | |
| // Create a temporary file | |
| const bytes = await audioFile.arrayBuffer(); | |
| const buffer = Buffer.from(bytes); | |
| const maxSize = 25 * 1024 * 1024; // 25MB limit | |
| if (buffer.length > maxSize) { | |
| return Response.json({ error: 'Audio file too large. Maximum size is 25MB.' }, { status: 400 }); | |
| } | |
| if (!audioFile.type.startsWith('audio/')) { | |
| return Response.json({ error: 'Invalid file type. Only audio files are allowed.' }, { status: 400 }); | |
| } | |
| const tmpFilePath = join('/tmp', `audio-${Date.now()}.webm`); | |
| await writeFile(tmpFilePath, buffer); |
| const startRecording = async () => { | ||
| try { | ||
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | ||
| const recorder = new MediaRecorder(stream); | ||
| const chunks = []; | ||
|
|
||
| recorder.ondataavailable = (e) => chunks.push(e.data); | ||
| recorder.onstop = async () => { | ||
| const blob = new Blob(chunks, { type: 'audio/webm' }); | ||
| const formData = new FormData(); | ||
| formData.append('audio', blob); | ||
|
|
||
| try { | ||
| setIsLoading(true); | ||
| const response = await fetch('/api/transcribe', { | ||
| method: 'POST', | ||
| body: formData, | ||
| }); | ||
|
|
||
| if (!response.ok) throw new Error('Failed to transcribe audio'); | ||
|
|
||
| const data = await response.json(); | ||
| if (data.error) throw new Error(data.error); | ||
|
|
||
| // Instead of setting input, directly send the transcribed message | ||
| handleSubmitMessage(data.text); | ||
| } catch (error) { | ||
| console.error('Transcription error:', error); | ||
| addSystemMessage('Failed to transcribe audio: ' + error.message, 'error'); | ||
| } finally { | ||
| setIsLoading(false); | ||
| } | ||
|
|
||
| // Clean up the stream | ||
| stream.getTracks().forEach(track => track.stop()); | ||
| }; | ||
|
|
||
| recorder.start(); | ||
| setMediaRecorder(recorder); | ||
| setIsRecording(true); | ||
| } catch (error) { | ||
| console.error('Error accessing microphone:', error); | ||
| addSystemMessage('Failed to access microphone: ' + error.message, 'error'); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Add error handling for unsupported browsers.
The audio recording functionality should check for browser support before attempting to access the microphone.
const startRecording = async () => {
try {
+ if (!navigator.mediaDevices?.getUserMedia) {
+ throw new Error('Audio recording is not supported in this browser');
+ }
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const startRecording = async () => { | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| const recorder = new MediaRecorder(stream); | |
| const chunks = []; | |
| recorder.ondataavailable = (e) => chunks.push(e.data); | |
| recorder.onstop = async () => { | |
| const blob = new Blob(chunks, { type: 'audio/webm' }); | |
| const formData = new FormData(); | |
| formData.append('audio', blob); | |
| try { | |
| setIsLoading(true); | |
| const response = await fetch('/api/transcribe', { | |
| method: 'POST', | |
| body: formData, | |
| }); | |
| if (!response.ok) throw new Error('Failed to transcribe audio'); | |
| const data = await response.json(); | |
| if (data.error) throw new Error(data.error); | |
| // Instead of setting input, directly send the transcribed message | |
| handleSubmitMessage(data.text); | |
| } catch (error) { | |
| console.error('Transcription error:', error); | |
| addSystemMessage('Failed to transcribe audio: ' + error.message, 'error'); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| // Clean up the stream | |
| stream.getTracks().forEach(track => track.stop()); | |
| }; | |
| recorder.start(); | |
| setMediaRecorder(recorder); | |
| setIsRecording(true); | |
| } catch (error) { | |
| console.error('Error accessing microphone:', error); | |
| addSystemMessage('Failed to access microphone: ' + error.message, 'error'); | |
| } | |
| }; | |
| const startRecording = async () => { | |
| try { | |
| if (!navigator.mediaDevices?.getUserMedia) { | |
| throw new Error('Audio recording is not supported in this browser'); | |
| } | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| const recorder = new MediaRecorder(stream); | |
| const chunks = []; | |
| recorder.ondataavailable = (e) => chunks.push(e.data); | |
| recorder.onstop = async () => { | |
| const blob = new Blob(chunks, { type: 'audio/webm' }); | |
| const formData = new FormData(); | |
| formData.append('audio', blob); | |
| try { | |
| setIsLoading(true); | |
| const response = await fetch('/api/transcribe', { | |
| method: 'POST', | |
| body: formData, | |
| }); | |
| if (!response.ok) throw new Error('Failed to transcribe audio'); | |
| const data = await response.json(); | |
| if (data.error) throw new Error(data.error); | |
| // Instead of setting input, directly send the transcribed message | |
| handleSubmitMessage(data.text); | |
| } catch (error) { | |
| console.error('Transcription error:', error); | |
| addSystemMessage('Failed to transcribe audio: ' + error.message, 'error'); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| // Clean up the stream | |
| stream.getTracks().forEach(track => track.stop()); | |
| }; | |
| recorder.start(); | |
| setMediaRecorder(recorder); | |
| setIsRecording(true); | |
| } catch (error) { | |
| console.error('Error accessing microphone:', error); | |
| addSystemMessage('Failed to access microphone: ' + error.message, 'error'); | |
| } | |
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 4
🧹 Nitpick comments (6)
apps/registry/app/api/chat/route.js (3)
4-18: Enhance the system prompt with validation rules.The system prompt could be improved by adding validation rules for:
- Required fields in each section
- Valid values for enums (e.g., employment type)
- URL format validation
- Phone number format validation
20-26: Improve error handling with specific messages.The error response could be more informative by:
- Using a more specific status code (e.g., 503 for service unavailable is good)
- Including an error code in the response
- Adding a more descriptive message about the OpenAI API being unavailable
return NextResponse.json( - { message: 'API not available during build' }, + { + error: 'OPENAI_API_UNAVAILABLE', + message: 'OpenAI API is not available. Please check your configuration.', + details: 'Missing API key in environment variables' + }, { status: 503 }, );
35-164: Add rate limiting and timeout handling.Consider implementing:
- Rate limiting to prevent API abuse
- Timeout handling for long-running requests
- Retry logic for failed API calls
const completion = await openai.chat.completions.create({ + maxRetries: 3, + timeout: 30000, // 30 seconds model: 'gpt-4', messages: [apps/registry/app/components/ResumeEditor.js (1)
14-75: Add schema validation for resume data.Consider adding Zod or Yup schema validation to:
- Validate the resume structure against the schema
- Provide type safety and runtime validation
- Handle malformed data gracefully
apps/registry/app/components/AIChatEditor.js (2)
7-25: Consider using a reducer for complex state management.The component manages multiple related states. Consider:
- Using useReducer for better state management
- Implementing proper cleanup for localStorage
- Adding state persistence error handling
+const chatReducer = (state, action) => { + switch (action.type) { + case 'ADD_MESSAGE': + return { ...state, messages: [...state.messages, action.message] }; + case 'SET_LOADING': + return { ...state, isLoading: action.loading }; + default: + return state; + } +};
339-409: Add visual feedback for settings changes.Consider adding:
- Toast notifications for settings updates
- Visual confirmation when settings are saved
- Error handling for failed settings updates
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (6)
apps/registry/app/api/chat/route.js(1 hunks)apps/registry/app/api/transcribe/route.js(1 hunks)apps/registry/app/components/AIChatEditor.js(1 hunks)apps/registry/app/components/ResumeEditor.js(2 hunks)apps/registry/app/hooks/useSpeech.js(1 hunks)apps/registry/scripts/jobs/gpted.js(3 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
- apps/registry/app/hooks/useSpeech.js
- apps/registry/app/api/transcribe/route.js
⏰ Context from checks skipped due to timeout of 90000ms (1)
- GitHub Check: build
🔇 Additional comments (3)
apps/registry/app/components/ResumeEditor.js (1)
123-159: Refine the array merge logic.The
mergeArraysmethod is useful, but it's keyed to certain fields likename,startDate, andendDate. If the user wants to remove or rename those fields, or if the resume schema changes significantly, edge cases could arise. Consider making the matching criteria more explicit or configurable so that future schema changes don't break the function.apps/registry/app/components/AIChatEditor.js (1)
160-217: Add error handling for unsupported browsers.The audio recording functionality should check for browser support before attempting to access the microphone.
apps/registry/scripts/jobs/gpted.js (1)
400-411: Avoid abruptprocess.exit(1)in production.Terminating the Node.js process can disrupt server uptime. Instead, handle errors gracefully, log them, or move this logic to a script exit only if this code is guaranteed to run as a one-off job.
| const response = completion.choices[0].message; | ||
|
|
||
| // If no tool calls, just return the message | ||
| if (!response.tool_calls) { | ||
| return NextResponse.json({ | ||
| message: response.content, | ||
| suggestedChanges: null, | ||
| }); | ||
| } | ||
|
|
||
| // Process tool calls | ||
| for (const toolCall of response.tool_calls) { | ||
| if (toolCall.function.name === 'update_resume') { | ||
| try { | ||
| const { changes, explanation } = JSON.parse( | ||
| toolCall.function.arguments, | ||
| ); | ||
| return NextResponse.json({ | ||
| message: explanation, | ||
| suggestedChanges: changes, | ||
| }); | ||
| } catch (error) { | ||
| console.error('Error parsing tool call arguments:', error); | ||
| throw error; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Fallback response if no relevant tool calls | ||
| return NextResponse.json({ | ||
| message: response.content, | ||
| suggestedChanges: null, | ||
| }); | ||
| } catch (error) { | ||
| console.error('Error:', error); | ||
| return NextResponse.json({ error: error.message }, { status: 500 }); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Implement more granular error handling.
The current error handling could be improved by:
- Distinguishing between different types of errors (parsing, API, validation)
- Providing more specific error messages in the response
- Adding error logging with proper error context
} catch (error) {
- console.error('Error:', error);
- return NextResponse.json({ error: error.message }, { status: 500 });
+ console.error('Error processing chat completion:', {
+ error,
+ messages,
+ currentResume
+ });
+ const status = error.status || 500;
+ const message = error.message || 'Internal server error';
+ return NextResponse.json({
+ error: {
+ type: error.name || 'UnknownError',
+ message,
+ details: process.env.NODE_ENV === 'development' ? error.stack : undefined
+ }
+ }, { status });
}Committable suggestion skipped: line range outside the PR's diff.
| <div className="inline-flex rounded-lg border bg-gray-50/50 p-1 shadow-sm"> | ||
| <Button | ||
| variant={editorMode === 'json' ? 'default' : 'ghost'} | ||
| size="sm" | ||
| onClick={() => setEditorMode('json')} | ||
| className="flex items-center gap-2 min-w-[90px] justify-center transition-colors" | ||
| aria-pressed={editorMode === 'json'} | ||
| > | ||
| <Code className="w-4 h-4" aria-hidden="true" /> | ||
| <span>JSON</span> | ||
| </Button> | ||
| <Button | ||
| variant={editorMode === 'gui' ? 'default' : 'ghost'} | ||
| size="sm" | ||
| onClick={() => setEditorMode('gui')} | ||
| className="flex items-center gap-2 min-w-[90px] justify-center transition-colors" | ||
| aria-pressed={editorMode === 'gui'} | ||
| > | ||
| <Layout className="w-4 h-4" aria-hidden="true" /> | ||
| <span>Form</span> | ||
| </Button> | ||
| <Button | ||
| variant={editorMode === 'ai' ? 'default' : 'ghost'} | ||
| size="sm" | ||
| onClick={() => setEditorMode('ai')} | ||
| className="flex items-center gap-2 min-w-[90px] justify-center transition-colors relative" | ||
| aria-pressed={editorMode === 'ai'} | ||
| > | ||
| <span | ||
| className="relative flex items-center" | ||
| aria-hidden="true" | ||
| > | ||
| <span className="absolute -top-1 -right-1 flex h-2 w-2"> | ||
| <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span> | ||
| <span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500"></span> | ||
| </span> | ||
| ✨ | ||
| </span> | ||
| <span>AI Chat</span> | ||
| </Button> | ||
| </div> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Enhance accessibility for editor mode switching.
Consider adding:
- ARIA labels for better screen reader support
- Keyboard navigation between modes
- Focus management when switching modes
<div className="inline-flex rounded-lg border bg-gray-50/50 p-1 shadow-sm">
+ <div role="tablist" aria-label="Editor modes">
<Button
variant={editorMode === 'json' ? 'default' : 'ghost'}
size="sm"
onClick={() => setEditorMode('json')}
+ role="tab"
+ aria-selected={editorMode === 'json'}
+ aria-controls="json-editor"
className="flex items-center gap-2 min-w-[90px] justify-center transition-colors"📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <div className="inline-flex rounded-lg border bg-gray-50/50 p-1 shadow-sm"> | |
| <Button | |
| variant={editorMode === 'json' ? 'default' : 'ghost'} | |
| size="sm" | |
| onClick={() => setEditorMode('json')} | |
| className="flex items-center gap-2 min-w-[90px] justify-center transition-colors" | |
| aria-pressed={editorMode === 'json'} | |
| > | |
| <Code className="w-4 h-4" aria-hidden="true" /> | |
| <span>JSON</span> | |
| </Button> | |
| <Button | |
| variant={editorMode === 'gui' ? 'default' : 'ghost'} | |
| size="sm" | |
| onClick={() => setEditorMode('gui')} | |
| className="flex items-center gap-2 min-w-[90px] justify-center transition-colors" | |
| aria-pressed={editorMode === 'gui'} | |
| > | |
| <Layout className="w-4 h-4" aria-hidden="true" /> | |
| <span>Form</span> | |
| </Button> | |
| <Button | |
| variant={editorMode === 'ai' ? 'default' : 'ghost'} | |
| size="sm" | |
| onClick={() => setEditorMode('ai')} | |
| className="flex items-center gap-2 min-w-[90px] justify-center transition-colors relative" | |
| aria-pressed={editorMode === 'ai'} | |
| > | |
| <span | |
| className="relative flex items-center" | |
| aria-hidden="true" | |
| > | |
| <span className="absolute -top-1 -right-1 flex h-2 w-2"> | |
| <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span> | |
| <span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500"></span> | |
| </span> | |
| ✨ | |
| </span> | |
| <span>AI Chat</span> | |
| </Button> | |
| </div> | |
| <div className="inline-flex rounded-lg border bg-gray-50/50 p-1 shadow-sm"> | |
| <div role="tablist" aria-label="Editor modes"> | |
| <Button | |
| variant={editorMode === 'json' ? 'default' : 'ghost'} | |
| size="sm" | |
| onClick={() => setEditorMode('json')} | |
| role="tab" | |
| aria-selected={editorMode === 'json'} | |
| aria-controls="json-editor" | |
| className="flex items-center gap-2 min-w-[90px] justify-center transition-colors" | |
| aria-pressed={editorMode === 'json'} | |
| > | |
| <Code className="w-4 h-4" aria-hidden="true" /> | |
| <span>JSON</span> | |
| </Button> | |
| <Button | |
| variant={editorMode === 'gui' ? 'default' : 'ghost'} | |
| size="sm" | |
| onClick={() => setEditorMode('gui')} | |
| className="flex items-center gap-2 min-w-[90px] justify-center transition-colors" | |
| aria-pressed={editorMode === 'gui'} | |
| > | |
| <Layout className="w-4 h-4" aria-hidden="true" /> | |
| <span>Form</span> | |
| </Button> | |
| <Button | |
| variant={editorMode === 'ai' ? 'default' : 'ghost'} | |
| size="sm" | |
| onClick={() => setEditorMode('ai')} | |
| className="flex items-center gap-2 min-w-[90px] justify-center transition-colors relative" | |
| aria-pressed={editorMode === 'ai'} | |
| > | |
| <span | |
| className="relative flex items-center" | |
| aria-hidden="true" | |
| > | |
| <span className="absolute -top-1 -right-1 flex h-2 w-2"> | |
| <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span> | |
| <span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500"></span> | |
| </span> | |
| ✨ | |
| </span> | |
| <span>AI Chat</span> | |
| </Button> | |
| </div> | |
| </div> |
| const chat = await openai.chat.completions.create({ | ||
| model: 'gpt-4o-2024-08-06', | ||
| temperature: 0.8, | ||
| model: 'gpt-4o-mini', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix invalid OpenAI model name.
The model name 'gpt-4o-mini' appears to be invalid. Use a valid model name like 'gpt-4' or 'gpt-3.5-turbo'.
-model: 'gpt-4o-mini',
+model: 'gpt-4',Also applies to: 430-430, 451-451
| console.log({ chat3, content }); | ||
| const { error } = await supabase | ||
| .from('jobs') | ||
| .update({ | ||
| gpt_content: details, | ||
| gpt_content_json_extended: jobJson2, | ||
| gpt_content_full: content, | ||
| }) | ||
| .eq('id', job.id); | ||
| console.log({ error }); | ||
| } catch (e) { | ||
| console.log({ e }); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Improve database operation error handling.
Consider implementing:
- Proper error logging with error context
- Retry mechanism for transient failures
- Transaction rollback on failure
-console.log({ error });
+const maxRetries = 3;
+let retryCount = 0;
+while (retryCount < maxRetries) {
+ try {
+ const { error } = await supabase
+ .from('jobs')
+ .update({
+ gpt_content: details,
+ gpt_content_json_extended: jobJson2,
+ gpt_content_full: content,
+ })
+ .eq('id', job.id);
+ if (!error) break;
+ console.error('Database update failed:', {
+ error,
+ jobId: job.id,
+ retryCount,
+ });
+ retryCount++;
+ await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
+ } catch (e) {
+ console.error('Unexpected error during database update:', {
+ error: e,
+ jobId: job.id,
+ retryCount,
+ });
+ retryCount++;
+ await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
+ }
+}Committable suggestion skipped: line range outside the PR's diff.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 5
♻️ Duplicate comments (3)
apps/registry/app/api/transcribe/route.js (2)
1-12:⚠️ Potential issueAdd missing NextResponse import.
The code uses
NextResponsebut it's not imported.Add this import at the beginning of the file:
+import { NextResponse } from 'next/server';Verify OpenAI API key configuration.
Ensure that the
OPENAI_API_KEYenvironment variable is properly configured in your deployment environment.
18-28: 🛠️ Refactor suggestionConsider adding file size and type validation.
The current implementation doesn't validate the audio file's size or type before processing, which could lead to unnecessary processing of invalid files.
const bytes = await audioFile.arrayBuffer(); const buffer = Buffer.from(bytes); +const maxSize = 25 * 1024 * 1024; // 25MB limit +if (buffer.length > maxSize) { + return Response.json({ error: 'Audio file too large. Maximum size is 25MB.' }, { status: 400 }); +} +if (!audioFile.type.startsWith('audio/')) { + return Response.json({ error: 'Invalid file type. Only audio files are allowed.' }, { status: 400 }); +} const tmpFilePath = join('/tmp', `audio-${Date.now()}.webm`); await writeFile(tmpFilePath, buffer);apps/registry/app/api/chat/route.js (1)
166-203:⚠️ Potential issueImplement more granular error handling.
Building upon the previous review comment, the error handling needs improvement.
// Process tool calls for (const toolCall of response.tool_calls) { if (toolCall.function.name === 'update_resume') { try { const { changes, explanation } = JSON.parse( toolCall.function.arguments ); + // Validate changes against schema + if (!changes || typeof changes !== 'object') { + throw new Error('Invalid changes format'); + } return NextResponse.json({ message: explanation, suggestedChanges: changes, }); } catch (error) { - console.error('Error parsing tool call arguments:', error); - throw error; + console.error('Error processing tool call:', { + error, + toolCall, + function: toolCall.function.name + }); + return NextResponse.json({ + error: { + type: 'ToolCallError', + message: 'Failed to process AI suggestions', + details: process.env.NODE_ENV === 'development' ? error.message : undefined + } + }, { status: 422 }); } } } // Fallback response if no relevant tool calls return NextResponse.json({ message: response.content, suggestedChanges: null, }); } catch (error) { - console.error('Error:', error); - return NextResponse.json({ error: error.message }, { status: 500 }); + console.error('Error processing chat completion:', { + error, + messages, + currentResume + }); + + let status = 500; + let message = 'Internal server error'; + + if (error.name === 'OpenAIError') { + status = error.status || 500; + message = 'AI service error'; + } else if (error instanceof SyntaxError) { + status = 400; + message = 'Invalid request format'; + } + + return NextResponse.json({ + error: { + type: error.name || 'UnknownError', + message, + details: process.env.NODE_ENV === 'development' ? error.stack : undefined + } + }, { status }); }
🧹 Nitpick comments (2)
apps/registry/app/api/transcribe/route.js (1)
35-42: Consider making model and language configurable.The Whisper model version and language are hardcoded. Consider making these configurable through environment variables or request parameters for more flexibility.
+const WHISPER_MODEL = process.env.WHISPER_MODEL || 'whisper-1'; +const DEFAULT_LANGUAGE = process.env.DEFAULT_LANGUAGE || 'en'; + +// Extract language from request if provided +const language = formData.get('language') || DEFAULT_LANGUAGE; + const transcription = await openai.audio.transcriptions.create({ file: fs.createReadStream(tmpFilePath), - model: 'whisper-1', - language: 'en', + model: WHISPER_MODEL, + language: language, response_format: 'text', });apps/registry/app/api/chat/route.js (1)
4-18: Enhance system prompt with validation rules and complex examples.The system prompt could be improved by:
- Including validation rules for specific fields (email format, phone format, URL validation)
- Adding examples for complex nested objects (location, profiles)
- Listing required fields for each section
const systemPrompt = `You are an AI assistant helping to edit a JSON Resume (https://jsonresume.org/schema/). When suggesting changes: 1. Return a JSON object containing ONLY the sections that need to be modified 2. For array sections (work, education, etc.): - To ADD a new item: include it in the array - To UPDATE an existing item: include the full item with all changes - To DELETE an item: include it with a "_delete": true flag and enough identifying information (name, dates) 3. Keep responses concise but friendly 4. Always validate dates are in YYYY-MM-DD format 5. Ensure all required fields are present +6. Validate field formats: + - email: must be a valid email address + - phone: must follow E.164 format + - url: must be a valid URL +7. Required fields by section: + - basics: name, email + - work: name, position, startDate + - education: institution, area, startDate + - skills: name Example responses: Adding: {"work": [{"name": "New Co", "position": "Engineer", "startDate": "2020-01-01"}]} Updating: {"work": [{"name": "Existing Co", "position": "Senior Engineer"}]} -Deleting: {"work": [{"name": "Old Co", "_delete": true}]}`; +Deleting: {"work": [{"name": "Old Co", "_delete": true}]} + +Complex examples: +{"basics": {"location": {"address": "123 Main St", "city": "San Francisco", "region": "CA", "countryCode": "US"}}} +{"basics": {"profiles": [{"network": "LinkedIn", "url": "https://linkedin.com/in/username"}]}}`;
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
apps/registry/app/api/chat/route.js(1 hunks)apps/registry/app/api/transcribe/route.js(1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms (1)
- GitHub Check: Meticulous Tests
| } catch (error) { | ||
| console.error('Error transcribing audio:', error); | ||
| return Response.json( | ||
| { | ||
| error: 'Failed to transcribe audio', | ||
| details: error.message, | ||
| }, | ||
| { status: 500 } | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Enhance error handling and logging.
The current error handling might expose sensitive information in the error details. Consider:
- Sanitizing error messages before sending them to the client
- Adding structured logging with error codes
- Including request correlation IDs for better debugging
+const sanitizeError = (error) => {
+ // Return a safe error message based on error type
+ if (error.code === 'ENOENT') return 'File processing error';
+ if (error.response?.status === 401) return 'Authentication error';
+ return 'Internal server error';
+};
+
catch (error) {
- console.error('Error transcribing audio:', error);
+ const correlationId = Date.now().toString(36);
+ console.error('Error transcribing audio:', {
+ correlationId,
+ error: error.message,
+ stack: error.stack,
+ });
return Response.json(
{
error: 'Failed to transcribe audio',
- details: error.message,
+ message: sanitizeError(error),
+ correlationId,
},
{ status: 500 }
);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| } catch (error) { | |
| console.error('Error transcribing audio:', error); | |
| return Response.json( | |
| { | |
| error: 'Failed to transcribe audio', | |
| details: error.message, | |
| }, | |
| { status: 500 } | |
| ); | |
| } | |
| +const sanitizeError = (error) => { | |
| + // Return a safe error message based on error type | |
| + if (error.code === 'ENOENT') return 'File processing error'; | |
| + if (error.response?.status === 401) return 'Authentication error'; | |
| + return 'Internal server error'; | |
| +}; | |
| } catch (error) { | |
| + const correlationId = Date.now().toString(36); | |
| + console.error('Error transcribing audio:', { | |
| + correlationId, | |
| + error: error.message, | |
| + stack: error.stack, | |
| + }); | |
| return Response.json( | |
| { | |
| error: 'Failed to transcribe audio', | |
| + message: sanitizeError(error), | |
| + correlationId, | |
| - details: error.message, | |
| }, | |
| { status: 500 } | |
| ); | |
| } |
| // Create a temporary file | ||
| const bytes = await audioFile.arrayBuffer(); | ||
| const buffer = Buffer.from(bytes); | ||
| const tmpFilePath = join('/tmp', `audio-${Date.now()}.webm`); | ||
| await writeFile(tmpFilePath, buffer); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Improve temporary file handling.
The current implementation has potential issues:
- The
.webmextension is hardcoded but the input file might be in a different format. - The
/tmpdirectory might not be available in all deployment environments.
-const tmpFilePath = join('/tmp', `audio-${Date.now()}.webm`);
+const extension = audioFile.name.split('.').pop() || 'webm';
+const tmpDir = process.env.TEMP_DIR || '/tmp';
+const tmpFilePath = join(tmpDir, `audio-${Date.now()}.${extension}`);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Create a temporary file | |
| const bytes = await audioFile.arrayBuffer(); | |
| const buffer = Buffer.from(bytes); | |
| const tmpFilePath = join('/tmp', `audio-${Date.now()}.webm`); | |
| await writeFile(tmpFilePath, buffer); | |
| // Create a temporary file | |
| const bytes = await audioFile.arrayBuffer(); | |
| const buffer = Buffer.from(bytes); | |
| const extension = audioFile.name.split('.').pop() || 'webm'; | |
| const tmpDir = process.env.TEMP_DIR || '/tmp'; | |
| const tmpFilePath = join(tmpDir, `audio-${Date.now()}.${extension}`); | |
| await writeFile(tmpFilePath, buffer); |
| // Clean up the temporary file | ||
| fs.unlink(tmpFilePath, (err) => { | ||
| if (err) console.error('Error deleting temporary file:', err); | ||
| }); | ||
|
|
||
| return Response.json({ text: transcription }); | ||
| } finally { | ||
| // Ensure we try to clean up the temp file even if transcription fails | ||
| try { | ||
| fs.unlinkSync(tmpFilePath); | ||
| } catch (e) { | ||
| // Ignore errors during cleanup | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Optimize temporary file cleanup.
The code attempts to delete the temporary file twice, which could lead to race conditions. Consider consolidating the cleanup into a single operation.
-// Clean up the temporary file
-fs.unlink(tmpFilePath, (err) => {
- if (err) console.error('Error deleting temporary file:', err);
-});
-
return Response.json({ text: transcription });
} finally {
// Ensure we try to clean up the temp file even if transcription fails
try {
fs.unlinkSync(tmpFilePath);
} catch (e) {
- // Ignore errors during cleanup
+ console.error('Error deleting temporary file:', e);
}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Clean up the temporary file | |
| fs.unlink(tmpFilePath, (err) => { | |
| if (err) console.error('Error deleting temporary file:', err); | |
| }); | |
| return Response.json({ text: transcription }); | |
| } finally { | |
| // Ensure we try to clean up the temp file even if transcription fails | |
| try { | |
| fs.unlinkSync(tmpFilePath); | |
| } catch (e) { | |
| // Ignore errors during cleanup | |
| } | |
| } | |
| return Response.json({ text: transcription }); | |
| } finally { | |
| // Ensure we try to clean up the temp file even if transcription fails | |
| try { | |
| fs.unlinkSync(tmpFilePath); | |
| } catch (e) { | |
| console.error('Error deleting temporary file:', e); | |
| } | |
| } |
| try { | ||
| const { messages, currentResume } = await req.json(); | ||
|
|
||
| const completion = await openai.chat.completions.create({ | ||
| model: 'gpt-4', | ||
| messages: [ | ||
| { role: 'system', content: systemPrompt }, | ||
| { | ||
| role: 'system', | ||
| content: `Current resume state: ${JSON.stringify( | ||
| currentResume, | ||
| null, | ||
| 2 | ||
| )}`, | ||
| }, | ||
| ...messages.map((msg) => ({ | ||
| role: msg.role, | ||
| content: msg.content, | ||
| })), | ||
| ], | ||
| tools: [ | ||
| { | ||
| type: 'function', | ||
| function: { | ||
| name: 'update_resume', | ||
| description: | ||
| 'Update specific sections of the resume with new information', | ||
| parameters: { | ||
| type: 'object', | ||
| properties: { | ||
| changes: { | ||
| type: 'object', | ||
| description: 'Changes to apply to the resume', | ||
| properties: { | ||
| basics: { | ||
| type: 'object', | ||
| properties: { | ||
| name: { type: 'string' }, | ||
| label: { type: 'string' }, | ||
| email: { type: 'string' }, | ||
| phone: { type: 'string' }, | ||
| url: { type: 'string' }, | ||
| summary: { type: 'string' }, | ||
| location: { | ||
| type: 'object', | ||
| properties: { | ||
| address: { type: 'string' }, | ||
| postalCode: { type: 'string' }, | ||
| city: { type: 'string' }, | ||
| countryCode: { type: 'string' }, | ||
| region: { type: 'string' }, | ||
| }, | ||
| }, | ||
| profiles: { | ||
| type: 'array', | ||
| items: { | ||
| type: 'object', | ||
| properties: { | ||
| network: { type: 'string' }, | ||
| username: { type: 'string' }, | ||
| url: { type: 'string' }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| work: { | ||
| type: 'array', | ||
| items: { | ||
| type: 'object', | ||
| properties: { | ||
| name: { type: 'string' }, | ||
| position: { type: 'string' }, | ||
| url: { type: 'string' }, | ||
| startDate: { type: 'string' }, | ||
| endDate: { type: 'string' }, | ||
| summary: { type: 'string' }, | ||
| highlights: { | ||
| type: 'array', | ||
| items: { type: 'string' }, | ||
| }, | ||
| technologies: { | ||
| type: 'array', | ||
| items: { type: 'string' }, | ||
| }, | ||
| _delete: { type: 'boolean' }, | ||
| }, | ||
| }, | ||
| }, | ||
| education: { | ||
| type: 'array', | ||
| items: { | ||
| type: 'object', | ||
| properties: { | ||
| institution: { type: 'string' }, | ||
| area: { type: 'string' }, | ||
| studyType: { type: 'string' }, | ||
| startDate: { type: 'string' }, | ||
| endDate: { type: 'string' }, | ||
| score: { type: 'string' }, | ||
| _delete: { type: 'boolean' }, | ||
| }, | ||
| }, | ||
| }, | ||
| skills: { | ||
| type: 'array', | ||
| items: { | ||
| type: 'object', | ||
| properties: { | ||
| name: { type: 'string' }, | ||
| level: { type: 'string' }, | ||
| keywords: { | ||
| type: 'array', | ||
| items: { type: 'string' }, | ||
| }, | ||
| _delete: { type: 'boolean' }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| explanation: { | ||
| type: 'string', | ||
| description: | ||
| 'A brief, friendly explanation of the changes being made', | ||
| }, | ||
| }, | ||
| required: ['changes', 'explanation'], | ||
| }, | ||
| }, | ||
| }, | ||
| ], | ||
| }); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add input validation and security measures.
The current implementation needs:
- Request body validation
- Size limits for messages array
- Sanitization of user input before JSON.stringify
try {
+ if (!req.body) {
+ return NextResponse.json(
+ { error: 'Missing request body' },
+ { status: 400 }
+ );
+ }
+
const { messages, currentResume } = await req.json();
+
+ // Validate required fields
+ if (!messages || !Array.isArray(messages) || !currentResume) {
+ return NextResponse.json(
+ { error: 'Invalid request format' },
+ { status: 400 }
+ );
+ }
+
+ // Limit message history
+ if (messages.length > 50) {
+ return NextResponse.json(
+ { error: 'Too many messages' },
+ { status: 400 }
+ );
+ }
+
+ // Validate message format
+ if (!messages.every(msg =>
+ msg &&
+ typeof msg.role === 'string' &&
+ typeof msg.content === 'string' &&
+ msg.content.length < 4000
+ )) {
+ return NextResponse.json(
+ { error: 'Invalid message format' },
+ { status: 400 }
+ );
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| try { | |
| const { messages, currentResume } = await req.json(); | |
| const completion = await openai.chat.completions.create({ | |
| model: 'gpt-4', | |
| messages: [ | |
| { role: 'system', content: systemPrompt }, | |
| { | |
| role: 'system', | |
| content: `Current resume state: ${JSON.stringify( | |
| currentResume, | |
| null, | |
| 2 | |
| )}`, | |
| }, | |
| ...messages.map((msg) => ({ | |
| role: msg.role, | |
| content: msg.content, | |
| })), | |
| ], | |
| tools: [ | |
| { | |
| type: 'function', | |
| function: { | |
| name: 'update_resume', | |
| description: | |
| 'Update specific sections of the resume with new information', | |
| parameters: { | |
| type: 'object', | |
| properties: { | |
| changes: { | |
| type: 'object', | |
| description: 'Changes to apply to the resume', | |
| properties: { | |
| basics: { | |
| type: 'object', | |
| properties: { | |
| name: { type: 'string' }, | |
| label: { type: 'string' }, | |
| email: { type: 'string' }, | |
| phone: { type: 'string' }, | |
| url: { type: 'string' }, | |
| summary: { type: 'string' }, | |
| location: { | |
| type: 'object', | |
| properties: { | |
| address: { type: 'string' }, | |
| postalCode: { type: 'string' }, | |
| city: { type: 'string' }, | |
| countryCode: { type: 'string' }, | |
| region: { type: 'string' }, | |
| }, | |
| }, | |
| profiles: { | |
| type: 'array', | |
| items: { | |
| type: 'object', | |
| properties: { | |
| network: { type: 'string' }, | |
| username: { type: 'string' }, | |
| url: { type: 'string' }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| work: { | |
| type: 'array', | |
| items: { | |
| type: 'object', | |
| properties: { | |
| name: { type: 'string' }, | |
| position: { type: 'string' }, | |
| url: { type: 'string' }, | |
| startDate: { type: 'string' }, | |
| endDate: { type: 'string' }, | |
| summary: { type: 'string' }, | |
| highlights: { | |
| type: 'array', | |
| items: { type: 'string' }, | |
| }, | |
| technologies: { | |
| type: 'array', | |
| items: { type: 'string' }, | |
| }, | |
| _delete: { type: 'boolean' }, | |
| }, | |
| }, | |
| }, | |
| education: { | |
| type: 'array', | |
| items: { | |
| type: 'object', | |
| properties: { | |
| institution: { type: 'string' }, | |
| area: { type: 'string' }, | |
| studyType: { type: 'string' }, | |
| startDate: { type: 'string' }, | |
| endDate: { type: 'string' }, | |
| score: { type: 'string' }, | |
| _delete: { type: 'boolean' }, | |
| }, | |
| }, | |
| }, | |
| skills: { | |
| type: 'array', | |
| items: { | |
| type: 'object', | |
| properties: { | |
| name: { type: 'string' }, | |
| level: { type: 'string' }, | |
| keywords: { | |
| type: 'array', | |
| items: { type: 'string' }, | |
| }, | |
| _delete: { type: 'boolean' }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| explanation: { | |
| type: 'string', | |
| description: | |
| 'A brief, friendly explanation of the changes being made', | |
| }, | |
| }, | |
| required: ['changes', 'explanation'], | |
| }, | |
| }, | |
| }, | |
| ], | |
| }); | |
| try { | |
| if (!req.body) { | |
| return NextResponse.json( | |
| { error: 'Missing request body' }, | |
| { status: 400 } | |
| ); | |
| } | |
| const { messages, currentResume } = await req.json(); | |
| // Validate required fields | |
| if (!messages || !Array.isArray(messages) || !currentResume) { | |
| return NextResponse.json( | |
| { error: 'Invalid request format' }, | |
| { status: 400 } | |
| ); | |
| } | |
| // Limit message history | |
| if (messages.length > 50) { | |
| return NextResponse.json( | |
| { error: 'Too many messages' }, | |
| { status: 400 } | |
| ); | |
| } | |
| // Validate message format | |
| if ( | |
| !messages.every( | |
| (msg) => | |
| msg && | |
| typeof msg.role === 'string' && | |
| typeof msg.content === 'string' && | |
| msg.content.length < 4000 | |
| ) | |
| ) { | |
| return NextResponse.json( | |
| { error: 'Invalid message format' }, | |
| { status: 400 } | |
| ); | |
| } | |
| const completion = await openai.chat.completions.create({ | |
| model: 'gpt-4', | |
| messages: [ | |
| { role: 'system', content: systemPrompt }, | |
| { | |
| role: 'system', | |
| content: `Current resume state: ${JSON.stringify( | |
| currentResume, | |
| null, | |
| 2 | |
| )}`, | |
| }, | |
| ...messages.map((msg) => ({ | |
| role: msg.role, | |
| content: msg.content, | |
| })), | |
| ], | |
| tools: [ | |
| { | |
| type: 'function', | |
| function: { | |
| name: 'update_resume', | |
| description: | |
| 'Update specific sections of the resume with new information', | |
| parameters: { | |
| type: 'object', | |
| properties: { | |
| changes: { | |
| type: 'object', | |
| description: 'Changes to apply to the resume', | |
| properties: { | |
| basics: { | |
| type: 'object', | |
| properties: { | |
| name: { type: 'string' }, | |
| label: { type: 'string' }, | |
| email: { type: 'string' }, | |
| phone: { type: 'string' }, | |
| url: { type: 'string' }, | |
| summary: { type: 'string' }, | |
| location: { | |
| type: 'object', | |
| properties: { | |
| address: { type: 'string' }, | |
| postalCode: { type: 'string' }, | |
| city: { type: 'string' }, | |
| countryCode: { type: 'string' }, | |
| region: { type: 'string' }, | |
| }, | |
| }, | |
| profiles: { | |
| type: 'array', | |
| items: { | |
| type: 'object', | |
| properties: { | |
| network: { type: 'string' }, | |
| username: { type: 'string' }, | |
| url: { type: 'string' }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| work: { | |
| type: 'array', | |
| items: { | |
| type: 'object', | |
| properties: { | |
| name: { type: 'string' }, | |
| position: { type: 'string' }, | |
| url: { type: 'string' }, | |
| startDate: { type: 'string' }, | |
| endDate: { type: 'string' }, | |
| summary: { type: 'string' }, | |
| highlights: { | |
| type: 'array', | |
| items: { type: 'string' }, | |
| }, | |
| technologies: { | |
| type: 'array', | |
| items: { type: 'string' }, | |
| }, | |
| _delete: { type: 'boolean' }, | |
| }, | |
| }, | |
| }, | |
| education: { | |
| type: 'array', | |
| items: { | |
| type: 'object', | |
| properties: { | |
| institution: { type: 'string' }, | |
| area: { type: 'string' }, | |
| studyType: { type: 'string' }, | |
| startDate: { type: 'string' }, | |
| endDate: { type: 'string' }, | |
| score: { type: 'string' }, | |
| _delete: { type: 'boolean' }, | |
| }, | |
| }, | |
| }, | |
| skills: { | |
| type: 'array', | |
| items: { | |
| type: 'object', | |
| properties: { | |
| name: { type: 'string' }, | |
| level: { type: 'string' }, | |
| keywords: { | |
| type: 'array', | |
| items: { type: 'string' }, | |
| }, | |
| _delete: { type: 'boolean' }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| explanation: { | |
| type: 'string', | |
| description: | |
| 'A brief, friendly explanation of the changes being made', | |
| }, | |
| }, | |
| required: ['changes', 'explanation'], | |
| }, | |
| }, | |
| }, | |
| ], | |
| }); |
| export async function POST(req) { | ||
| if (!process.env.OPENAI_API_KEY) { | ||
| return NextResponse.json( | ||
| { message: 'API not available during build' }, | ||
| { status: 503 } | ||
| ); | ||
| } | ||
|
|
||
| const openai = new OpenAI({ | ||
| apiKey: process.env.OPENAI_API_KEY, | ||
| }); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Add rate limiting and improve API key handling.
Consider implementing:
- Rate limiting to prevent abuse
- API key validation before initialization
- Timeout configuration for OpenAI client
+import rateLimit from 'express-rate-limit';
+
+const limiter = rateLimit({
+ windowMs: 15 * 60 * 1000, // 15 minutes
+ max: 100 // limit each IP to 100 requests per windowMs
+});
export async function POST(req) {
if (!process.env.OPENAI_API_KEY) {
return NextResponse.json(
{ message: 'API not available during build' },
{ status: 503 }
);
}
+ // Validate API key format
+ if (!process.env.OPENAI_API_KEY.startsWith('sk-')) {
+ console.error('Invalid OpenAI API key format');
+ return NextResponse.json(
+ { error: 'Invalid API configuration' },
+ { status: 500 }
+ );
+ }
+
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
+ timeout: 30000, // 30 second timeout
+ maxRetries: 3
});📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export async function POST(req) { | |
| if (!process.env.OPENAI_API_KEY) { | |
| return NextResponse.json( | |
| { message: 'API not available during build' }, | |
| { status: 503 } | |
| ); | |
| } | |
| const openai = new OpenAI({ | |
| apiKey: process.env.OPENAI_API_KEY, | |
| }); | |
| import rateLimit from 'express-rate-limit'; | |
| const limiter = rateLimit({ | |
| windowMs: 15 * 60 * 1000, // 15 minutes | |
| max: 100 // limit each IP to 100 requests per windowMs | |
| }); | |
| export async function POST(req) { | |
| if (!process.env.OPENAI_API_KEY) { | |
| return NextResponse.json( | |
| { message: 'API not available during build' }, | |
| { status: 503 } | |
| ); | |
| } | |
| // Validate API key format | |
| if (!process.env.OPENAI_API_KEY.startsWith('sk-')) { | |
| console.error('Invalid OpenAI API key format'); | |
| return NextResponse.json( | |
| { error: 'Invalid API configuration' }, | |
| { status: 500 } | |
| ); | |
| } | |
| const openai = new OpenAI({ | |
| apiKey: process.env.OPENAI_API_KEY, | |
| timeout: 30000, // 30 second timeout | |
| maxRetries: 3 | |
| }); | |
| // ... rest of the handler logic | |
| } |
Summary by CodeRabbit
New Features
Chores