Prmbr/src/app/simulator/[id]/page.tsx

535 lines
18 KiB
TypeScript

'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { useAuthUser } from '@/hooks/useAuthUser'
import { useRouter } from 'next/navigation'
import { Header } from '@/components/layout/Header'
import { Footer } from '@/components/layout/Footer'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { LoadingSpinner } from '@/components/ui/loading-spinner'
import { Badge } from '@/components/ui/badge'
import {
ArrowLeft,
Play,
Copy,
RefreshCw,
Clock,
CheckCircle2,
XCircle,
Zap,
Eye,
FileText,
Settings,
BarChart3,
DollarSign
} from 'lucide-react'
import Link from 'next/link'
import { formatDistanceToNow } from 'date-fns'
import { zhCN, enUS } from 'date-fns/locale'
import { useLocale } from 'next-intl'
import { getPromptContent } from '@/lib/simulator-utils'
interface SimulatorRun {
id: string
name: string
status: string
userInput: string
promptContent?: string | null
output?: string
error?: string
createdAt: string
completedAt?: string
temperature?: number
maxTokens?: number
topP?: number
frequencyPenalty?: number
presencePenalty?: number
inputTokens?: number
outputTokens?: number
totalCost?: number
duration?: number
prompt: {
id: string
name: string
content: string
}
model: {
id: string
name: string
provider: string
modelId: string
description?: string
maxTokens?: number
}
}
export default function SimulatorRunPage({ params }: { params: Promise<{ id: string }> }) {
const { user, loading: authLoading } = useAuthUser()
const router = useRouter()
const t = useTranslations('simulator')
const locale = useLocale()
const [run, setRun] = useState<SimulatorRun | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isExecuting, setIsExecuting] = useState(false)
const [streamOutput, setStreamOutput] = useState('')
const [runId, setRunId] = useState<string | null>(null)
const outputRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const getParams = async () => {
const resolvedParams = await params
setRunId(resolvedParams.id)
}
getParams()
}, [params])
const fetchRunCallback = useCallback(async () => {
if (!runId) return
try {
setIsLoading(true)
const response = await fetch(`/api/simulator/${runId}`)
if (response.ok) {
const data = await response.json()
setRun(data)
setStreamOutput(data.output || '')
} else if (response.status === 404) {
router.push('/simulator')
}
} catch (error) {
console.error('Error fetching simulator run:', error)
} finally {
setIsLoading(false)
}
}, [runId, router])
useEffect(() => {
if (!authLoading && user && runId) {
fetchRunCallback()
}
}, [user, authLoading, runId, fetchRunCallback])
const executeRun = async () => {
if (!run || run.status !== 'pending' || !runId) return
setIsExecuting(true)
setStreamOutput('')
try {
const response = await fetch(`/api/simulator/${runId}/execute`, {
method: 'POST',
})
if (!response.ok) {
throw new Error('Failed to start execution')
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('No response body')
}
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += new TextDecoder().decode(value)
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6)
if (data === '[DONE]') {
// Execution completed, refresh data
await fetchRunCallback()
return
}
try {
const parsed = JSON.parse(data)
if (parsed.choices?.[0]?.delta?.content) {
const content = parsed.choices[0].delta.content
setStreamOutput(prev => prev + content)
// Auto scroll to bottom
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight
}
}
} catch {
// Ignore parsing errors
}
}
}
}
} catch (error) {
console.error('Error executing run:', error)
// Refresh data to get error status
await fetchRunCallback()
} finally {
setIsExecuting(false)
}
}
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
// You could add a toast notification here
} catch (error) {
console.error('Error copying to clipboard:', error)
}
}
const getStatusIcon = (status: string) => {
switch (status) {
case 'pending':
return <Clock className="h-5 w-5" />
case 'running':
return <RefreshCw className="h-5 w-5 animate-spin" />
case 'completed':
return <CheckCircle2 className="h-5 w-5" />
case 'failed':
return <XCircle className="h-5 w-5" />
default:
return <Clock className="h-5 w-5" />
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'pending':
return 'bg-yellow-50 text-yellow-700 border-yellow-200 dark:bg-yellow-900/20 dark:text-yellow-400 dark:border-yellow-800'
case 'running':
return 'bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800'
case 'completed':
return 'bg-green-50 text-green-700 border-green-200 dark:bg-green-900/20 dark:text-green-400 dark:border-green-800'
case 'failed':
return 'bg-red-50 text-red-700 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800'
default:
return 'bg-gray-50 text-gray-700 border-gray-200 dark:bg-gray-900/20 dark:text-gray-400 dark:border-gray-800'
}
}
if (authLoading || isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<LoadingSpinner size="lg" />
<p className="mt-4 text-muted-foreground">Loading run details...</p>
</div>
</div>
)
}
if (!user || !run) {
return null
}
const promptContent = getPromptContent(run)
const currentOutput = (isExecuting && run.status === 'pending') ? streamOutput : run.output
return (
<div className="min-h-screen">
<Header />
<div className="container mx-auto px-4 py-8 max-w-6xl">
{/* Header */}
<div className="mb-8">
<div className="flex items-center space-x-4 mb-4">
<Link href="/simulator">
<Button variant="outline" size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
{t('backToList')}
</Button>
</Link>
</div>
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground mb-2">
{run.name}
</h1>
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
<Badge className={`border ${getStatusColor(run.status)}`}>
<div className="flex items-center space-x-1">
{getStatusIcon(run.status)}
<span>{t(`status.${run.status}`)}</span>
</div>
</Badge>
<span>{run.model.provider} {run.model.name}</span>
<span>
{formatDistanceToNow(new Date(run.createdAt), {
addSuffix: true,
locale: locale === 'zh' ? zhCN : enUS
})}
</span>
</div>
</div>
{run.status === 'pending' && (
<Button onClick={executeRun} disabled={isExecuting}>
{isExecuting ? (
<>
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
{t('executing')}
</>
) : (
<>
<Play className="h-4 w-4 mr-2" />
{t('execute')}
</>
)}
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Side - Configuration */}
<div className="space-y-6">
{/* Prompt Content */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold flex items-center">
<FileText className="h-5 w-5 mr-2" />
{t('promptContent')}
</h2>
<Button
variant="outline"
size="sm"
onClick={() => copyToClipboard(promptContent)}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<div className="bg-muted rounded-md p-4 border max-h-64 overflow-y-auto">
<pre className="text-sm text-foreground whitespace-pre-wrap font-mono">
{promptContent}
</pre>
</div>
</Card>
{/* User Input */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">{t('userInput')}</h2>
<Button
variant="outline"
size="sm"
onClick={() => copyToClipboard(run.userInput)}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<div className="bg-muted rounded-md p-4 border">
<div className="text-sm text-foreground whitespace-pre-wrap">
{run.userInput}
</div>
</div>
</Card>
{/* Configuration */}
<Card className="p-6">
<h2 className="text-xl font-semibold mb-4 flex items-center">
<Settings className="h-5 w-5 mr-2" />
{t('configuration')}
</h2>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium text-foreground">
{t('temperature')}:
</span>
<span className="ml-2 text-muted-foreground">
{run.temperature || 0.7}
</span>
</div>
{run.maxTokens && (
<div>
<span className="font-medium text-foreground">
{t('maxTokens')}:
</span>
<span className="ml-2 text-muted-foreground">
{run.maxTokens.toLocaleString()}
</span>
</div>
)}
{run.topP && (
<div>
<span className="font-medium text-foreground">
{t('topP')}:
</span>
<span className="ml-2 text-muted-foreground">
{run.topP}
</span>
</div>
)}
{run.frequencyPenalty !== undefined && (
<div>
<span className="font-medium text-foreground">
{t('frequencyPenalty')}:
</span>
<span className="ml-2 text-muted-foreground">
{run.frequencyPenalty}
</span>
</div>
)}
{run.presencePenalty !== undefined && (
<div>
<span className="font-medium text-foreground">
{t('presencePenalty')}:
</span>
<span className="ml-2 text-muted-foreground">
{run.presencePenalty}
</span>
</div>
)}
</div>
</Card>
{/* Statistics */}
{(run.inputTokens || run.outputTokens || run.totalCost || run.duration) && (
<Card className="p-6">
<h2 className="text-xl font-semibold mb-4 flex items-center">
<BarChart3 className="h-5 w-5 mr-2" />
{t('statistics')}
</h2>
<div className="grid grid-cols-2 gap-4 text-sm">
{run.inputTokens && (
<div>
<span className="font-medium text-foreground">
{t('inputTokens')}:
</span>
<span className="ml-2 text-muted-foreground">
{run.inputTokens.toLocaleString()}
</span>
</div>
)}
{run.outputTokens && (
<div>
<span className="font-medium text-foreground">
{t('outputTokens')}:
</span>
<span className="ml-2 text-muted-foreground">
{run.outputTokens.toLocaleString()}
</span>
</div>
)}
{run.totalCost && (
<div>
<span className="font-medium text-foreground flex items-center">
<DollarSign className="h-3 w-3 mr-1" />
{t('totalCost')}:
</span>
<span className="ml-2 text-muted-foreground">
${run.totalCost.toFixed(4)}
</span>
</div>
)}
{run.duration && (
<div>
<span className="font-medium text-foreground">
{t('duration')}:
</span>
<span className="ml-2 text-muted-foreground">
{(run.duration / 1000).toFixed(2)}s
</span>
</div>
)}
</div>
</Card>
)}
</div>
{/* Right Side - Output */}
<div className="space-y-6">
<Card className="p-6 h-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold flex items-center">
<Zap className="h-5 w-5 mr-2" />
{t('output')}
</h2>
{currentOutput && (
<Button
variant="outline"
size="sm"
onClick={() => copyToClipboard(currentOutput)}
>
<Copy className="h-4 w-4" />
</Button>
)}
</div>
{run.status === 'pending' && !isExecuting ? (
<div className="text-center py-12">
<Clock className="h-16 w-16 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground mb-4">
{t('pendingExecution')}
</p>
<Button onClick={executeRun}>
<Play className="h-4 w-4 mr-2" />
{t('execute')}
</Button>
</div>
) : run.status === 'running' || isExecuting ? (
<div>
<div className="flex items-center space-x-2 mb-4 text-blue-600 dark:text-blue-400">
<RefreshCw className="h-4 w-4 animate-spin" />
<span className="text-sm font-medium">{t('generating')}</span>
</div>
<div
ref={outputRef}
className="bg-muted rounded-md p-4 border min-h-64 max-h-96 overflow-y-auto"
>
<div className="text-sm text-foreground whitespace-pre-wrap font-mono">
{currentOutput}
<span className="inline-block w-2 h-4 bg-blue-600 animate-pulse ml-1" />
</div>
</div>
</div>
) : run.error ? (
<div>
<div className="flex items-center space-x-2 mb-4 text-red-600 dark:text-red-400">
<XCircle className="h-4 w-4" />
<span className="text-sm font-medium">{t('error')}</span>
</div>
<div className="bg-red-50 dark:bg-red-950/30 rounded-md p-4 border border-red-200 dark:border-red-800">
<div className="text-sm text-red-700 dark:text-red-300 whitespace-pre-wrap">
{run.error}
</div>
</div>
</div>
) : currentOutput ? (
<div>
<div className="flex items-center space-x-2 mb-4 text-green-600 dark:text-green-400">
<CheckCircle2 className="h-4 w-4" />
<span className="text-sm font-medium">{t('completed')}</span>
</div>
<div className="bg-muted rounded-md p-4 border min-h-32 max-h-96 overflow-y-auto">
<div className="text-sm text-foreground whitespace-pre-wrap">
{currentOutput}
</div>
</div>
</div>
) : (
<div className="text-center py-12">
<Eye className="h-16 w-16 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">
{t('noOutput')}
</p>
</div>
)}
</Card>
</div>
</div>
</div>
<Footer />
</div>
)
}