|
|
'use client'; |
|
|
|
|
|
import { useState, useRef, useEffect, useCallback } from 'react'; |
|
|
import { Sparkles, Send, Loader2, Trash2, ChevronLeft, Copy, Check, Play } from 'lucide-react'; |
|
|
import ReactMarkdown from 'react-markdown'; |
|
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; |
|
|
import { clsx } from 'clsx'; |
|
|
import type { CodingProblem } from '@/types'; |
|
|
import { postProcessResponse } from '@/lib/utils/response'; |
|
|
import { LoadingStatus } from '../Chat/LoadingStatus'; |
|
|
|
|
|
interface AIHelperProps { |
|
|
problem: CodingProblem | null; |
|
|
userCode: string; |
|
|
isCollapsed: boolean; |
|
|
onToggleCollapse: () => void; |
|
|
onApplyCode?: (code: string) => void; |
|
|
} |
|
|
|
|
|
interface HelperMessage { |
|
|
id: string; |
|
|
role: 'user' | 'assistant'; |
|
|
content: string; |
|
|
timestamp: Date; |
|
|
} |
|
|
|
|
|
|
|
|
const customTheme: { [key: string]: React.CSSProperties } = { |
|
|
'code[class*="language-"]': { |
|
|
color: '#d4d4d8', |
|
|
background: 'none', |
|
|
fontFamily: "'JetBrains Mono', Consolas, Monaco, monospace", |
|
|
fontSize: '0.8rem', |
|
|
textAlign: 'left', |
|
|
whiteSpace: 'pre', |
|
|
wordSpacing: 'normal', |
|
|
wordBreak: 'normal', |
|
|
wordWrap: 'normal', |
|
|
lineHeight: '1.5', |
|
|
tabSize: 4, |
|
|
}, |
|
|
'pre[class*="language-"]': { |
|
|
color: '#d4d4d8', |
|
|
background: '#18181b', |
|
|
fontFamily: "'JetBrains Mono', Consolas, Monaco, monospace", |
|
|
fontSize: '0.8rem', |
|
|
textAlign: 'left', |
|
|
whiteSpace: 'pre', |
|
|
wordSpacing: 'normal', |
|
|
wordBreak: 'normal', |
|
|
wordWrap: 'normal', |
|
|
lineHeight: '1.5', |
|
|
tabSize: 4, |
|
|
padding: '0.75rem', |
|
|
margin: '0', |
|
|
overflow: 'auto', |
|
|
borderRadius: '0.375rem', |
|
|
}, |
|
|
comment: { color: '#71717a' }, |
|
|
prolog: { color: '#71717a' }, |
|
|
doctype: { color: '#71717a' }, |
|
|
punctuation: { color: '#a1a1aa' }, |
|
|
property: { color: '#f0abfc' }, |
|
|
tag: { color: '#f0abfc' }, |
|
|
boolean: { color: '#c4b5fd' }, |
|
|
number: { color: '#c4b5fd' }, |
|
|
constant: { color: '#c4b5fd' }, |
|
|
symbol: { color: '#c4b5fd' }, |
|
|
selector: { color: '#86efac' }, |
|
|
string: { color: '#86efac' }, |
|
|
char: { color: '#86efac' }, |
|
|
builtin: { color: '#86efac' }, |
|
|
operator: { color: '#f0abfc' }, |
|
|
variable: { color: '#d4d4d8' }, |
|
|
function: { color: '#93c5fd' }, |
|
|
'class-name': { color: '#93c5fd' }, |
|
|
keyword: { color: '#c4b5fd' }, |
|
|
regex: { color: '#fcd34d' }, |
|
|
important: { color: '#fcd34d', fontWeight: 'bold' }, |
|
|
}; |
|
|
|
|
|
const HELPER_PROMPT = `You are a helpful coding assistant for quantum computing practice problems using Qiskit. |
|
|
|
|
|
Your role is to: |
|
|
1. Provide hints and guidance without giving away the complete solution |
|
|
2. Explain quantum computing concepts when asked |
|
|
3. Help debug code issues |
|
|
4. Suggest improvements to the user's approach |
|
|
|
|
|
Guidelines: |
|
|
- Be encouraging and educational |
|
|
- Give progressively more detailed hints if the user is stuck |
|
|
- Focus on teaching, not just solving |
|
|
- Reference Qiskit 2.0 best practices |
|
|
- Keep responses concise and focused |
|
|
|
|
|
Current problem context will be provided. Help the user learn while they solve the problem themselves.`; |
|
|
|
|
|
function getSolvePrompt(problemType: 'function_completion' | 'code_generation') { |
|
|
if (problemType === 'function_completion') { |
|
|
return `You are a quantum computing expert using Qiskit. |
|
|
|
|
|
Your task is to provide ONLY the code lines that complete the function body. Do NOT include the function signature/definition - just the implementation lines that go inside the function. |
|
|
|
|
|
Guidelines: |
|
|
- Provide ONLY the implementation code (the lines after the function definition) |
|
|
- Do NOT repeat the function signature like "def function_name(...):" |
|
|
- Include proper indentation for the function body |
|
|
- Use Qiskit 2.0 best practices |
|
|
- Add brief comments for complex steps |
|
|
|
|
|
Example: If the function is: |
|
|
\`\`\`python |
|
|
def create_bell_state(): |
|
|
"""Create a Bell state circuit.""" |
|
|
pass |
|
|
\`\`\` |
|
|
|
|
|
You should respond with ONLY: |
|
|
\`\`\`python |
|
|
qc = QuantumCircuit(2) |
|
|
qc.h(0) |
|
|
qc.cx(0, 1) |
|
|
return qc |
|
|
\`\`\` |
|
|
|
|
|
Format your response with ONLY the implementation code in a Python code block.`; |
|
|
} |
|
|
|
|
|
return `You are a quantum computing expert using Qiskit. |
|
|
|
|
|
Your task is to provide a complete, working solution for the given problem. |
|
|
|
|
|
Guidelines: |
|
|
- Provide a complete, executable Python solution |
|
|
- Include all necessary imports |
|
|
- Use Qiskit 2.0 best practices |
|
|
- Include brief comments explaining key steps |
|
|
- Make sure the solution passes the provided tests |
|
|
|
|
|
Format your response with the complete code in a Python code block.`; |
|
|
} |
|
|
|
|
|
function looksLikeCode(text: string): boolean { |
|
|
const codeIndicators = [ |
|
|
/^from\s+/m, |
|
|
/^import\s+/m, |
|
|
/^def\s+/m, |
|
|
/^class\s+/m, |
|
|
/^\s*return\s+/m, |
|
|
/QuantumCircuit/, |
|
|
/Parameter\(/, |
|
|
/\.\w+\([^)]*\)/m, |
|
|
/^\s{4}/m, |
|
|
/qc\.\w+/, |
|
|
/circuit\.\w+/, |
|
|
]; |
|
|
return codeIndicators.some((p) => p.test(text)); |
|
|
} |
|
|
|
|
|
interface CodeBlockProps { |
|
|
code: string; |
|
|
language: string; |
|
|
onCopy: () => void; |
|
|
onApply?: () => void; |
|
|
copied: boolean; |
|
|
} |
|
|
|
|
|
function CodeBlock({ code, language, onCopy, onApply, copied }: CodeBlockProps) { |
|
|
return ( |
|
|
<div className="relative group my-2"> |
|
|
{/* Action buttons - always visible for better discoverability */} |
|
|
<div className="absolute right-2 top-2 flex items-center gap-1.5 z-10"> |
|
|
<span className="text-[10px] text-zinc-500 bg-zinc-900/80 px-1.5 py-0.5 rounded"> |
|
|
{language || 'python'} |
|
|
</span> |
|
|
|
|
|
<button |
|
|
onClick={onCopy} |
|
|
className="p-1 rounded bg-zinc-800/90 hover:bg-zinc-700 transition-colors" |
|
|
title="Copy code" |
|
|
> |
|
|
{copied ? ( |
|
|
<Check className="w-3 h-3 text-emerald-400" /> |
|
|
) : ( |
|
|
<Copy className="w-3 h-3 text-zinc-400" /> |
|
|
)} |
|
|
</button> |
|
|
|
|
|
{onApply && ( |
|
|
<button |
|
|
onClick={onApply} |
|
|
className="flex items-center gap-1 px-1.5 py-1 rounded bg-teal-700/80 hover:bg-teal-600 text-teal-100 transition-colors text-[10px] font-medium" |
|
|
title="Apply code to editor" |
|
|
> |
|
|
<Play className="w-3 h-3" /> |
|
|
Apply |
|
|
</button> |
|
|
)} |
|
|
</div> |
|
|
|
|
|
<SyntaxHighlighter |
|
|
style={customTheme} |
|
|
language={language || 'python'} |
|
|
PreTag="div" |
|
|
customStyle={{ |
|
|
margin: 0, |
|
|
borderRadius: '0.375rem', |
|
|
background: '#18181b', |
|
|
padding: '0.75rem', |
|
|
paddingTop: '2rem', // Space for buttons |
|
|
fontSize: '0.8rem', |
|
|
border: '1px solid #27272a', |
|
|
lineHeight: '1.5', |
|
|
}} |
|
|
codeTagProps={{ |
|
|
style: { |
|
|
background: 'none', |
|
|
padding: 0, |
|
|
}, |
|
|
}} |
|
|
wrapLongLines={false} |
|
|
> |
|
|
{code} |
|
|
</SyntaxHighlighter> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
|
|
|
export function AIHelper({ |
|
|
problem, |
|
|
userCode, |
|
|
isCollapsed, |
|
|
onToggleCollapse, |
|
|
onApplyCode, |
|
|
}: AIHelperProps) { |
|
|
const [messages, setMessages] = useState<HelperMessage[]>([]); |
|
|
const [input, setInput] = useState(''); |
|
|
const [isLoading, setIsLoading] = useState(false); |
|
|
const [hasStartedStreaming, setHasStartedStreaming] = useState(false); |
|
|
const [copiedId, setCopiedId] = useState<string | null>(null); |
|
|
const abortControllerRef = useRef<AbortController | null>(null); |
|
|
|
|
|
const messagesContainerRef = useRef<HTMLDivElement>(null); |
|
|
|
|
|
const scrollToBottom = useCallback(() => { |
|
|
|
|
|
if (messagesContainerRef.current) { |
|
|
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight; |
|
|
} |
|
|
}, []); |
|
|
|
|
|
useEffect(() => { |
|
|
|
|
|
requestAnimationFrame(() => { |
|
|
scrollToBottom(); |
|
|
}); |
|
|
}, [messages, scrollToBottom]); |
|
|
|
|
|
useEffect(() => { |
|
|
setMessages([]); |
|
|
}, [problem?.id]); |
|
|
|
|
|
|
|
|
const fetchImageBase64 = async (imageUrl: string): Promise<string | null> => { |
|
|
try { |
|
|
const response = await fetch(imageUrl); |
|
|
const blob = await response.blob(); |
|
|
return new Promise((resolve) => { |
|
|
const reader = new FileReader(); |
|
|
reader.onloadend = () => { |
|
|
const base64 = reader.result as string; |
|
|
const base64Data = base64.split(',')[1] || base64; |
|
|
resolve(base64Data); |
|
|
}; |
|
|
reader.onerror = () => resolve(null); |
|
|
reader.readAsDataURL(blob); |
|
|
}); |
|
|
} catch { |
|
|
return null; |
|
|
} |
|
|
}; |
|
|
|
|
|
const handleSendMessage = async (customMessage?: string, isSolveRequest = false) => { |
|
|
const messageText = customMessage || input.trim(); |
|
|
if (!messageText || isLoading || !problem) return; |
|
|
|
|
|
if (abortControllerRef.current) { |
|
|
abortControllerRef.current.abort(); |
|
|
} |
|
|
abortControllerRef.current = new AbortController(); |
|
|
|
|
|
const userMessage: HelperMessage = { |
|
|
id: crypto.randomUUID(), |
|
|
role: 'user', |
|
|
content: messageText, |
|
|
timestamp: new Date(), |
|
|
}; |
|
|
|
|
|
const assistantId = crypto.randomUUID(); |
|
|
const loadingMessage: HelperMessage = { |
|
|
id: assistantId, |
|
|
role: 'assistant', |
|
|
content: '', |
|
|
timestamp: new Date(), |
|
|
}; |
|
|
|
|
|
setMessages((prev) => [...prev, userMessage, loadingMessage]); |
|
|
setInput(''); |
|
|
setIsLoading(true); |
|
|
setHasStartedStreaming(false); |
|
|
|
|
|
try { |
|
|
|
|
|
let contextMessage = `Problem: ${problem.question}`; |
|
|
|
|
|
if (!isSolveRequest && userCode) { |
|
|
contextMessage += `\n\nUser's current code:\n\`\`\`python\n${userCode || '# No code written yet'}\n\`\`\``; |
|
|
} |
|
|
|
|
|
contextMessage += `\n\nUser's request: ${messageText}`; |
|
|
|
|
|
|
|
|
const systemPrompt = isSolveRequest |
|
|
? getSolvePrompt(problem.type as 'function_completion' | 'code_generation') |
|
|
: HELPER_PROMPT; |
|
|
|
|
|
|
|
|
const apiMessages: Array<{ |
|
|
role: 'system' | 'user' | 'assistant'; |
|
|
content: string | Array<{ type: string; text?: string; image_url?: { url: string } }>; |
|
|
}> = [ |
|
|
{ role: 'system', content: systemPrompt }, |
|
|
...messages.map((m) => ({ |
|
|
role: m.role as 'user' | 'assistant', |
|
|
content: m.content, |
|
|
})), |
|
|
]; |
|
|
|
|
|
|
|
|
if (problem.imageUrl && problem.hasImage) { |
|
|
const imageBase64 = await fetchImageBase64(problem.imageUrl); |
|
|
if (imageBase64) { |
|
|
apiMessages.push({ |
|
|
role: 'user', |
|
|
content: [ |
|
|
{ type: 'text', text: contextMessage }, |
|
|
{ |
|
|
type: 'image_url', |
|
|
image_url: { url: `data:image/jpeg;base64,${imageBase64}` }, |
|
|
}, |
|
|
], |
|
|
}); |
|
|
} else { |
|
|
apiMessages.push({ role: 'user', content: contextMessage }); |
|
|
} |
|
|
} else { |
|
|
apiMessages.push({ role: 'user', content: contextMessage }); |
|
|
} |
|
|
|
|
|
const response = await fetch('/api/chat', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ |
|
|
messages: apiMessages, |
|
|
stream: true, |
|
|
}), |
|
|
signal: abortControllerRef.current.signal, |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
const data = await response.json(); |
|
|
throw new Error(data.error || 'Request failed'); |
|
|
} |
|
|
|
|
|
const reader = response.body?.getReader(); |
|
|
if (!reader) throw new Error('No response body'); |
|
|
|
|
|
const decoder = new TextDecoder(); |
|
|
let buffer = ''; |
|
|
let fullContent = ''; |
|
|
|
|
|
while (true) { |
|
|
const { done, value } = await reader.read(); |
|
|
if (done) break; |
|
|
|
|
|
buffer += decoder.decode(value, { stream: true }); |
|
|
const lines = buffer.split('\n'); |
|
|
buffer = lines.pop() || ''; |
|
|
|
|
|
for (const line of lines) { |
|
|
const trimmed = line.trim(); |
|
|
if (!trimmed || !trimmed.startsWith('data: ')) continue; |
|
|
|
|
|
const jsonStr = trimmed.slice(6); |
|
|
try { |
|
|
const data = JSON.parse(jsonStr); |
|
|
if (data.content) { |
|
|
|
|
|
if (fullContent === '') { |
|
|
setHasStartedStreaming(true); |
|
|
} |
|
|
fullContent += data.content; |
|
|
|
|
|
const processedContent = postProcessResponse(fullContent); |
|
|
setMessages((prev) => |
|
|
prev.map((m) => |
|
|
m.id === assistantId ? { ...m, content: processedContent } : m |
|
|
) |
|
|
); |
|
|
} |
|
|
} catch { |
|
|
continue; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const finalContent = postProcessResponse(fullContent); |
|
|
setMessages((prev) => |
|
|
prev.map((m) => |
|
|
m.id === assistantId ? { ...m, content: finalContent } : m |
|
|
) |
|
|
); |
|
|
} catch (error) { |
|
|
if ((error as Error).name === 'AbortError') return; |
|
|
|
|
|
setMessages((prev) => |
|
|
prev.map((m) => |
|
|
m.id === assistantId |
|
|
? { ...m, content: `Error: ${error instanceof Error ? error.message : 'Failed'}` } |
|
|
: m |
|
|
) |
|
|
); |
|
|
} finally { |
|
|
setIsLoading(false); |
|
|
setHasStartedStreaming(false); |
|
|
abortControllerRef.current = null; |
|
|
} |
|
|
}; |
|
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => { |
|
|
if (e.key === 'Enter' && !e.shiftKey) { |
|
|
e.preventDefault(); |
|
|
handleSendMessage(); |
|
|
} |
|
|
}; |
|
|
|
|
|
const clearMessages = () => { |
|
|
if (abortControllerRef.current) { |
|
|
abortControllerRef.current.abort(); |
|
|
} |
|
|
setMessages([]); |
|
|
}; |
|
|
|
|
|
|
|
|
const extractCodeBlocks = (content: string): string[] => { |
|
|
const codeBlockRegex = /```(?:python)?\n?([\s\S]*?)```/g; |
|
|
const blocks: string[] = []; |
|
|
let match; |
|
|
while ((match = codeBlockRegex.exec(content)) !== null) { |
|
|
|
|
|
const code = match[1].replace(/\n+$/, ''); |
|
|
blocks.push(code); |
|
|
} |
|
|
return blocks; |
|
|
}; |
|
|
|
|
|
const handleCopyCode = (code: string, messageId: string) => { |
|
|
navigator.clipboard.writeText(code); |
|
|
setCopiedId(messageId); |
|
|
setTimeout(() => setCopiedId(null), 2000); |
|
|
}; |
|
|
|
|
|
const handleApplyCode = (code: string) => { |
|
|
if (onApplyCode) { |
|
|
onApplyCode(code); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
const processContent = useCallback((content: string): string => { |
|
|
|
|
|
if (content.includes('```')) { |
|
|
return content; |
|
|
} |
|
|
|
|
|
|
|
|
if (looksLikeCode(content)) { |
|
|
return '```python\n' + content + '\n```'; |
|
|
} |
|
|
|
|
|
return content; |
|
|
}, []); |
|
|
|
|
|
|
|
|
if (isCollapsed) { |
|
|
return ( |
|
|
<button |
|
|
onClick={onToggleCollapse} |
|
|
className="h-full w-full flex flex-col items-center justify-center gap-2 bg-zinc-900/95 border-l border-zinc-800/80 hover:bg-zinc-800/50 transition-colors cursor-pointer" |
|
|
title="Expand AI Helper" |
|
|
> |
|
|
<Sparkles className="w-5 h-5 text-teal-500" /> |
|
|
<span className="text-xs text-zinc-500 [writing-mode:vertical-lr] rotate-180 font-medium"> |
|
|
AI Helper |
|
|
</span> |
|
|
</button> |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
return ( |
|
|
<div className="h-full flex flex-col bg-zinc-900/95 border-l border-zinc-800/80"> |
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800/80 flex-shrink-0"> |
|
|
<div className="flex items-center gap-2"> |
|
|
<Sparkles className="w-4 h-4 text-teal-500" /> |
|
|
<h3 className="font-semibold text-zinc-200 text-sm">AI Helper</h3> |
|
|
</div> |
|
|
<div className="flex items-center gap-1"> |
|
|
{messages.length > 0 && ( |
|
|
<button |
|
|
onClick={clearMessages} |
|
|
className="p-1.5 rounded-md hover:bg-zinc-800/50 transition-colors" |
|
|
title="Clear chat" |
|
|
> |
|
|
<Trash2 className="w-3.5 h-3.5 text-zinc-500" /> |
|
|
</button> |
|
|
)} |
|
|
<button |
|
|
onClick={onToggleCollapse} |
|
|
className="p-1.5 rounded-md hover:bg-zinc-800/50 transition-colors" |
|
|
title="Collapse" |
|
|
> |
|
|
<ChevronLeft className="w-4 h-4 text-zinc-500 rotate-180" /> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div ref={messagesContainerRef} className="flex-1 overflow-y-auto p-3 space-y-3 min-h-0"> |
|
|
{messages.length === 0 ? ( |
|
|
<div className="flex flex-col items-center justify-center h-full text-center px-4"> |
|
|
<Sparkles className="w-8 h-8 text-teal-500/50 mb-3" /> |
|
|
<p className="text-sm text-zinc-500 mb-4"> |
|
|
Need help? Ask for hints or get the solution. |
|
|
</p> |
|
|
{problem && ( |
|
|
<div className="space-y-2 w-full"> |
|
|
{[ |
|
|
{ label: 'Give me a hint', isSolve: false }, |
|
|
{ label: 'Explain the concept', isSolve: false }, |
|
|
{ label: 'Solve it', isSolve: true }, |
|
|
].map(({ label, isSolve }) => ( |
|
|
<button |
|
|
key={label} |
|
|
onClick={() => handleSendMessage(label, isSolve)} |
|
|
className={clsx( |
|
|
'w-full text-left px-3 py-2 rounded-md text-xs transition-colors', |
|
|
isSolve |
|
|
? 'bg-teal-900/40 hover:bg-teal-800/50 text-teal-300 hover:text-teal-200 border border-teal-700/30' |
|
|
: 'bg-zinc-800/60 hover:bg-zinc-800 text-zinc-400 hover:text-zinc-200' |
|
|
)} |
|
|
> |
|
|
{label} |
|
|
</button> |
|
|
))} |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
) : ( |
|
|
messages.map((message) => { |
|
|
const processedContent = processContent(message.content); |
|
|
const codeBlocks = extractCodeBlocks(processedContent); |
|
|
const hasCode = codeBlocks.length > 0; |
|
|
|
|
|
return ( |
|
|
<div |
|
|
key={message.id} |
|
|
className={clsx( |
|
|
'flex flex-col', |
|
|
message.role === 'user' ? 'items-end' : 'items-start' |
|
|
)} |
|
|
> |
|
|
<div |
|
|
className={clsx( |
|
|
'max-w-[95%] rounded-lg px-3 py-2 text-sm', |
|
|
message.role === 'user' |
|
|
? 'bg-teal-700/60 text-white' |
|
|
: 'bg-zinc-800/80 text-zinc-300' |
|
|
)} |
|
|
> |
|
|
{message.role === 'assistant' && !message.content ? ( |
|
|
<LoadingStatus |
|
|
isLoading={isLoading} |
|
|
hasStartedStreaming={hasStartedStreaming} |
|
|
/> |
|
|
) : ( |
|
|
<ReactMarkdown |
|
|
components={{ |
|
|
code({ className, children, ...props }) { |
|
|
const match = /language-(\w+)/.exec(className || ''); |
|
|
const code = String(children).replace(/\n$/, ''); |
|
|
const isBlock = match || code.includes('\n') || looksLikeCode(code); |
|
|
|
|
|
if (isBlock) { |
|
|
return ( |
|
|
<CodeBlock |
|
|
code={code} |
|
|
language={match?.[1] || 'python'} |
|
|
onCopy={() => handleCopyCode(code, message.id)} |
|
|
onApply={onApplyCode ? () => handleApplyCode(code) : undefined} |
|
|
copied={copiedId === message.id} |
|
|
/> |
|
|
); |
|
|
} |
|
|
|
|
|
return ( |
|
|
<code className="bg-zinc-700/50 px-1 py-0.5 rounded text-xs" {...props}> |
|
|
{children} |
|
|
</code> |
|
|
); |
|
|
}, |
|
|
pre({ children }) { |
|
|
return <>{children}</>; |
|
|
}, |
|
|
p({ children }) { |
|
|
return <p className="mb-2 last:mb-0">{children}</p>; |
|
|
}, |
|
|
ul({ children }) { |
|
|
return <ul className="list-disc ml-4 mb-2 space-y-1">{children}</ul>; |
|
|
}, |
|
|
ol({ children }) { |
|
|
return <ol className="list-decimal ml-4 mb-2 space-y-1">{children}</ol>; |
|
|
}, |
|
|
li({ children }) { |
|
|
return <li className="text-zinc-300">{children}</li>; |
|
|
}, |
|
|
strong({ children }) { |
|
|
return <strong className="font-semibold text-zinc-200">{children}</strong>; |
|
|
}, |
|
|
}} |
|
|
> |
|
|
{processedContent} |
|
|
</ReactMarkdown> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}) |
|
|
)} |
|
|
</div> |
|
|
|
|
|
{problem && ( |
|
|
<div className="p-3 border-t border-zinc-800/80 flex-shrink-0"> |
|
|
<div className="flex items-end gap-2"> |
|
|
<textarea |
|
|
value={input} |
|
|
onChange={(e) => setInput(e.target.value)} |
|
|
onKeyDown={handleKeyDown} |
|
|
placeholder="Ask for help..." |
|
|
disabled={isLoading} |
|
|
rows={1} |
|
|
className="flex-1 bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-200 placeholder:text-zinc-500 resize-none focus:outline-none focus:ring-1 focus:ring-teal-600/50 min-h-[40px] max-h-[100px]" |
|
|
style={{ height: 'auto' }} |
|
|
onInput={(e) => { |
|
|
const target = e.target as HTMLTextAreaElement; |
|
|
target.style.height = 'auto'; |
|
|
target.style.height = `${Math.min(target.scrollHeight, 100)}px`; |
|
|
}} |
|
|
/> |
|
|
<button |
|
|
onClick={() => handleSendMessage()} |
|
|
disabled={!input.trim() || isLoading} |
|
|
className={clsx( |
|
|
'p-2 rounded-lg transition-all', |
|
|
input.trim() && !isLoading |
|
|
? 'bg-teal-600 hover:bg-teal-500 text-white' |
|
|
: 'bg-zinc-800 text-zinc-500' |
|
|
)} |
|
|
> |
|
|
{isLoading ? ( |
|
|
<Loader2 className="w-4 h-4 animate-spin" /> |
|
|
) : ( |
|
|
<Send className="w-4 h-4" /> |
|
|
)} |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
); |
|
|
} |
|
|
|