363 lines
12 KiB
TypeScript
363 lines
12 KiB
TypeScript
'use client'
|
||
|
||
import { motion } from 'framer-motion'
|
||
import { useEffect, useRef, useState } from 'react'
|
||
import { useTerminal } from '@/lib/TerminalContext'
|
||
import ReactMarkdown from 'react-markdown'
|
||
|
||
function Spinner() {
|
||
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||
const [frame, setFrame] = useState(0)
|
||
|
||
useEffect(() => {
|
||
const interval = setInterval(() => {
|
||
setFrame(f => (f + 1) % frames.length)
|
||
}, 80)
|
||
return () => clearInterval(interval)
|
||
}, [frames.length])
|
||
|
||
return <span className="inline-block w-3">{frames[frame]}</span>
|
||
}
|
||
|
||
function useTheme() {
|
||
const [isDark, setIsDark] = useState(true)
|
||
|
||
useEffect(() => {
|
||
const check = () => setIsDark(document.documentElement.classList.contains('dark'))
|
||
check()
|
||
|
||
const observer = new MutationObserver(check)
|
||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||
return () => observer.disconnect()
|
||
}, [])
|
||
|
||
return isDark
|
||
}
|
||
|
||
const commands: Record<string, string> = {
|
||
help: `Commands: help, /clear
|
||
|
||
INTERVIEW-STYLE QUESTIONS:
|
||
• Cert rotation in production—downtime?
|
||
• Why Talos over standard K8s?
|
||
• Multi-region SQS failover—atomic safety?
|
||
• vLLM 60% latency: bottleneck & fix?
|
||
• Exactly-once delivery design?
|
||
• 2hr→20min deploy: root cause?
|
||
• K8s CRD reconciliation at scale?
|
||
|
||
homelab | RBC | AWS | ask anything`,
|
||
about: `Rock Liang - Senior Software Engineer
|
||
6+ years | Infrastructure × Backend × LLM Systems
|
||
AWS → RBC → Building production homelab
|
||
|
||
Ask me anything specific!`,
|
||
}
|
||
|
||
interface HistoryEntry {
|
||
cmd: string
|
||
output: string
|
||
thinking?: string
|
||
isStreaming?: boolean
|
||
}
|
||
|
||
export function InteractiveTerminal() {
|
||
const [input, setInput] = useState('')
|
||
const [history, setHistory] = useState<HistoryEntry[]>([])
|
||
const [isLoading, setIsLoading] = useState(false)
|
||
const { isOpen, setIsOpen } = useTerminal()
|
||
const [isMac, setIsMac] = useState(true)
|
||
const [width, setWidth] = useState(448) // 28rem = 448px
|
||
const [height, setHeight] = useState(512) // 32rem = 512px
|
||
const [isResizing, setIsResizing] = useState(false)
|
||
const terminalRef = useRef<HTMLDivElement>(null)
|
||
const containerRef = useRef<HTMLDivElement>(null)
|
||
const inputRef = useRef<HTMLInputElement>(null)
|
||
const resizeHandleRef = useRef<HTMLDivElement>(null)
|
||
const isDark = useTheme()
|
||
|
||
// Close on click outside
|
||
useEffect(() => {
|
||
const handleClickOutside = (e: MouseEvent) => {
|
||
if (isOpen && containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||
setIsOpen(false)
|
||
}
|
||
}
|
||
document.addEventListener('mousedown', handleClickOutside)
|
||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||
}, [isOpen, setIsOpen])
|
||
|
||
useEffect(() => {
|
||
const isMacOS = /Mac|iPhone|iPad|iPod/.test(navigator.platform)
|
||
setIsMac(isMacOS)
|
||
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||
e.preventDefault()
|
||
setIsOpen(!isOpen)
|
||
}
|
||
}
|
||
|
||
window.addEventListener('keydown', handleKeyDown)
|
||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||
}, [isOpen, setIsOpen])
|
||
|
||
// Auto-scroll on history change
|
||
useEffect(() => {
|
||
terminalRef.current?.scrollTo(0, terminalRef.current.scrollHeight)
|
||
}, [history])
|
||
|
||
// Focus input when opened
|
||
useEffect(() => {
|
||
if (isOpen) inputRef.current?.focus()
|
||
}, [isOpen])
|
||
|
||
// Handle terminal resize (drag top-left, anchored bottom-right)
|
||
useEffect(() => {
|
||
const handleMouseMove = (e: MouseEvent) => {
|
||
if (!isResizing) return
|
||
const container = containerRef.current
|
||
if (!container) return
|
||
|
||
const rect = container.getBoundingClientRect()
|
||
const newWidth = Math.max(320, rect.right - e.clientX)
|
||
const newHeight = Math.max(300, rect.bottom - e.clientY)
|
||
|
||
setWidth(newWidth)
|
||
setHeight(newHeight)
|
||
}
|
||
|
||
const handleMouseUp = () => {
|
||
setIsResizing(false)
|
||
}
|
||
|
||
if (isResizing) {
|
||
document.addEventListener('mousemove', handleMouseMove)
|
||
document.addEventListener('mouseup', handleMouseUp)
|
||
return () => {
|
||
document.removeEventListener('mousemove', handleMouseMove)
|
||
document.removeEventListener('mouseup', handleMouseUp)
|
||
}
|
||
}
|
||
}, [isResizing])
|
||
|
||
const handleCommand = async (cmd: string) => {
|
||
const trimmed = cmd.trim()
|
||
if (!trimmed) return
|
||
|
||
const lowerCmd = trimmed.toLowerCase()
|
||
|
||
if (lowerCmd === 'clear' || lowerCmd === '/clear') {
|
||
setHistory([])
|
||
setInput('')
|
||
return
|
||
}
|
||
|
||
// Check for built-in commands
|
||
if (commands[lowerCmd]) {
|
||
setHistory(prev => [...prev, { cmd: trimmed, output: commands[lowerCmd] }])
|
||
setInput('')
|
||
return
|
||
}
|
||
|
||
// Otherwise, call the LLM API
|
||
setIsLoading(true)
|
||
setInput('')
|
||
|
||
const entryIndex = history.length
|
||
setHistory(prev => [...prev, { cmd: trimmed, output: '', thinking: '', isStreaming: true }])
|
||
|
||
try {
|
||
const response = await fetch('/api/chat', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ message: trimmed }),
|
||
})
|
||
|
||
if (!response.ok) throw new Error('API error')
|
||
|
||
const reader = response.body?.getReader()
|
||
if (!reader) throw new Error('No reader')
|
||
|
||
const decoder = new TextDecoder()
|
||
|
||
// eslint-disable-next-line no-constant-condition
|
||
while (true) {
|
||
const { done, value } = await reader.read()
|
||
if (done) break
|
||
|
||
const chunk = decoder.decode(value, { stream: true })
|
||
const lines = chunk.split('\n')
|
||
|
||
for (const line of lines) {
|
||
if (!line.startsWith('data: ')) continue
|
||
const data = line.slice(6)
|
||
|
||
try {
|
||
const json = JSON.parse(data)
|
||
|
||
setHistory(prev => {
|
||
const updated = [...prev]
|
||
const entry = updated[entryIndex]
|
||
if (!entry) return prev
|
||
|
||
if (json.type === 'reasoning') {
|
||
entry.thinking = json.text
|
||
} else if (json.type === 'content') {
|
||
entry.output = json.text
|
||
} else if (json.type === 'done') {
|
||
entry.isStreaming = false
|
||
}
|
||
|
||
return updated
|
||
})
|
||
} catch {
|
||
// Skip invalid JSON
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
setHistory(prev => {
|
||
const updated = [...prev]
|
||
const entry = updated[entryIndex]
|
||
if (entry) {
|
||
entry.output = 'Error: Failed to get response. Try again.'
|
||
entry.isStreaming = false
|
||
}
|
||
return updated
|
||
})
|
||
} finally {
|
||
setIsLoading(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<motion.div
|
||
className="fixed bottom-6 right-6 z-40"
|
||
initial={{ opacity: 0, scale: 0.9 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
>
|
||
{!isOpen ? (
|
||
<button
|
||
onClick={() => setIsOpen(true)}
|
||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-mono text-sm shadow-lg"
|
||
>
|
||
Ask Poimen ({isMac ? 'Cmd' : 'Ctrl'}+K)
|
||
</button>
|
||
) : (
|
||
<motion.div
|
||
ref={containerRef}
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
style={{ width: `${width}px`, height: `${height}px` }}
|
||
className={`rounded-lg shadow-2xl flex flex-col border relative ${
|
||
isDark
|
||
? 'bg-gray-950 border-gray-700'
|
||
: 'bg-white border-gray-300'
|
||
}`}
|
||
>
|
||
<div className={`border-b px-4 py-3 flex justify-between items-center ${
|
||
isDark
|
||
? 'bg-gray-900 border-gray-700'
|
||
: 'bg-gray-100 border-gray-300'
|
||
}`}>
|
||
<span className={`font-mono text-xs ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
|
||
Poimen (AI Assistant)
|
||
</span>
|
||
<button
|
||
onClick={() => setIsOpen(false)}
|
||
className={isDark ? 'text-gray-400 hover:text-white' : 'text-gray-500 hover:text-gray-900'}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
<div
|
||
ref={terminalRef}
|
||
className={`flex-1 overflow-y-auto px-4 py-3 font-mono text-sm space-y-4 ${
|
||
isDark ? 'text-gray-200' : 'text-gray-800'
|
||
}`}
|
||
>
|
||
{history.length === 0 && (
|
||
<div className={isDark ? 'text-gray-500' : 'text-gray-400'}>
|
||
Ask me about Rock's experience, projects, or skills!
|
||
<br />
|
||
<span className="text-xs">Type 'help' for commands</span>
|
||
</div>
|
||
)}
|
||
{history.map((entry, i) => (
|
||
<div key={i} className="space-y-2">
|
||
<div className={isDark ? 'text-green-400' : 'text-green-600'}>
|
||
<span className={isDark ? 'text-blue-400' : 'text-blue-600'}>→</span> {entry.cmd}
|
||
</div>
|
||
|
||
{entry.isStreaming && !entry.output && (
|
||
<div className={`space-y-2 text-xs ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
|
||
<div className="flex items-start gap-2">
|
||
<Spinner />
|
||
<span className="italic truncate max-w-[300px]">
|
||
{entry.thinking ? entry.thinking.slice(-80) : 'Working...'}
|
||
</span>
|
||
</div>
|
||
<div className={`text-[11px] ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>
|
||
(Reasoning on V100 32GB, slight delay expected)
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{entry.output && (
|
||
<div className={`prose prose-sm max-w-none ${isDark ? 'prose-invert' : ''}`}>
|
||
<ReactMarkdown>{entry.output}</ReactMarkdown>
|
||
{entry.isStreaming && (
|
||
<span className="animate-pulse">▊</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className={`border-t px-4 py-3 ${isDark ? 'border-gray-700' : 'border-gray-300'}`}>
|
||
<div className="flex items-center gap-2">
|
||
<span className={isDark ? 'text-blue-400' : 'text-blue-600'}>→</span>
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && !isLoading) handleCommand(input)
|
||
}}
|
||
placeholder={isLoading ? 'Thinking...' : 'Ask anything...'}
|
||
disabled={isLoading}
|
||
className={`flex-1 bg-transparent text-sm outline-none ${
|
||
isDark ? 'text-gray-200 placeholder-gray-600' : 'text-gray-800 placeholder-gray-400'
|
||
} ${isLoading ? 'opacity-50' : ''}`}
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Resize handle — top-left corner */}
|
||
<div
|
||
ref={resizeHandleRef}
|
||
onMouseDown={() => setIsResizing(true)}
|
||
className={`absolute top-0 left-0 w-4 h-4 cursor-nw-resize ${
|
||
isDark ? 'hover:bg-gray-600' : 'hover:bg-gray-400'
|
||
} transition-colors z-10`}
|
||
style={{
|
||
borderTopLeftRadius: 'inherit',
|
||
opacity: isResizing ? 1 : 0.5,
|
||
}}
|
||
>
|
||
<svg width="10" height="10" viewBox="0 0 10 10" className="m-0.5 opacity-40">
|
||
<line x1="0" y1="10" x2="10" y2="0" stroke="currentColor" strokeWidth="1.5" />
|
||
<line x1="0" y1="6" x2="6" y2="0" stroke="currentColor" strokeWidth="1.5" />
|
||
</svg>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</motion.div>
|
||
)
|
||
}
|