| import { exec, spawn } from 'child_process'; |
| import { promises as fs } from 'fs'; |
| import * as https from 'https'; |
| import * as http from 'http'; |
| import * as crypto from 'crypto'; |
| import * as path from 'path'; |
| import { Plugin } from 'vite'; |
|
|
| |
| interface ExecResult { |
| success: boolean; |
| stdout: string; |
| stderr: string; |
| output: string; |
| error: string; |
| exitCode: number; |
| executionTime: number; |
| durationMs: number; |
| } |
|
|
| interface AIThread { |
| id: string; |
| userId: string; |
| title: string; |
| createdAt: string; |
| updatedAt: string; |
| } |
|
|
| interface AIMessage { |
| id: string; |
| threadId: string; |
| role: 'user' | 'assistant' | 'system'; |
| content: string; |
| createdAt: string; |
| } |
|
|
| |
| const DATA_FILE = path.join(process.cwd(), '.ryp-runner', 'ai-threads.json'); |
|
|
| let threadsStore: Map<string, AIThread> = new Map(); |
| let messagesStore: Map<string, AIMessage[]> = new Map(); |
|
|
| async function loadAIData() { |
| try { |
| await fs.mkdir(path.dirname(DATA_FILE), { recursive: true }); |
| const raw = await fs.readFile(DATA_FILE, 'utf-8'); |
| const data = JSON.parse(raw); |
| threadsStore = new Map(Object.entries(data.threads || {})); |
| messagesStore = new Map(Object.entries(data.messages || {})); |
| } catch { |
| |
| } |
| } |
|
|
| async function saveAIData() { |
| try { |
| await fs.mkdir(path.dirname(DATA_FILE), { recursive: true }); |
| const data = { |
| threads: Object.fromEntries(threadsStore), |
| messages: Object.fromEntries(messagesStore), |
| }; |
| await fs.writeFile(DATA_FILE, JSON.stringify(data, null, 2)); |
| } catch (e) { |
| |
| } |
| } |
|
|
| |
| function decodeJwtUserId(authHeader: string | undefined): string { |
| if (!authHeader) return ''; |
| const token = authHeader.replace(/^Bearer\s+/i, '').trim(); |
| try { |
| const parts = token.split('.'); |
| if (parts.length !== 3) return ''; |
| const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')); |
| return payload.sub || payload.userId || payload.id || ''; |
| } catch { |
| return ''; |
| } |
| } |
|
|
| |
| function groqRequest(apiKey: string, payload: object): Promise<any> { |
| return new Promise((resolve, reject) => { |
| const body = JSON.stringify(payload); |
| const options: https.RequestOptions = { |
| hostname: 'api.groq.com', |
| port: 443, |
| path: '/openai/v1/chat/completions', |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| 'Authorization': `Bearer ${apiKey}`, |
| 'Content-Length': Buffer.byteLength(body), |
| }, |
| }; |
| const req = https.request(options, (res) => { |
| let data = ''; |
| res.on('data', (chunk) => { data += chunk; }); |
| res.on('end', () => { |
| try { resolve({ status: res.statusCode, body: JSON.parse(data) }); } |
| catch { reject(new Error('Invalid JSON from Groq')); } |
| }); |
| }); |
| req.on('error', reject); |
| req.setTimeout(60000, () => { req.destroy(new Error('Groq request timeout')); }); |
| req.write(body); |
| req.end(); |
| }); |
| } |
|
|
| |
| function stripHtmlTags(s: string): string { |
| return s.replace(/<[^>]*>/g, '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").trim(); |
| } |
|
|
| function searchDuckDuckGo(query: string): Promise<string> { |
| return new Promise((resolve) => { |
| const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`; |
| const options: https.RequestOptions = { |
| hostname: 'html.duckduckgo.com', |
| path: `/html/?q=${encodeURIComponent(query)}`, |
| method: 'GET', |
| headers: { |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36', |
| 'Accept': 'text/html', |
| 'Accept-Language': 'en-US,en;q=0.9', |
| }, |
| timeout: 10000, |
| }; |
| const req = https.request(options, (res) => { |
| let html = ''; |
| res.on('data', (c) => { html += c; }); |
| res.on('end', () => { |
| const snippets: string[] = []; |
| const titles: string[] = []; |
| |
| const snipParts = html.split('class="result__snippet"'); |
| for (let i = 1; i < snipParts.length && snippets.length < 4; i++) { |
| const start = snipParts[i].indexOf('>'); |
| if (start === -1) continue; |
| const rest = snipParts[i].slice(start + 1); |
| let end = rest.indexOf('</a>'); |
| if (end === -1) end = rest.indexOf('</span>'); |
| if (end < 0 || end > 600) continue; |
| const snippet = stripHtmlTags(rest.slice(0, end)); |
| if (snippet.length > 30) snippets.push(snippet); |
| } |
| |
| const titleParts = html.split('class="result__title"'); |
| for (let i = 1; i < titleParts.length && titles.length < 4; i++) { |
| const start = titleParts[i].indexOf('>'); |
| if (start === -1) continue; |
| const rest = titleParts[i].slice(start + 1); |
| const end = rest.indexOf('</'); |
| if (end < 0 || end > 200) continue; |
| const t = stripHtmlTags(rest.slice(0, end)); |
| if (t.length > 5) titles.push(t); |
| } |
| if (snippets.length === 0) { |
| resolve(titles.length > 0 ? `Search found: ${titles.join(', ')}` : `No results for: ${query}`); |
| return; |
| } |
| let out = `Web search results for "${query}":\n\n`; |
| snippets.forEach((s, i) => { |
| if (i < titles.length) out += `**${titles[i]}**\n`; |
| out += `${s}\n\n`; |
| }); |
| resolve(out); |
| }); |
| }); |
| req.on('error', () => resolve(`Search failed for: ${query}`)); |
| req.on('timeout', () => { req.destroy(); resolve(`Search timed out for: ${query}`); }); |
| req.end(); |
| }); |
| } |
|
|
| |
| const AI_SYSTEM_PROMPT = `You are RYP AI Teacher — a smart, patient, and encouraging tutor for college students preparing for placements, coding interviews, and exams. |
| |
| ## YOUR IDENTITY |
| You are an expert in: |
| - Data structures & algorithms |
| - Programming (Python, Java, C++, JavaScript, Go, etc.) |
| - Computer science fundamentals (OS, DBMS, Networking, OOP) |
| - Aptitude & reasoning |
| - Resume writing, HR interviews, and placement preparation |
| - System design basics |
| |
| ## HOW TO RESPOND |
| 1. Explain clearly, step by step, with examples. |
| 2. Use Markdown formatting always: |
| - ### Headings for sections |
| - **Bold** for key terms |
| - Numbered lists for steps |
| - Bullet points for features/comparisons |
| - Fenced code blocks with language name for ALL code samples |
| 3. Be encouraging and motivating — like a great college professor. |
| 4. For coding questions: explain the approach, then show code with comments. |
| 5. When grading tests or quizzes, verify the correct option first. Never say an answer is correct unless the user's selected option exactly matches the verified correct option. |
| |
| ## TOOL USAGE — VERY IMPORTANT |
| You have access to a web_search tool. |
| |
| YOU MUST USE web_search when: |
| - The student asks about current job market, placements, companies, or salaries |
| - The student asks for latest documentation, APIs, or framework versions |
| - The student asks about recent events, news, or time-sensitive information |
| - You are unsure if your knowledge is up-to-date |
| - The question involves facts that change over time (prices, statistics, rankings) |
| |
| Do NOT apologize for using tools. Just use them silently and incorporate results into your answer naturally. |
| |
| ## SCOPE |
| You answer questions related to education, study, coding, computer science, aptitude, placements, interviews, and career development. |
| - If the student greets you, greet them warmly and invite a study, coding, placement, or career question. |
| - If the student's text is unclear, random, or incomplete, ask one focused clarifying question instead of guessing. |
| - If the student asks a clearly off-topic question, briefly say you can help with study, coding, CS, aptitude, placements, interviews, and career prep, then ask them to send a related question. |
| - Never repeat an old refusal just because it appears in the chat history. Respond to the latest student message.`; |
|
|
| const AI_TUTOR_GREETING_RESPONSE = "Hi! I'm RYP AI Tutor. Ask me anything about coding, CS subjects, aptitude, placements, interviews, or career prep."; |
| const AI_TUTOR_THANKS_RESPONSE = "You're welcome. Send your next study, coding, placement, or interview question whenever you are ready."; |
| const AI_TUTOR_CLARIFICATION_RESPONSE = "I could not understand that clearly. Please ask a clear study, coding, placement, or career question, and I will help step by step."; |
|
|
| function normalizeAITeacherText(message: string): string { |
| return message |
| .trim() |
| .toLowerCase() |
| .replace(/^[\s.,!?;:"'`()\[\]{}]+|[\s.,!?;:"'`()\[\]{}]+$/g, '') |
| .split(/\s+/) |
| .filter(Boolean) |
| .join(' '); |
| } |
|
|
| function aiTeacherAlphaNumToken(message: string): string { |
| return message.toLowerCase().replace(/[^a-z0-9]/g, ''); |
| } |
|
|
| function isAITeacherGreeting(message: string): boolean { |
| return new Set([ |
| 'hi', |
| 'hii', |
| 'hiii', |
| 'hello', |
| 'hey', |
| 'heyy', |
| 'hey there', |
| 'hello there', |
| 'good morning', |
| 'good afternoon', |
| 'good evening', |
| ]).has(message); |
| } |
|
|
| function isAITeacherThanks(message: string): boolean { |
| return new Set(['thanks', 'thank you', 'thankyou', 'ok', 'okay', 'cool', 'got it']).has(message); |
| } |
|
|
| function countAITeacherVowels(value: string): number { |
| return [...value].filter((ch) => 'aeiou'.includes(ch)).length; |
| } |
|
|
| function isKnownAITeacherSingleWord(token: string): boolean { |
| return new Set([ |
| 'ai', 'api', 'aptitude', 'array', 'aws', 'bfs', 'binary', 'career', 'class', 'cloud', |
| 'code', 'coding', 'css', 'data', 'dbms', 'dfs', 'dsa', 'git', 'golang', 'graph', |
| 'html', 'interview', 'java', 'javascript', 'leetcode', 'linux', 'loops', 'mongodb', |
| 'networking', 'node', 'oop', 'oops', 'os', 'placement', 'python', 'queue', 'react', |
| 'recursion', 'resume', 'roadmap', 'sql', 'stack', 'sorting', 'system', 'tree', |
| ]).has(token); |
| } |
|
|
| function isLikelyUnclearAITeacherInput(message: string): boolean { |
| const fields = message.split(/\s+/).filter(Boolean); |
| const token = aiTeacherAlphaNumToken(message); |
| if (!token) return true; |
|
|
| if (fields.length === 1) { |
| if (isKnownAITeacherSingleWord(token)) return false; |
| if (token.length <= 5) return true; |
| if (countAITeacherVowels(token) === 0 && token.length >= 4) return true; |
| } |
|
|
| return fields.length <= 2 && token.length <= 4; |
| } |
|
|
| function aiTeacherQuickResponse(message: string, hasAttachments: boolean): string | null { |
| if (hasAttachments) return null; |
|
|
| const normalized = normalizeAITeacherText(message); |
| if (!normalized) return null; |
| if (isAITeacherGreeting(normalized)) return AI_TUTOR_GREETING_RESPONSE; |
| if (isAITeacherThanks(normalized)) return AI_TUTOR_THANKS_RESPONSE; |
| if (isLikelyUnclearAITeacherInput(normalized)) return AI_TUTOR_CLARIFICATION_RESPONSE; |
| return null; |
| } |
|
|
| interface AITeacherAnswerSubmission { |
| answers: Map<number, string>; |
| canonical: string; |
| } |
|
|
| interface AITeacherGradeItem { |
| questionNumber: number; |
| selectedOption: string; |
| correctOption: string; |
| correctAnswer: string; |
| explanation: string; |
| } |
|
|
| interface AITeacherGradeResult { |
| items: AITeacherGradeItem[]; |
| } |
|
|
| function parseAITeacherAnswerSubmission(message: string): AITeacherAnswerSubmission | null { |
| const normalized = message.trim().toLowerCase(); |
| if (!normalized) return null; |
|
|
| const answerPattern = /(?:^|[\s,;|])(?:q(?:uestion)?\s*)?([0-9]{1,2})\s*[\.\):\-]?\s*([a-e])(?=\b|$)/gim; |
| const matches = [...normalized.matchAll(answerPattern)]; |
| if (matches.length < 2) return null; |
|
|
| const answers = new Map<number, string>(); |
| for (const match of matches) { |
| const questionNumber = Number(match[1]); |
| if (!Number.isFinite(questionNumber) || questionNumber <= 0 || questionNumber > 100) continue; |
| answers.set(questionNumber, (match[2] || '').toLowerCase()); |
| } |
| if (answers.size < 2) return null; |
|
|
| const leftover = normalized |
| .replace(answerPattern, ' ') |
| .replace(/^[\s,;|/_-]+|[\s,;|/_-]+$/g, '') |
| .trim(); |
| if (leftover && !isAllowedAITeacherAnswerSubmissionPrefix(leftover)) return null; |
|
|
| const canonical = [...answers.entries()] |
| .sort(([a], [b]) => a - b) |
| .map(([questionNumber, answer]) => `${questionNumber}.${answer}`) |
| .join('\n'); |
|
|
| return { answers, canonical }; |
| } |
|
|
| function isAllowedAITeacherAnswerSubmissionPrefix(value: string): boolean { |
| const allowedWords = new Set([ |
| 'answer', 'answers', 'ans', 'my', 'here', 'are', 'is', 'the', 'test', 'quiz', |
| 'submit', 'submitting', 'submitted', 'choice', 'choices', 'option', 'options', |
| ]); |
| return value |
| .split(/\s+/) |
| .map((field) => field.replace(/^[\s.,!?;:"'`()\[\]{}]+|[\s.,!?;:"'`()\[\]{}]+$/g, '')) |
| .filter(Boolean) |
| .every((field) => allowedWords.has(field)); |
| } |
|
|
| function looksLikeAITeacherTest(content: string): boolean { |
| const questionMatches = content.match(/(?:^|\n)\s*(?:#{1,4}\s*)?(?:q(?:uestion)?\s*)?([0-9]{1,2})[\).\:\-]?\s+/gim) || []; |
| const optionMatches = content.match(/(?:^|\n)\s*(?:[-*]\s*)?([a-e])[\).\:\-]\s+\S+/gim) || []; |
| return questionMatches.length >= 2 && optionMatches.length >= 4; |
| } |
|
|
| function findRecentAITeacherTest(messages: AIMessage[]): string | null { |
| for (let index = messages.length - 1; index >= 0; index -= 1) { |
| const message = messages[index]; |
| if (message.role === 'assistant' && looksLikeAITeacherTest(message.content)) { |
| return message.content; |
| } |
| } |
| return null; |
| } |
|
|
| function buildAITeacherUngradableAnswerResponse(): string { |
| return 'I found your answer choices, but I need the test questions in this chat to grade them correctly. Please send the test questions with options, then send answers like `1.a 2.b 3.c`.'; |
| } |
|
|
| function normalizeAITeacherOption(value: string): string { |
| const match = (value || '').trim().toLowerCase().match(/[a-e]/); |
| return match?.[0] || ''; |
| } |
|
|
| function extractAITeacherJSON(value: string): string { |
| let text = (value || '').trim(); |
| if (text.startsWith('```')) { |
| text = text.replace(/^```json\s*/i, '').replace(/^```\s*/i, '').replace(/```$/i, ''); |
| } |
| return text.trim(); |
| } |
|
|
| async function gradeAITeacherAnswerSubmission( |
| groqApiKey: string, |
| testContent: string, |
| submission: AITeacherAnswerSubmission, |
| ): Promise<string> { |
| const questionNumbers = [...submission.answers.keys()].sort((a, b) => a - b); |
| const result = await groqRequest(groqApiKey, { |
| model: 'llama-3.3-70b-versatile', |
| messages: [ |
| { |
| role: 'system', |
| content: `You are a strict quiz answer checker. Return JSON only. |
| Rules: |
| - Use only the provided test questions and options. |
| - Re-solve each question before selecting the correct option. |
| - Do not trust the student's claimed correctness. |
| - selectedOption and correctOption must be lowercase letters a-e. |
| - If a question cannot be verified from the test text, set correctOption to "" and explain what is missing. |
| - Return exactly: {"items":[{"questionNumber":1,"selectedOption":"a","correctOption":"b","correctAnswer":"option text","explanation":"short reason"}]}`, |
| }, |
| { |
| role: 'user', |
| content: `Test questions: |
| ${testContent} |
| |
| Student answers, already normalized to lowercase: |
| ${submission.canonical} |
| |
| Grade only these question numbers: ${questionNumbers.join(', ')}`, |
| }, |
| ], |
| temperature: 0, |
| max_tokens: 1800, |
| response_format: { type: 'json_object' }, |
| }); |
|
|
| if (result.status < 200 || result.status >= 300) { |
| throw new Error(`Groq grading error: ${result.status}`); |
| } |
|
|
| const content = result.body?.choices?.[0]?.message?.content || ''; |
| const parsed = JSON.parse(extractAITeacherJSON(content)) as AITeacherGradeResult; |
| return renderAITeacherGradeFeedback(submission, parsed); |
| } |
|
|
| function renderAITeacherGradeFeedback(submission: AITeacherAnswerSubmission, result: AITeacherGradeResult): string { |
| const itemsByQuestion = new Map<number, AITeacherGradeItem>(); |
| for (const item of result.items || []) { |
| if (!item?.questionNumber) continue; |
| itemsByQuestion.set(item.questionNumber, { |
| ...item, |
| selectedOption: normalizeAITeacherOption(item.selectedOption), |
| correctOption: normalizeAITeacherOption(item.correctOption), |
| }); |
| } |
|
|
| const questionNumbers = [...submission.answers.keys()].sort((a, b) => a - b); |
| let score = 0; |
| let gradable = 0; |
| const lines: string[] = [ |
| '### Test Feedback', |
| '', |
| '| Question | Your answer | Correct answer | Result |', |
| '|---|---:|---:|---|', |
| ]; |
|
|
| for (const questionNumber of questionNumbers) { |
| const selected = normalizeAITeacherOption(submission.answers.get(questionNumber) || ''); |
| const item = itemsByQuestion.get(questionNumber); |
| const correct = normalizeAITeacherOption(item?.correctOption || ''); |
| let resultText = 'Needs review'; |
| if (correct) { |
| gradable += 1; |
| if (selected === correct) { |
| score += 1; |
| resultText = 'Correct'; |
| } else { |
| resultText = 'Incorrect'; |
| } |
| } |
| lines.push(`| ${questionNumber} | ${selected.toUpperCase()} | ${correct.toUpperCase()} | ${resultText} |`); |
| } |
|
|
| lines.push(''); |
| if (gradable > 0) lines.push(`**Score:** ${score}/${gradable}`, ''); |
| lines.push('### Review', ''); |
|
|
| for (const questionNumber of questionNumbers) { |
| const selected = normalizeAITeacherOption(submission.answers.get(questionNumber) || ''); |
| const item = itemsByQuestion.get(questionNumber); |
| const correct = normalizeAITeacherOption(item?.correctOption || ''); |
| if (!item || !correct) { |
| lines.push(`${questionNumber}. **Question ${questionNumber}:** I could not verify this one from the test text. Please resend the question if you want me to check it.`); |
| continue; |
| } |
| const explanation = item.explanation?.trim() || 'Checked by comparing your selected option with the verified correct option.'; |
| if (selected === correct) { |
| lines.push(`${questionNumber}. **Question ${questionNumber}:** Correct. Your answer **${selected.toUpperCase()}** matches the verified answer. ${explanation}`); |
| } else { |
| const answerText = item.correctAnswer?.trim() ? ` (${item.correctAnswer.trim()})` : ''; |
| lines.push(`${questionNumber}. **Question ${questionNumber}:** Incorrect. You answered **${selected.toUpperCase()}**; the correct answer is **${correct.toUpperCase()}**${answerText}. ${explanation}`); |
| } |
| } |
|
|
| return lines.join('\n').trim(); |
| } |
|
|
| const AI_TOOLS = [ |
| { |
| type: 'function', |
| function: { |
| name: 'web_search', |
| description: 'Search the web for up-to-date information. Use this for current events, job market, latest docs, prices, or any time-sensitive query.', |
| parameters: { |
| type: 'object', |
| properties: { |
| query: { type: 'string', description: 'The search query' }, |
| }, |
| required: ['query'], |
| }, |
| }, |
| }, |
| ]; |
|
|
| |
| async function runAITeacher( |
| threadId: string, |
| userMessage: string, |
| attachments: any[], |
| groqApiKey: string |
| ): Promise<string> { |
| |
| const threadMessages = (messagesStore.get(threadId) || []).slice(-30); |
|
|
| const answerSubmission = parseAITeacherAnswerSubmission(userMessage); |
| if (answerSubmission) { |
| const testContent = findRecentAITeacherTest(threadMessages); |
| if (!testContent) return buildAITeacherUngradableAnswerResponse(); |
| try { |
| return await gradeAITeacherAnswerSubmission(groqApiKey, testContent, answerSubmission); |
| } catch (error) { |
| console.error('[AI Teacher] grading error:', error); |
| return buildAITeacherUngradableAnswerResponse(); |
| } |
| } |
|
|
| const quickResponse = aiTeacherQuickResponse(userMessage, attachments?.length > 0); |
| if (quickResponse) return quickResponse; |
|
|
| |
| let userContent = userMessage.trim(); |
| if (attachments?.length > 0) { |
| const attCtx = attachments |
| .filter(a => a.name) |
| .map(a => { |
| let s = `\n- ${a.name}`; |
| if (a.type) s += ` (${a.type})`; |
| if (a.textExcerpt) s += `\n Content:\n${a.textExcerpt.slice(0, 3500)}`; |
| return s; |
| }) |
| .join(''); |
| if (attCtx) userContent += `\n\n[Attached Files]:${attCtx}`; |
| } |
|
|
| const msgs: any[] = [ |
| { role: 'system', content: AI_SYSTEM_PROMPT }, |
| ...threadMessages.map(m => ({ role: m.role, content: m.content })), |
| { role: 'user', content: userContent }, |
| ]; |
|
|
| |
| let finalResponse = ''; |
| for (let iter = 0; iter < 3; iter++) { |
| const toolChoice = iter === 2 ? 'none' : 'auto'; |
| const result = await groqRequest(groqApiKey, { |
| model: 'llama-3.3-70b-versatile', |
| messages: msgs, |
| temperature: 0.65, |
| max_tokens: 2048, |
| tools: AI_TOOLS, |
| tool_choice: toolChoice, |
| }); |
|
|
| if (result.status < 200 || result.status >= 300) { |
| console.error('[AI Teacher] Groq error:', result.status, JSON.stringify(result.body)); |
| throw new Error(`Groq API error: ${result.status}`); |
| } |
|
|
| const choice = result.body?.choices?.[0]?.message; |
| if (!choice) throw new Error('No response from Groq'); |
|
|
| const msgToAppend: any = { role: choice.role }; |
| if (choice.content) msgToAppend.content = choice.content; |
| if (choice.tool_calls?.length > 0) msgToAppend.tool_calls = choice.tool_calls; |
| msgs.push(msgToAppend); |
|
|
| if (choice.tool_calls?.length > 0) { |
| |
| for (const toolCall of choice.tool_calls) { |
| if (toolCall.function?.name === 'web_search') { |
| let args: any = {}; |
| try { args = JSON.parse(toolCall.function.arguments || '{}'); } catch { } |
| const searchResult = await searchDuckDuckGo(args.query || ''); |
| msgs.push({ |
| role: 'tool', |
| tool_call_id: toolCall.id, |
| name: 'web_search', |
| content: searchResult, |
| }); |
| } else { |
| msgs.push({ |
| role: 'tool', |
| tool_call_id: toolCall.id, |
| name: toolCall.function?.name, |
| content: 'Tool not supported.', |
| }); |
| } |
| } |
| |
| } else { |
| finalResponse = choice.content || ''; |
| break; |
| } |
| } |
|
|
| return finalResponse || 'I encountered an error formulating a response. Please try again.'; |
| } |
|
|
| |
| function readBody(req: http.IncomingMessage): Promise<string> { |
| return new Promise((resolve) => { |
| let body = ''; |
| req.on('data', (chunk) => { body += chunk.toString(); }); |
| req.on('end', () => resolve(body)); |
| req.on('error', () => resolve('')); |
| }); |
| } |
|
|
| function jsonOk(res: http.ServerResponse, data: any) { |
| res.setHeader('Content-Type', 'application/json'); |
| res.statusCode = 200; |
| res.end(JSON.stringify(data)); |
| } |
|
|
| function jsonErr(res: http.ServerResponse, status: number, msg: string) { |
| res.setHeader('Content-Type', 'application/json'); |
| res.statusCode = status; |
| res.end(JSON.stringify({ error: msg })); |
| } |
|
|
| |
| export function aiTeacherPlugin(): Plugin { |
| return { |
| name: 'ai-teacher', |
| async configureServer(server) { |
| await loadAIData(); |
| console.log('[AI Teacher] In-process middleware active (bypasses WDAC)'); |
|
|
| const groqKey = process.env.GROQ_API_KEY || process.env.VITE_GROQ_API_KEY || ''; |
|
|
| |
| server.middlewares.use(async (req: http.IncomingMessage, res: http.ServerResponse, next: () => void) => { |
| const url = req.url || ''; |
|
|
| |
| if (!url.startsWith('/api/ai/')) return next(); |
|
|
| const userId = decodeJwtUserId(req.headers['authorization'] as string); |
|
|
| |
| if (url === '/api/ai/threads' && req.method === 'GET') { |
| const userThreads = [...threadsStore.values()] |
| .filter(t => t.userId === userId) |
| .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); |
| return jsonOk(res, { threads: userThreads }); |
| } |
|
|
| |
| if (url === '/api/ai/threads/new' && req.method === 'POST') { |
| const body = await readBody(req); |
| let title = 'New Chat'; |
| try { title = JSON.parse(body).title || title; } catch { } |
| const now = new Date().toISOString(); |
| const thread: AIThread = { |
| id: crypto.randomUUID(), |
| userId, |
| title, |
| createdAt: now, |
| updatedAt: now, |
| }; |
| threadsStore.set(thread.id, thread); |
| await saveAIData(); |
| return jsonOk(res, { thread }); |
| } |
|
|
| |
| if (url.startsWith('/api/ai/threads/messages') && req.method === 'GET') { |
| const threadId = new URL(url, 'http://localhost').searchParams.get('threadId') || ''; |
| const thread = threadsStore.get(threadId); |
| if (!thread || thread.userId !== userId) return jsonErr(res, 403, 'Thread not found'); |
| const msgs = messagesStore.get(threadId) || []; |
| return jsonOk(res, { messages: msgs }); |
| } |
|
|
| |
| if (url.startsWith('/api/ai/threads/delete') && (req.method === 'DELETE' || req.method === 'POST')) { |
| let threadId = new URL(url, 'http://localhost').searchParams.get('threadId') || ''; |
| if (!threadId) { |
| const body = await readBody(req); |
| try { threadId = JSON.parse(body).threadId || ''; } catch { } |
| } |
| const thread = threadsStore.get(threadId); |
| if (!thread || thread.userId !== userId) return jsonErr(res, 403, 'Thread not found'); |
| threadsStore.delete(threadId); |
| messagesStore.delete(threadId); |
| await saveAIData(); |
| return jsonOk(res, { status: 'deleted' }); |
| } |
|
|
| |
| if (url === '/api/ai/teacher' && req.method === 'POST') { |
| if (!groqKey) return jsonErr(res, 500, 'GROQ_API_KEY is not configured in .env'); |
|
|
| const body = await readBody(req); |
| let payload: any = {}; |
| try { payload = JSON.parse(body); } catch { return jsonErr(res, 400, 'Invalid JSON'); } |
|
|
| const { threadId, message, attachments = [] } = payload; |
| if (!threadId || !message?.trim()) return jsonErr(res, 400, 'threadId and message are required'); |
|
|
| const thread = threadsStore.get(threadId); |
| if (!thread || thread.userId !== userId) return jsonErr(res, 403, 'Thread not found'); |
|
|
| const now = new Date().toISOString(); |
|
|
| |
| const userMsg: AIMessage = { |
| id: crypto.randomUUID(), |
| threadId, |
| role: 'user', |
| content: message.trim(), |
| createdAt: now, |
| }; |
| const threadMsgs = messagesStore.get(threadId) || []; |
| threadMsgs.push(userMsg); |
| messagesStore.set(threadId, threadMsgs); |
|
|
| |
| if (threadMsgs.length === 1) { |
| const titleText = message.trim(); |
| thread.title = titleText.length > 30 ? titleText.slice(0, 30) + '...' : titleText; |
| } |
| thread.updatedAt = now; |
| threadsStore.set(threadId, thread); |
|
|
| try { |
| const aiResponse = await runAITeacher(threadId, message, attachments, groqKey); |
| const aiMsg: AIMessage = { |
| id: crypto.randomUUID(), |
| threadId, |
| role: 'assistant', |
| content: aiResponse, |
| createdAt: new Date().toISOString(), |
| }; |
| threadMsgs.push(aiMsg); |
| messagesStore.set(threadId, threadMsgs); |
| await saveAIData(); |
| return jsonOk(res, { |
| thread, |
| userMessage: userMsg, |
| assistantMessage: aiMsg, |
| message: aiMsg |
| }); |
| } catch (err: any) { |
| console.error('[AI Teacher] Error:', err.message); |
| return jsonErr(res, 500, err.message || 'AI Teacher error'); |
| } |
| } |
|
|
| |
| return next(); |
| }); |
| }, |
| }; |
| } |
|
|
| |
| export function localExecutorPlugin(): Plugin { |
| return { |
| name: 'local-executor', |
| configureServer(server) { |
| server.middlewares.use('/api/execute', async (req, res, next) => { |
| if (req.method !== 'POST') { |
| return next(); |
| } |
|
|
| let body = ''; |
| req.on('data', chunk => { |
| body += chunk.toString(); |
| }); |
|
|
| req.on('end', async () => { |
| try { |
| const data = JSON.parse(body); |
| const { language, code, input } = data; |
|
|
| if (!language || !code) { |
| res.statusCode = 400; |
| res.end(JSON.stringify({ error: 'Language and code are required' })); |
| return; |
| } |
|
|
| const result = await executeLocally(language.toLowerCase(), code, input || ''); |
|
|
| res.setHeader('Content-Type', 'application/json'); |
| res.end(JSON.stringify(result)); |
| } catch (err: any) { |
| res.statusCode = 500; |
| res.end(JSON.stringify({ error: err.message || 'Execution failed' })); |
| } |
| }); |
| }); |
| } |
| }; |
| } |
|
|
| async function executeLocally(language: string, code: string, stdin: string): Promise<ExecResult> { |
| |
| if (['c', 'cpp', 'c++', 'go', 'golang'].includes(language)) { |
| const start = Date.now(); |
| try { |
| let compiler = ''; |
| if (language === 'c') compiler = 'gcc-head-c'; |
| else if (language === 'cpp' || language === 'c++') compiler = 'gcc-head'; |
| else if (language === 'go' || language === 'golang') compiler = 'go-head'; |
|
|
| const response = await fetch('https://wandbox.org/api/compile.json', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| compiler: compiler, |
| code: code, |
| stdin: stdin || '' |
| }) |
| }); |
|
|
| if (!response.ok) { |
| throw new Error(`Wandbox API error: ${response.statusText}`); |
| } |
|
|
| const result: any = await response.json(); |
| const elapsed = Date.now() - start; |
|
|
| const stderrStr = result.compiler_error || result.program_error || ''; |
| const stdoutStr = result.program_output || result.compiler_message || ''; |
| const success = result.status === "0"; |
|
|
| return { |
| success, |
| stdout: stdoutStr, |
| stderr: stderrStr, |
| output: stdoutStr, |
| error: stderrStr, |
| exitCode: success ? 0 : 1, |
| executionTime: elapsed, |
| durationMs: elapsed, |
| }; |
| } catch (err: any) { |
| return { |
| success: false, |
| stdout: '', |
| stderr: err.message, |
| output: '', |
| error: `Remote Compilation Error:\n${err.message}`, |
| exitCode: 1, |
| executionTime: Date.now() - start, |
| durationMs: Date.now() - start, |
| }; |
| } |
| } |
|
|
| |
| const workDir = path.join(process.cwd(), '.ryp-runner', `run-${Date.now()}`); |
| await fs.mkdir(workDir, { recursive: true }); |
|
|
| let sourceFile = ''; |
| let cmd = ''; |
| let args: string[] = []; |
|
|
| try { |
| switch (language) { |
| case 'python': |
| case 'python3': |
| case 'py': |
| sourceFile = 'main.py'; |
| cmd = 'python'; |
| args = ['main.py']; |
| break; |
| case 'javascript': |
| case 'js': |
| case 'node': |
| sourceFile = 'main.js'; |
| cmd = 'node'; |
| args = ['main.js']; |
| break; |
| case 'java': |
| sourceFile = 'Main.java'; |
| await fs.writeFile(path.join(workDir, sourceFile), code); |
| await new Promise((resolve, reject) => { |
| exec('javac Main.java', { cwd: workDir }, (error, stdout, stderr) => { |
| if (error) reject(new Error(stderr || stdout || error.message)); |
| resolve(null); |
| }); |
| }); |
| cmd = 'java'; |
| args = ['-cp', '.', 'Main']; |
| break; |
| default: |
| throw new Error(`Unsupported language: ${language}`); |
| } |
|
|
| if (sourceFile && language !== 'java') { |
| await fs.writeFile(path.join(workDir, sourceFile), code); |
| } |
|
|
| const start = Date.now(); |
|
|
| return await new Promise((resolve) => { |
| const child = spawn(cmd, args, { cwd: workDir, shell: process.platform === 'win32' }); |
|
|
| let stdoutStr = ''; |
| let stderrStr = ''; |
| let isDone = false; |
|
|
| const timeout = setTimeout(() => { |
| if (!isDone) { |
| child.kill(); |
| resolve({ |
| success: false, |
| stdout: stdoutStr, |
| stderr: 'Time Limit Exceeded (>10s)', |
| output: stdoutStr, |
| error: 'Time Limit Exceeded (>10s)', |
| exitCode: -1, |
| executionTime: Date.now() - start, |
| durationMs: Date.now() - start, |
| }); |
| } |
| }, 10000); |
|
|
| child.stdout.on('data', (data) => { stdoutStr += data.toString(); }); |
| child.stderr.on('data', (data) => { stderrStr += data.toString(); }); |
|
|
| if (stdin) { |
| child.stdin.write(stdin); |
| child.stdin.end(); |
| } |
|
|
| child.on('close', (code) => { |
| isDone = true; |
| clearTimeout(timeout); |
| const elapsed = Date.now() - start; |
|
|
| if (stderrStr.includes('Application Control policy') || stderrStr.includes('Device Guard')) { |
| stderrStr = 'Execution blocked by Windows Application Control Policy.\n' + stderrStr; |
| } |
|
|
| resolve({ |
| success: code === 0, |
| stdout: stdoutStr, |
| stderr: stderrStr, |
| output: stdoutStr, |
| error: stderrStr, |
| exitCode: code ?? -1, |
| executionTime: elapsed, |
| durationMs: elapsed, |
| }); |
| }); |
|
|
| child.on('error', (err: any) => { |
| isDone = true; |
| clearTimeout(timeout); |
| const elapsed = Date.now() - start; |
| resolve({ |
| success: false, |
| stdout: stdoutStr, |
| stderr: err.message, |
| output: stdoutStr, |
| error: err.message, |
| exitCode: -1, |
| executionTime: elapsed, |
| durationMs: elapsed, |
| }); |
| }); |
| }); |
|
|
| } catch (err: any) { |
| return { |
| success: false, |
| stdout: '', |
| stderr: err.message, |
| output: '', |
| error: `Compilation/Setup Error:\n${err.message}`, |
| exitCode: 1, |
| executionTime: 0, |
| durationMs: 0, |
| }; |
| } finally { |
| setTimeout(() => { |
| fs.rm(workDir, { recursive: true, force: true }).catch(() => { }); |
| }, 1000); |
| } |
| } |
|
|