535 lines
18 KiB
TypeScript
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>
|
|
)
|
|
} |