Prmbr/src/app/simulator/[id]/page.tsx
2025-09-02 00:15:51 +08:00

1197 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { useBetterAuth } from '@/hooks/useBetterAuth'
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 { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import {
ArrowLeft,
Play,
Copy,
RefreshCw,
Clock,
CheckCircle2,
XCircle,
Zap,
Eye,
FileText,
Settings,
BarChart3,
DollarSign,
Edit,
Save,
X,
Share2,
Globe
} 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'
import { DebugPanel } from '@/components/simulator/DebugPanel'
import { Base64Image } from '@/components/ui/Base64Image'
interface SimulatorRun {
id: string
name: string
status: string
userInput: string
promptContent?: string | null
output?: string
error?: string
outputType?: string
permissions: string
visibility?: string | null
createdAt: string
completedAt?: string
temperature?: number
maxTokens?: number
topP?: number
frequencyPenalty?: number
presencePenalty?: number
inputTokens?: number
outputTokens?: number
totalCost?: number
duration?: number
debugRequest?: Record<string, unknown> | null
debugResponse?: Record<string, unknown> | null
generatedFilePath?: string | null
prompt: {
id: string
name: string
content: string
}
model: {
id: string
name: string
provider: string
modelId: string
outputType?: string
description?: string
maxTokens?: number
}
}
interface Model {
id: string
modelId: string
name: string
provider: string
outputType?: string
description?: string
maxTokens?: number
inputCostPer1k?: number
outputCostPer1k?: number
}
export default function SimulatorRunPage({ params }: { params: Promise<{ id: string }> }) {
const { user, loading: authLoading } = useBetterAuth()
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 [isDuplicating, setIsDuplicating] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [models, setModels] = useState<Model[]>([])
const [generatedImageUrl, setGeneratedImageUrl] = useState<string | null>(null)
const [isLoadingImage, setIsLoadingImage] = useState(false)
const [imageLoadError, setImageLoadError] = useState(false)
const [isTogglingShare, setIsTogglingShare] = useState(false)
const [editForm, setEditForm] = useState({
name: '',
userInput: '',
promptContent: '',
modelId: '',
temperature: 0.7,
maxTokens: undefined as number | undefined,
topP: 1,
frequencyPenalty: 0,
presencePenalty: 0,
})
const outputRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const getParams = async () => {
const resolvedParams = await params
setRunId(resolvedParams.id)
}
getParams()
}, [params])
const fetchImageUrl = useCallback(async (runId: string) => {
try {
setIsLoadingImage(true)
const response = await fetch(`/api/simulator/${runId}/image`)
if (response.ok) {
const data = await response.json()
if (data.fileUrl) {
setGeneratedImageUrl(data.fileUrl)
}
}
} catch (error) {
console.error('Error fetching image URL:', error)
} finally {
setIsLoadingImage(false)
}
}, [])
const copyToClipboard = useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text)
} catch (error) {
console.error('Failed to copy to clipboard:', error)
}
}, [])
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)
// 如果是图片类型,优先使用 generatedFilePath 获取临时URL
if (data.model?.outputType === 'image') {
if (data.generatedFilePath) {
// 有文件路径获取临时URL
fetchImageUrl(runId)
} else if (data.output) {
// 从输出中提取图像URL兼容旧数据
const urlMatch = data.output.match(/https?:\/\/[^\s<>"']*\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:\?[^\s<>"']*)?/i)
if (urlMatch) {
setGeneratedImageUrl(urlMatch[0])
}
}
setStreamOutput(data.output || '')
} else {
// 只有非图片类型才设置文本输出
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])
const fetchModels = useCallback(async () => {
try {
const response = await fetch('/api/simulator/models')
if (response.ok) {
const data = await response.json()
setModels(data.models || [])
}
} catch (error) {
console.error('Error fetching models:', error)
}
}, [])
const handleStartEdit = () => {
if (!run || run.status !== 'pending') {
return
}
const promptContent = getPromptContent(run)
setEditForm({
name: run.name,
userInput: run.userInput,
promptContent,
modelId: run.model.id,
temperature: run.temperature || 0.7,
maxTokens: run.maxTokens,
topP: run.topP || 1,
frequencyPenalty: run.frequencyPenalty || 0,
presencePenalty: run.presencePenalty || 0,
})
setIsEditing(true)
// 获取模型列表
fetchModels()
}
const handleCancelEdit = () => {
setIsEditing(false)
setEditForm({
name: '',
userInput: '',
promptContent: '',
modelId: '',
temperature: 0.7,
maxTokens: undefined,
topP: 1,
frequencyPenalty: 0,
presencePenalty: 0,
})
}
const handleSaveEdit = async () => {
if (!run) return
setIsSaving(true)
try {
const response = await fetch(`/api/simulator/${run.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: editForm.name,
userInput: editForm.userInput,
promptContent: editForm.promptContent,
modelId: editForm.modelId,
temperature: editForm.temperature,
maxTokens: editForm.maxTokens,
topP: editForm.topP,
frequencyPenalty: editForm.frequencyPenalty,
presencePenalty: editForm.presencePenalty,
}),
})
if (response.ok) {
const updatedRun = await response.json()
setRun(updatedRun)
setIsEditing(false)
console.log(t('runUpdated'))
} else {
console.error('Error updating run:', await response.text())
}
} catch (error) {
console.error('Error updating run:', error)
} finally {
setIsSaving(false)
}
}
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)
// 对于图像生成模型尝试从内容中提取图像URL
if (run?.model?.outputType === 'image') {
// 匹配各种图像URL格式
const urlMatch = content.match(/https?:\/\/[^\s<>"']*\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:\?[^\s<>"']*)?/i)
if (urlMatch) {
setGeneratedImageUrl(urlMatch[0])
}
}
// 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 handleDuplicateRun = async () => {
if (!run || !confirm(t('duplicateRunConfirm'))) {
return
}
setIsDuplicating(true)
try {
const response = await fetch(`/api/simulator/${run.id}/duplicate`, {
method: 'POST',
})
if (response.ok) {
const newRun = await response.json()
// 跳转到新创建的运行页面
router.push(`/simulator/${newRun.id}`)
} else {
console.error('Error duplicating run:', await response.text())
}
} catch (error) {
console.error('Error duplicating run:', error)
} finally {
setIsDuplicating(false)
}
}
const handleToggleShare = async () => {
if (!run) return
setIsTogglingShare(true)
try {
const newPermissions = run.permissions === 'private' ? 'public' : 'private'
const response = await fetch(`/api/simulator/${run.id}/share`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
permissions: newPermissions,
}),
})
if (response.ok) {
const updatedRun = await response.json()
setRun(updatedRun)
} else {
console.error('Error toggling share status:', await response.text())
}
} catch (error) {
console.error('Error toggling share status:', error)
} finally {
setIsTogglingShare(false)
}
}
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)
// 对于图片类型,不显示 output 作为文本内容
const currentOutput = (isExecuting && run.status === 'pending')
? streamOutput
: (run?.model?.outputType === 'image' ? '' : run.output)
// 获取图片数据使用临时URL或提取的URL
const imageData = generatedImageUrl
// 调试:在开发环境中显示图片数据信息
if (process.env.NODE_ENV === 'development' && run?.model?.outputType === 'image') {
console.log('Image data info:', {
hasGeneratedFilePath: !!run?.generatedFilePath,
generatedFilePath: run?.generatedFilePath,
hasGeneratedImageUrl: !!generatedImageUrl,
finalImageData: imageData?.substring(0, 100) + '...'
})
}
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>
{isEditing ? (
<div className="mb-2">
<Label htmlFor="runName" className="text-sm font-medium">{t('runName')}</Label>
<Input
id="runName"
value={editForm.name}
onChange={(e) => setEditForm({...editForm, name: e.target.value})}
className="text-2xl font-bold border-2 border-primary"
placeholder={t('runName')}
/>
</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>
{/* 分享状态显示 */}
{run.permissions === 'public' && (
<Badge variant="secondary" className="text-xs">
{run.visibility === 'published' ? (
<span className="text-green-600 dark:text-green-400"></span>
) : (
<span className="text-orange-600 dark:text-orange-400"></span>
)}
</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>
<div className="flex items-center space-x-2">
{/* 共享按钮 - 只有已完成的 run 才能共享 */}
{run.status === 'completed' && (
<Button
onClick={handleToggleShare}
disabled={isTogglingShare}
variant={run.permissions === 'public' ? 'default' : 'outline'}
>
{isTogglingShare ? (
<>
<LoadingSpinner size="sm" />
<span className="ml-2">{t('updating')}</span>
</>
) : run.permissions === 'public' ? (
<>
<Globe className="h-4 w-4 mr-2" />
{t('shared')}
</>
) : (
<>
<Share2 className="h-4 w-4 mr-2" />
{t('shareRun')}
</>
)}
</Button>
)}
<Button
onClick={handleDuplicateRun}
disabled={isDuplicating}
variant="outline"
>
{isDuplicating ? (
<>
<LoadingSpinner size="sm" />
<span className="ml-2">{t('creating')}</span>
</>
) : (
<>
<Copy className="h-4 w-4 mr-2" />
{t('duplicateRun')}
</>
)}
</Button>
{/* 编辑按钮 - 只有pending状态才显示 */}
{run.status === 'pending' && !isEditing && (
<Button
onClick={handleStartEdit}
variant="outline"
>
<Edit className="h-4 w-4 mr-2" />
{t('editRun')}
</Button>
)}
{/* 编辑模式下的保存和取消按钮 */}
{isEditing && (
<>
<Button
onClick={handleCancelEdit}
variant="outline"
>
<X className="h-4 w-4 mr-2" />
{t('cancelEdit')}
</Button>
<Button
onClick={handleSaveEdit}
disabled={isSaving}
>
{isSaving ? (
<>
<LoadingSpinner size="sm" />
<span className="ml-2">{t('creating')}</span>
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
{t('saveChanges')}
</>
)}
</Button>
</>
)}
{run.status === 'pending' && !isEditing && (
<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>
<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>
{!isEditing && (
<Button
variant="outline"
size="sm"
onClick={() => copyToClipboard(promptContent)}
>
<Copy className="h-4 w-4" />
</Button>
)}
</div>
{isEditing ? (
<Textarea
value={editForm.promptContent}
onChange={(e) => setEditForm({...editForm, promptContent: e.target.value})}
className="min-h-32 font-mono text-sm"
placeholder={t('promptContentPlaceholder')}
/>
) : (
<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>
{!isEditing && (
<Button
variant="outline"
size="sm"
onClick={() => copyToClipboard(run.userInput)}
>
<Copy className="h-4 w-4" />
</Button>
)}
</div>
{isEditing ? (
<Textarea
value={editForm.userInput}
onChange={(e) => setEditForm({...editForm, userInput: e.target.value})}
className="min-h-32"
placeholder={t('userInputPlaceholder')}
/>
) : (
<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>
{isEditing ? (
<div className="space-y-4">
{/* Model Selection */}
<div>
<Label htmlFor="modelSelect" className="text-sm font-medium">{t('selectModel')}</Label>
<select
id="modelSelect"
value={editForm.modelId}
onChange={(e) => setEditForm({...editForm, modelId: e.target.value})}
className="w-full mt-1 px-3 py-2 border border-input bg-background rounded-md text-sm"
>
{models.map((model) => (
<option key={model.id} value={model.id}>
{model.provider} - {model.name}
</option>
))}
</select>
</div>
{/* Temperature */}
<div>
<Label htmlFor="temperature" className="text-sm font-medium">{t('temperature')}</Label>
<Input
id="temperature"
type="number"
min="0"
max="2"
step="0.1"
value={editForm.temperature}
onChange={(e) => setEditForm({...editForm, temperature: parseFloat(e.target.value) || 0.7})}
className="mt-1"
/>
</div>
{/* Max Tokens */}
<div>
<Label htmlFor="maxTokens" className="text-sm font-medium">{t('maxTokens')}</Label>
<Input
id="maxTokens"
type="number"
min="1"
value={editForm.maxTokens || ''}
onChange={(e) => setEditForm({...editForm, maxTokens: e.target.value ? parseInt(e.target.value) : undefined})}
placeholder="Leave empty for model default"
className="mt-1"
/>
</div>
{/* Top P */}
<div>
<Label htmlFor="topP" className="text-sm font-medium">{t('topP')}</Label>
<Input
id="topP"
type="number"
min="0"
max="1"
step="0.01"
value={editForm.topP}
onChange={(e) => setEditForm({...editForm, topP: parseFloat(e.target.value) || 1})}
className="mt-1"
/>
</div>
{/* Frequency Penalty */}
<div>
<Label htmlFor="frequencyPenalty" className="text-sm font-medium">{t('frequencyPenalty')}</Label>
<Input
id="frequencyPenalty"
type="number"
min="-2"
max="2"
step="0.1"
value={editForm.frequencyPenalty}
onChange={(e) => setEditForm({...editForm, frequencyPenalty: parseFloat(e.target.value) || 0})}
className="mt-1"
/>
</div>
{/* Presence Penalty */}
<div>
<Label htmlFor="presencePenalty" className="text-sm font-medium">{t('presencePenalty')}</Label>
<Input
id="presencePenalty"
type="number"
min="-2"
max="2"
step="0.1"
value={editForm.presencePenalty}
onChange={(e) => setEditForm({...editForm, presencePenalty: parseFloat(e.target.value) || 0})}
className="mt-1"
/>
</div>
</div>
) : (
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="col-span-2">
<span className="font-medium text-foreground">
{t('selectModel')}:
</span>
<span className="ml-2 text-muted-foreground">
{run.model.provider} - {run.model.name}
</span>
</div>
<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>
{/* 只在非图片类型时显示流式输出 */}
{run.model?.outputType !== 'image' && (
<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>
)}
{/* 图片类型显示等待状态 */}
{run.model?.outputType === 'image' && (
<div className="bg-gradient-to-br from-slate-50 via-background to-slate-50 dark:from-slate-900 dark:via-background dark:to-slate-900 rounded-2xl p-12 border shadow-lg">
<div className="flex flex-col items-center justify-center space-y-6">
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 rounded-full blur-md opacity-30 animate-pulse"></div>
<div className="relative p-4 bg-background rounded-full border shadow-sm">
<RefreshCw className="h-8 w-8 animate-spin text-primary" />
</div>
</div>
<div className="text-center space-y-2">
<p className="text-lg font-semibold text-foreground"></p>
<p className="text-sm text-muted-foreground">AI ...</p>
</div>
<div className="w-32 h-1 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-primary to-primary/50 rounded-full animate-pulse" />
</div>
</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>
) : (run.model?.outputType === 'image' && imageData) || (run.model?.outputType !== 'image' && 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>
{/* 图片输出 */}
{run.model?.outputType === 'image' && imageData && (
<div className="bg-gradient-to-br from-slate-50 via-background to-slate-50 dark:from-slate-900 dark:via-background dark:to-slate-900 rounded-2xl p-8 border shadow-lg">
<div className="text-center mb-6">
<div className="inline-flex items-center space-x-2 px-3 py-1.5 bg-primary/10 text-primary rounded-full text-sm font-medium mb-4">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21,15 16,10 5,21"></polyline>
</svg>
<span></span>
</div>
</div>
<div className="flex justify-center mb-6">
<div className="relative group cursor-pointer">
<div className="absolute inset-0 bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 rounded-xl blur-xl opacity-20 group-hover:opacity-30 transition-opacity duration-300"></div>
<Base64Image
src={imageData}
alt="Generated image"
className="relative max-w-full max-h-[600px] rounded-xl shadow-2xl border border-white/20 transition-all duration-300 group-hover:scale-[1.02] group-hover:shadow-3xl"
onError={() => {
console.error('Failed to load image')
setImageLoadError(true)
}}
onLoad={() => {
setImageLoadError(false)
}}
onClick={() => window.open(imageData, '_blank')}
/>
</div>
</div>
<div className="flex justify-center space-x-4">
<button
onClick={() => window.open(imageData, '_blank')}
className="flex items-center space-x-2 px-4 py-2 bg-primary/10 hover:bg-primary/20 text-primary rounded-lg transition-all duration-200 hover:scale-105 text-sm font-medium"
>
<Eye className="h-4 w-4" />
<span></span>
</button>
<button
onClick={async () => {
try {
if (imageData) {
// URL 数据通过 fetch 下载
const response = await fetch(imageData)
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.style.display = 'none'
a.href = url
a.download = `generated-image-${run.name || 'untitled'}-${Date.now()}.png`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
}
} catch (error) {
console.error('Download failed:', error)
}
}}
className="flex items-center space-x-2 px-4 py-2 bg-green-50 hover:bg-green-100 dark:bg-green-900/30 dark:hover:bg-green-900/50 text-green-600 dark:text-green-400 rounded-lg transition-all duration-200 hover:scale-105 text-sm font-medium"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
</svg>
<span></span>
</button>
<button
onClick={() => {
// 复制图片链接
copyToClipboard(imageData || '')
}}
className="flex items-center space-x-2 px-4 py-2 bg-blue-50 hover:bg-blue-100 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 text-blue-600 dark:text-blue-400 rounded-lg transition-all duration-200 hover:scale-105 text-sm font-medium"
>
<Copy className="h-4 w-4" />
<span></span>
</button>
</div>
</div>
)}
{/* 文本输出 - 只在非图片类型时显示 */}
{run.model?.outputType !== 'image' && currentOutput && (
<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>
)}
{/* 图片错误状态 */}
{run.model?.outputType === 'image' && imageData && imageLoadError && (
<div className="bg-gradient-to-br from-red-50 to-red-100 dark:from-red-950/50 dark:to-red-900/50 rounded-xl p-8 border border-red-200 dark:border-red-800">
<div className="flex flex-col items-center justify-center space-y-4">
<div className="p-3 bg-red-100 dark:bg-red-900 rounded-full">
<svg className="h-8 w-8 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.19 2.5 1.732 2.5z" />
</svg>
</div>
<div className="text-center">
<p className="text-sm font-medium text-red-800 dark:text-red-200"></p>
<p className="text-xs text-red-600 dark:text-red-400 mt-1">访</p>
</div>
<button
onClick={() => {
setImageLoadError(false)
if (runId) {
fetchImageUrl(runId)
}
}}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-xs rounded-lg transition-colors"
>
</button>
</div>
</div>
)}
{/* 图片加载中状态 */}
{run.model?.outputType === 'image' && !imageData && isLoadingImage && (
<div className="bg-gradient-to-br from-slate-50 via-background to-slate-50 dark:from-slate-900 dark:via-background dark:to-slate-900 rounded-2xl p-12 border shadow-lg">
<div className="flex flex-col items-center justify-center space-y-6">
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 rounded-full blur-md opacity-30 animate-pulse"></div>
<div className="relative p-4 bg-background rounded-full border shadow-sm">
<RefreshCw className="h-8 w-8 animate-spin text-primary" />
</div>
</div>
<div className="text-center space-y-2">
<p className="text-lg font-semibold text-foreground"></p>
<p className="text-sm text-muted-foreground">...</p>
</div>
<div className="w-32 h-1 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-primary to-primary/50 rounded-full animate-pulse" />
</div>
</div>
</div>
)}
</div>
) : run.model?.outputType === 'image' && !imageData && !isLoadingImage ? (
<div className="text-center py-12">
<Eye className="h-16 w-16 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">
...
</p>
</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>
{/* Debug Panel - Only in development */}
{run && (
<DebugPanel
debugRequest={run.debugRequest}
debugResponse={run.debugResponse}
runId={run.id}
status={run.status}
/>
)}
</div>
</div>
</div>
<Footer />
</div>
)
}