1197 lines
44 KiB
TypeScript
1197 lines
44 KiB
TypeScript
'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>
|
||
)
|
||
} |