From 642a29835c90756ccbc35cb59ce80e32ecf58c51 Mon Sep 17 00:00:00 2001 From: TannedCung Date: Fri, 25 Jul 2025 18:23:52 +0700 Subject: [PATCH 1/5] feat: add interview plugin to side panel --- src/components/side-panel/SidePanel.tsx | 50 ++++++++++++++++++------- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/src/components/side-panel/SidePanel.tsx b/src/components/side-panel/SidePanel.tsx index 9c5094d..a0caf28 100644 --- a/src/components/side-panel/SidePanel.tsx +++ b/src/components/side-panel/SidePanel.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState, useCallback } from "react"; -import { RiSidebarFoldLine, RiSidebarUnfoldLine, RiHistoryLine, RiTerminalLine, RiLogoutBoxLine, RiCalendarLine, RiSettingsLine, RiTranslate, RiMenuLine, RiCloseLine } from "react-icons/ri"; +import { RiSidebarFoldLine, RiSidebarUnfoldLine, RiHistoryLine, RiTerminalLine, RiLogoutBoxLine, RiCalendarLine, RiSettingsLine, RiTranslate, RiMenuLine, RiCloseLine, RiUserSearchLine } from "react-icons/ri"; import { Select, SelectItem, @@ -22,6 +22,7 @@ import Calendar from "./Calendar"; import { UserSettingsDialogFull } from "../settings-dialog/UserSettingsDialogFull"; import { pluginRegistry } from "../../lib/plugin-registry"; import { languageLearningPlugin } from "../../plugins/language-learning"; +import { interviewPlugin } from "../../plugins/interview"; import { PluginDefinition } from "../../types"; const filterOptions = [ @@ -240,10 +241,11 @@ export default function SidePanel() { if (isLoggedIn && currentUser && !pluginsInitialized) { const initPlugins = async () => { try { - // Register the Language Learning plugin + // Register plugins pluginRegistry.register(languageLearningPlugin); + pluginRegistry.register(interviewPlugin); - // Initialize plugin with context + // Initialize plugins with context const sessionPassword = localStorage.getItem('genkit_session_password'); if (sessionPassword) { const sessionData = JSON.parse(sessionPassword); @@ -257,6 +259,15 @@ export default function SidePanel() { currentConversation ); + await pluginRegistry.initializePlugin( + 'interview', + currentUser.id, + sessionData.password, + addMessageToCurrentConversation, + createNewConversation, + currentConversation + ); + setPlugins(pluginRegistry.getAllPlugins()); setPluginsInitialized(true); } @@ -485,17 +496,30 @@ export default function SidePanel() { } /> {/* Plugin Tabs */} - {plugins.map((plugin) => ( - - - {plugin.name} - + {plugins.map((plugin) => { + // Simple icon mapping for plugins + const getPluginIcon = (iconName?: string) => { + switch (iconName) { + case 'RiUserSearchLine': + return ; + case 'RiTranslate': + default: + return ; } - /> - ))} + }; + + return ( + + {getPluginIcon(plugin.icon)} + {plugin.name} + + } + /> + ); + })} {process.env.NODE_ENV === 'development' && ( Date: Fri, 25 Jul 2025 18:24:05 +0700 Subject: [PATCH 2/5] feat: interview plugin --- src/plugins/interview/InterviewApp.tsx | 1114 ++++++++++++++++++++++++ src/plugins/interview/index.ts | 636 ++++++++++++++ 2 files changed, 1750 insertions(+) create mode 100644 src/plugins/interview/InterviewApp.tsx create mode 100644 src/plugins/interview/index.ts diff --git a/src/plugins/interview/InterviewApp.tsx b/src/plugins/interview/InterviewApp.tsx new file mode 100644 index 0000000..d2dab68 --- /dev/null +++ b/src/plugins/interview/InterviewApp.tsx @@ -0,0 +1,1114 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Card, + CardBody, + CardHeader, + Button, + Input, + Textarea, + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow, + Modal, + ModalContent, + ModalHeader, + ModalBody, + ModalFooter, + useDisclosure, + Chip, + Select, + SelectItem, + Tabs, + Tab +} from '@heroui/react'; +import { + RiAddLine, + RiDeleteBin7Line, + RiEditLine, + RiUserSearchLine, + RiQuestionLine, + RiPlayLine, + RiTimeLine, + RiGroupLine, + RiFileListLine, + RiMicLine, + RiUserLine +} from 'react-icons/ri'; +import { PluginContext } from '../../types'; +import CleanConversationMessages from '../../components/side-panel/CleanConversationMessages'; +import { useLiveAPIContext } from '../../contexts/LiveAPIContext'; + +interface InterviewQuestion { + id: string; + question: string; + category: 'technical' | 'behavioral' | 'situational' | 'experience' | 'culture-fit' | 'problem-solving' | 'leadership' | 'communication'; + difficulty: 'entry' | 'mid' | 'senior' | 'executive'; + department: string; + tags: string[]; + followUp: string; + idealAnswer: string; + dateAdded: Date; + usageCount: number; +} + +interface InterviewRound { + id: string; + name: string; + description: string; + duration: number; // in minutes + questionCategories: string[]; + difficulty: 'entry' | 'mid' | 'senior' | 'executive'; + questionCount: number; + interviewerRole: string; + dateCreated: Date; + usageCount: number; + // New fields for interview focus + focusType: 'cv' | 'domain' | 'techniques'; + cvContent?: string; // For CV-based interviews + domains?: string[]; // For domain-based interviews + techniques?: string[]; // For techniques-based interviews +} + +interface InterviewSession { + id: string; + roundId: string; + startTime: Date; + endTime?: Date; + questionsAsked: string[]; + status: 'in-progress' | 'completed' | 'cancelled'; + feedback?: string; +} + +interface InterviewAppProps { + isActive: boolean; + context?: PluginContext; +} + +const CATEGORIES = [ + { key: 'technical', label: 'Technical', color: 'primary' as const }, + { key: 'behavioral', label: 'Behavioral', color: 'secondary' as const }, + { key: 'situational', label: 'Situational', color: 'success' as const }, + { key: 'experience', label: 'Experience', color: 'warning' as const }, + { key: 'culture-fit', label: 'Culture Fit', color: 'danger' as const }, + { key: 'problem-solving', label: 'Problem Solving', color: 'default' as const }, + { key: 'leadership', label: 'Leadership', color: 'secondary' as const }, + { key: 'communication', label: 'Communication', color: 'primary' as const } +]; + +const DIFFICULTY_COLORS = { + entry: 'success', + mid: 'warning', + senior: 'danger', + executive: 'secondary' +} as const; + +const DIFFICULTY_LABELS = { + entry: 'Entry Level', + mid: 'Mid Level', + senior: 'Senior Level', + executive: 'Executive Level' +} as const; + +export const InterviewApp: React.FC = ({ isActive, context }) => { + const [questions, setQuestions] = useState([]); + const [rounds, setRounds] = useState([]); + const [sessions, setSessions] = useState([]); + const [activeTab, setActiveTab] = useState('interview'); + const [searchTerm, setSearchTerm] = useState(''); + const [filterCategory, setFilterCategory] = useState(''); + const [filterDifficulty, setFilterDifficulty] = useState(''); + + // Access to LiveAPI for audio streaming + const { connect, connected } = useLiveAPIContext(); + + // Add/Edit Question Modal + const { isOpen: isAddQuestionOpen, onOpen: onAddQuestionOpen, onClose: onAddQuestionClose } = useDisclosure(); + const [editingQuestion, setEditingQuestion] = useState(null); + const [newQuestion, setNewQuestion] = useState<{ + question: string; + category: string; + difficulty: string; + department: string; + tags: string; + followUp: string; + idealAnswer: string; + }>({ + question: '', + category: 'technical', + difficulty: 'entry', + department: '', + tags: '', + followUp: '', + idealAnswer: '' + }); + + // Add/Edit Round Modal + const { isOpen: isAddRoundOpen, onOpen: onAddRoundOpen, onClose: onAddRoundClose } = useDisclosure(); + const [editingRound, setEditingRound] = useState(null); + const [newRound, setNewRound] = useState<{ + name: string; + description: string; + duration: number; + questionCategories: string[]; + difficulty: string; + questionCount: number; + interviewerRole: string; + focusType: string; + cvContent: string; + domains: string; + techniques: string; + }>({ + name: '', + description: '', + duration: 60, + questionCategories: [], + difficulty: 'entry', + questionCount: 5, + interviewerRole: '', + focusType: 'cv', + cvContent: '', + domains: '', + techniques: '' + }); + + const loadQuestions = useCallback(async () => { + if (!context) return; + + try { + const savedQuestions = await context.storage.get('questions'); + if (savedQuestions) { + setQuestions(savedQuestions.map((q: any) => ({ + ...q, + dateAdded: new Date(q.dateAdded) + }))); + } + } catch (error) { + console.error('Failed to load questions:', error); + } + }, [context]); + + const loadRounds = useCallback(async () => { + if (!context) return; + + try { + const savedRounds = await context.storage.get('interviewRounds'); + if (savedRounds) { + setRounds(savedRounds.map((r: any) => ({ + ...r, + dateCreated: new Date(r.dateCreated) + }))); + } + } catch (error) { + console.error('Failed to load rounds:', error); + } + }, [context]); + + const loadSessions = useCallback(async () => { + if (!context) return; + + try { + const savedSessions = await context.storage.get('interviewSessions'); + if (savedSessions) { + setSessions(savedSessions.map((s: any) => ({ + ...s, + startTime: new Date(s.startTime), + endTime: s.endTime ? new Date(s.endTime) : undefined + }))); + } + } catch (error) { + console.error('Failed to load sessions:', error); + } + }, [context]); + + // Load data from plugin storage + useEffect(() => { + if (context && isActive) { + loadQuestions(); + loadRounds(); + loadSessions(); + } + }, [context, isActive, loadQuestions, loadRounds, loadSessions]); + + const saveQuestions = async (newQuestions: InterviewQuestion[]) => { + if (!context) return; + + try { + await context.storage.set('questions', newQuestions); + setQuestions(newQuestions); + } catch (error) { + console.error('Failed to save questions:', error); + } + }; + + const saveRounds = async (newRounds: InterviewRound[]) => { + if (!context) return; + + try { + await context.storage.set('interviewRounds', newRounds); + setRounds(newRounds); + } catch (error) { + console.error('Failed to save rounds:', error); + } + }; + + const addQuestion = async () => { + if (!context) return; + + const question: InterviewQuestion = { + id: editingQuestion?.id || Date.now().toString(), + question: newQuestion.question, + category: newQuestion.category as any, + difficulty: newQuestion.difficulty as any, + department: newQuestion.department, + tags: newQuestion.tags.split(',').map(tag => tag.trim()).filter(tag => tag), + followUp: newQuestion.followUp, + idealAnswer: newQuestion.idealAnswer, + dateAdded: editingQuestion?.dateAdded || new Date(), + usageCount: editingQuestion?.usageCount || 0 + }; + + const updatedQuestions = editingQuestion + ? questions.map(q => q.id === editingQuestion.id ? question : q) + : [...questions, question]; + + await saveQuestions(updatedQuestions); + + // Reset form + setNewQuestion({ + question: '', + category: 'technical', + difficulty: 'entry', + department: '', + tags: '', + followUp: '', + idealAnswer: '' + }); + setEditingQuestion(null); + onAddQuestionClose(); + }; + + const deleteQuestion = async (questionId: string) => { + if (!context) return; + + const updatedQuestions = questions.filter(q => q.id !== questionId); + await saveQuestions(updatedQuestions); + }; + + const handleEditQuestion = (question: InterviewQuestion) => { + setEditingQuestion(question); + setNewQuestion({ + question: question.question, + category: question.category, + difficulty: question.difficulty, + department: question.department, + tags: question.tags.join(', '), + followUp: question.followUp, + idealAnswer: question.idealAnswer + }); + onAddQuestionOpen(); + }; + + const addRound = async () => { + if (!context) return; + + const round: InterviewRound = { + id: editingRound?.id || Date.now().toString(), + name: newRound.name, + description: newRound.description, + duration: newRound.duration, + questionCategories: newRound.questionCategories, + difficulty: newRound.difficulty as any, + questionCount: newRound.questionCount, + interviewerRole: newRound.interviewerRole, + dateCreated: editingRound?.dateCreated || new Date(), + usageCount: editingRound?.usageCount || 0, + focusType: newRound.focusType as any, + cvContent: newRound.cvContent, + domains: newRound.domains ? newRound.domains.split(',').map(d => d.trim()).filter(d => d) : undefined, + techniques: newRound.techniques ? newRound.techniques.split(',').map(t => t.trim()).filter(t => t) : undefined + }; + + const updatedRounds = editingRound + ? rounds.map(r => r.id === editingRound.id ? round : r) + : [...rounds, round]; + + await saveRounds(updatedRounds); + + // Reset form + setNewRound({ + name: '', + description: '', + duration: 60, + questionCategories: [], + difficulty: 'entry', + questionCount: 5, + interviewerRole: '', + focusType: 'cv', + cvContent: '', + domains: '', + techniques: '' + }); + setEditingRound(null); + onAddRoundClose(); + }; + + const deleteRound = async (roundId: string) => { + if (!context) return; + + const updatedRounds = rounds.filter(r => r.id !== roundId); + await saveRounds(updatedRounds); + }; + + const handleEditRound = (round: InterviewRound) => { + setEditingRound(round); + setNewRound({ + name: round.name, + description: round.description, + duration: round.duration, + questionCategories: round.questionCategories, + difficulty: round.difficulty, + questionCount: round.questionCount, + interviewerRole: round.interviewerRole, + focusType: round.focusType, + cvContent: round.cvContent || '', + domains: round.domains?.join(', ') || '', + techniques: round.techniques?.join(', ') || '' + }); + onAddRoundOpen(); + }; + + const startInterview = async (round: InterviewRound) => { + if (!context) return; + + try { + // Update round usage count + const updatedRound = { + ...round, + usageCount: round.usageCount + 1 + }; + const updatedRounds = rounds.map(r => r.id === round.id ? updatedRound : r); + await saveRounds(updatedRounds); + + // Create interview session + const session: InterviewSession = { + id: Date.now().toString(), + roundId: round.id, + startTime: new Date(), + questionsAsked: [], + status: 'in-progress' + }; + + const updatedSessions = [...sessions, session]; + await context.storage.set('interviewSessions', updatedSessions); + setSessions(updatedSessions); + + // Create system prompt for the interview + let focusSection = ''; + + if (round.focusType === 'cv' && round.cvContent) { + focusSection = ` +Candidate's CV Information: +${round.cvContent} + +IMPORTANT: Base your questions on the candidate's actual experience, skills, and career progression mentioned in their CV. Ask about specific projects, technologies, and accomplishments listed. Probe deeper into their roles and responsibilities.`; + } else if (round.focusType === 'domain' && round.domains && round.domains.length > 0) { + focusSection = ` +Business Domain Focus: ${round.domains.join(', ')} + +IMPORTANT: Focus questions on domain-specific challenges, industry knowledge, business logic, and real-world applications in these domains. Ask about scaling challenges, regulatory requirements, and domain-specific technical decisions.`; + } else if (round.focusType === 'techniques' && round.techniques && round.techniques.length > 0) { + focusSection = ` +Technical Focus Areas: ${round.techniques.join(', ')} + +IMPORTANT: Deep-dive into the candidate's expertise with these specific technologies and techniques. Ask about implementation details, best practices, performance considerations, and real-world usage scenarios.`; + } + + const systemPrompt = `You are conducting a ${round.name} interview round. + +Round Details: +- Duration: ${round.duration} minutes +- Your Role: ${round.interviewerRole} +- Question Categories: ${round.questionCategories.join(', ')} +- Difficulty Level: ${DIFFICULTY_LABELS[round.difficulty]} +- Target Questions: ${round.questionCount} + +Description: ${round.description}${focusSection} + +Interview Instructions: +1. Start by introducing yourself as the ${round.interviewerRole} +2. Explain the format and duration of the interview +3. Ask questions from the specified categories at the appropriate difficulty level +4. Listen carefully to responses and ask relevant follow-up questions +5. Provide constructive feedback using the STAR method (Situation, Task, Action, Result) +6. Maintain a professional but friendly demeanor +7. Help the candidate improve their answers when needed +8. Wrap up the interview within the time limit + +Remember to: +- Ask behavioral questions that can be answered using STAR method +- Probe deeper on technical topics when appropriate +- Assess both technical competency and cultural fit +- Give specific, actionable feedback +- Encourage detailed responses that demonstrate experience + +Begin the interview now by introducing yourself and setting expectations.`; + + // Create a new conversation with the interview system prompt + const conversationTitle = `${round.name} - Mock Interview`; + const conversationDescription = `Mock interview session: ${round.description}`; + + await context.createConversation(systemPrompt, undefined, conversationTitle, conversationDescription); + + // Switch to Interview tab to show the conversation + setActiveTab('interview'); + + // Start audio streaming for voice interview + if (!connected) { + console.log('Starting audio streaming for interview...'); + try { + await connect(); + console.log('Audio streaming started successfully'); + } catch (error) { + console.error('Failed to start audio streaming:', error); + alert('Could not start voice interview. Please check your microphone permissions and internet connection.'); + } + } + } catch (error) { + console.error('Failed to start interview:', error); + alert('Failed to start interview session. Please try again.'); + } + }; + + const filteredQuestions = questions.filter(question => { + const matchesSearch = question.question.toLowerCase().includes(searchTerm.toLowerCase()) || + question.tags.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase())) || + question.department.toLowerCase().includes(searchTerm.toLowerCase()); + const matchesCategory = !filterCategory || question.category === filterCategory; + const matchesDifficulty = !filterDifficulty || question.difficulty === filterDifficulty; + + return matchesSearch && matchesCategory && matchesDifficulty; + }); + + const getStats = () => { + const totalQuestions = questions.length; + const totalRounds = rounds.length; + const recentSessions = sessions.filter(s => + s.startTime.getTime() > Date.now() - 7 * 24 * 60 * 60 * 1000 + ).length; + const completedSessions = sessions.filter(s => s.status === 'completed').length; + + return { totalQuestions, totalRounds, recentSessions, completedSessions }; + }; + + const stats = getStats(); + + if (!isActive) { + return null; + } + + return ( +
+ {/* Header */} +
+
+
+ +

Interview Practice

+
+
+ + {/* Stats */} +
+ +
+
{stats.totalQuestions}
+
Questions
+
+
+ +
+
{stats.totalRounds}
+
Rounds
+
+
+ +
+
{stats.completedSessions}
+
Completed
+
+
+ +
+
{stats.recentSessions}
+
Recent (7d)
+
+
+
+ + setActiveTab(key as string)} + className="w-full" + > + + + + +
+ + {/* Content */} +
+ {activeTab === 'interview' && ( +
+ +
+ )} + + {activeTab === 'rounds' && ( +
+
+

+ + Interview Rounds +

+ +
+ +
+ {rounds.map((round) => ( + + +
+
+

{round.name}

+

{round.description}

+
+
+ + {DIFFICULTY_LABELS[round.difficulty]} + +
+
+
+ +
+
+
+ + {round.duration} minutes +
+
+ + {round.questionCount} questions +
+
+ + {round.interviewerRole} +
+
+ + Used {round.usageCount} times +
+
+ +
+
Question Categories:
+
+ {round.questionCategories.map((category) => { + const categoryInfo = CATEGORIES.find(c => c.key === category); + return ( + + {categoryInfo?.label || category} + + ); + })} +
+
+ + {/* Focus Type Information */} +
+
Interview Focus:
+
+ + {round.focusType === 'cv' ? '📄 CV-based' : + round.focusType === 'domain' ? '🏢 Domain-based' : + '⚡ Techniques-based'} + + + {round.focusType === 'cv' && round.cvContent && ( +
+ CV provided - Questions will be personalized based on candidate's experience +
+ )} + + {round.focusType === 'domain' && round.domains && round.domains.length > 0 && ( +
+ Domains: {round.domains.join(', ')} +
+ )} + + {round.focusType === 'techniques' && round.techniques && round.techniques.length > 0 && ( +
+ Technologies: {round.techniques.join(', ')} +
+ )} +
+
+
+
+ +
+ + + +
+
+
+ ))} +
+
+ )} + + {activeTab === 'questions' && ( +
+
+

+ + Question Bank +

+ +
+ + {/* Filters */} +
+ setSearchTerm(e.target.value)} + className="flex-1 min-w-64" + /> + + +
+ + + + Question + Category + Difficulty + Department + Usage + Actions + + + {filteredQuestions.map((question) => { + const categoryInfo = CATEGORIES.find(c => c.key === question.category); + return ( + + +
+
{question.question}
+ {question.tags.length > 0 && ( +
+ {question.tags.slice(0, 3).map((tag, index) => ( + + {tag} + + ))} + {question.tags.length > 3 && ( + + +{question.tags.length - 3} + + )} +
+ )} +
+
+ + + {categoryInfo?.label || question.category} + + + + + {DIFFICULTY_LABELS[question.difficulty]} + + + +
+ {question.department || 'General'} +
+
+ +
+ {question.usageCount} times +
+
+ +
+ + +
+
+
+ ); + })} +
+
+
+ )} +
+ + {/* Add/Edit Question Modal */} + + + + {editingQuestion ? 'Edit Question' : 'Add New Question'} + + +
+