add simulator

This commit is contained in:
songtianlun 2025-08-08 07:08:33 +08:00
parent f4de70302b
commit 06c1a60d1a
11 changed files with 2146 additions and 0 deletions

View File

@ -2,6 +2,7 @@
"navigation": {
"home": "Home",
"studio": "Studio",
"simulator": "Simulator",
"plaza": "Plaza",
"pricing": "Pricing",
"subscription": "Subscription",
@ -344,6 +345,68 @@
"showingResults": "Showing {current} of {total} results",
"clearFilters": "Clear Filters"
},
"simulator": {
"title": "AI Simulator",
"description": "Test and debug your prompts with AI models",
"newRun": "New Run",
"newRunDescription": "Create a new simulation run to test your prompts",
"allRuns": "All Runs",
"completed": "Completed",
"running": "Running",
"failed": "Failed",
"noRuns": "No simulation runs yet",
"noRunsDescription": "Start by creating your first simulation run",
"createFirstRun": "Create First Run",
"viewDetails": "View Details",
"userInput": "User Input",
"output": "Output",
"error": "Error",
"previous": "Previous",
"next": "Next",
"backToList": "Back to List",
"selectPrompt": "Select Prompt",
"prompt": "Prompt",
"selectPromptPlaceholder": "Choose a prompt to test",
"version": "Version",
"selectVersionPlaceholder": "Choose a version",
"useLatestVersion": "Use Latest Version",
"promptContent": "Prompt Content",
"selectModel": "Select AI Model",
"userInputPlaceholder": "Enter your input for testing the prompt...",
"advancedSettings": "Advanced Settings",
"show": "Show",
"hide": "Hide",
"temperature": "Temperature",
"temperatureDescription": "Controls randomness. Higher values make output more creative.",
"topP": "Top P",
"maxTokens": "Max Tokens",
"frequencyPenalty": "Frequency Penalty",
"presencePenalty": "Presence Penalty",
"cancel": "Cancel",
"creating": "Creating...",
"runSimulation": "Run Simulation",
"execute": "Execute",
"executing": "Executing...",
"generating": "Generating...",
"pendingExecution": "Click execute to run this simulation",
"noOutput": "No output generated yet",
"configuration": "Configuration",
"statistics": "Statistics",
"inputTokens": "Input Tokens",
"outputTokens": "Output Tokens",
"totalCost": "Total Cost",
"duration": "Duration",
"copiedToClipboard": "Copied to clipboard",
"copyError": "Failed to copy to clipboard",
"executeError": "Failed to execute simulation",
"loadingRuns": "Loading simulation runs...",
"status": {
"pending": "Pending",
"running": "Running",
"completed": "Completed",
"failed": "Failed"
}
},
"errors": {
"generic": "Something went wrong. Please try again.",
"network": "Network error. Please check your connection.",

View File

@ -2,6 +2,7 @@
"navigation": {
"home": "首页",
"studio": "工作室",
"simulator": "模拟器",
"plaza": "广场",
"pricing": "价格",
"subscription": "订阅",
@ -344,6 +345,68 @@
"showingResults": "显示 {current} / {total} 个结果",
"clearFilters": "清除筛选"
},
"simulator": {
"title": "AI 模拟器",
"description": "使用AI模型测试和调试您的提示词",
"newRun": "新建运行",
"newRunDescription": "创建新的模拟运行来测试您的提示词",
"allRuns": "所有运行",
"completed": "已完成",
"running": "运行中",
"failed": "失败",
"noRuns": "还没有模拟运行",
"noRunsDescription": "开始创建您的第一个模拟运行",
"createFirstRun": "创建首个运行",
"viewDetails": "查看详情",
"userInput": "用户输入",
"output": "输出",
"error": "错误",
"previous": "上一页",
"next": "下一页",
"backToList": "返回列表",
"selectPrompt": "选择提示词",
"prompt": "提示词",
"selectPromptPlaceholder": "选择要测试的提示词",
"version": "版本",
"selectVersionPlaceholder": "选择版本",
"useLatestVersion": "使用最新版本",
"promptContent": "提示词内容",
"selectModel": "选择AI模型",
"userInputPlaceholder": "输入用于测试提示词的内容...",
"advancedSettings": "高级设置",
"show": "显示",
"hide": "隐藏",
"temperature": "温度",
"temperatureDescription": "控制随机性。较高的值使输出更有创意。",
"topP": "Top P",
"maxTokens": "最大Token数",
"frequencyPenalty": "频率惩罚",
"presencePenalty": "存在惩罚",
"cancel": "取消",
"creating": "创建中...",
"runSimulation": "运行模拟",
"execute": "执行",
"executing": "执行中...",
"generating": "生成中...",
"pendingExecution": "点击执行来运行此模拟",
"noOutput": "还没有生成输出",
"configuration": "配置",
"statistics": "统计信息",
"inputTokens": "输入Token",
"outputTokens": "输出Token",
"totalCost": "总费用",
"duration": "持续时间",
"copiedToClipboard": "已复制到剪贴板",
"copyError": "复制到剪贴板失败",
"executeError": "执行模拟失败",
"loadingRuns": "加载模拟运行中...",
"status": {
"pending": "待执行",
"running": "运行中",
"completed": "已完成",
"failed": "失败"
}
},
"errors": {
"generic": "出现错误,请重试。",
"network": "网络错误,请检查您的网络连接。",

View File

@ -41,6 +41,7 @@ model User {
prompts Prompt[]
credits Credit[]
subscriptions Subscription[]
simulatorRuns SimulatorRun[]
@@map("users")
}
@ -92,6 +93,7 @@ model Prompt {
albumId String?
tests PromptTestRun[]
stats PromptStats?
simulatorRuns SimulatorRun[]
@@map("prompts")
}
@ -105,6 +107,7 @@ model PromptVersion {
promptId String
prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade)
simulatorRuns SimulatorRun[]
@@unique([promptId, version])
@@map("prompt_versions")
@ -227,7 +230,47 @@ model Model {
// 关联关系
subscriptionPlan SubscriptionPlan @relation(fields: [subscriptionPlanId], references: [id], onDelete: Cascade)
// 关联关系
simulatorRuns SimulatorRun[]
// 同一个套餐内不能有相同的模型ID
@@unique([subscriptionPlanId, modelId])
@@map("models")
}
// 模拟器运行记录
model SimulatorRun {
id String @id @default(cuid())
userId String
promptId String
promptVersionId String? // 可选,如果选择了特定版本
modelId String
userInput String // 用户输入内容
output String? // AI响应输出
error String? // 错误信息
status String @default("pending") // "pending", "running", "completed", "failed"
// 运行配置
temperature Float? @default(0.7)
maxTokens Int?
topP Float?
frequencyPenalty Float?
presencePenalty Float?
// 消耗和统计
inputTokens Int? // 输入token数
outputTokens Int? // 输出token数
totalCost Float? // 总消费
duration Int? // 运行时长(毫秒)
createdAt DateTime @default(now())
completedAt DateTime?
// 关联关系
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade)
promptVersion PromptVersion? @relation(fields: [promptVersionId], references: [id], onDelete: SetNull)
model Model @relation(fields: [modelId], references: [id])
@@map("simulator_runs")
}

View File

@ -0,0 +1,193 @@
import { NextRequest, NextResponse } from "next/server";
import { createServerSupabaseClient } from "@/lib/supabase-server";
import { prisma } from "@/lib/prisma";
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const run = await prisma.simulatorRun.findFirst({
where: {
id,
userId: user.id,
},
include: {
prompt: true,
promptVersion: true,
model: true,
}
});
if (!run) {
return NextResponse.json({ error: "Run not found" }, { status: 404 });
}
if (run.status !== "pending") {
return NextResponse.json({ error: "Run already executed" }, { status: 400 });
}
// 更新状态为运行中
await prisma.simulatorRun.update({
where: { id },
data: { status: "running" },
});
// 准备AI API请求
const promptContent = run.promptVersion?.content || run.prompt.content;
const finalPrompt = `${promptContent}\n\nUser Input: ${run.userInput}`;
const requestBody = {
model: run.model.modelId,
messages: [
{
role: "user",
content: finalPrompt,
}
],
temperature: run.temperature || 0.7,
...(run.maxTokens && { max_tokens: run.maxTokens }),
...(run.topP && { top_p: run.topP }),
...(run.frequencyPenalty && { frequency_penalty: run.frequencyPenalty }),
...(run.presencePenalty && { presence_penalty: run.presencePenalty }),
stream: true,
};
const openRouterResponse = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`,
"Content-Type": "application/json",
"HTTP-Referer": process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
"X-Title": "Prmbr - AI Prompt Studio",
},
body: JSON.stringify(requestBody),
});
if (!openRouterResponse.ok) {
const errorText = await openRouterResponse.text();
await prisma.simulatorRun.update({
where: { id },
data: {
status: "failed",
error: `OpenRouter API error: ${openRouterResponse.status} - ${errorText}`,
},
});
return NextResponse.json({ error: "AI API request failed" }, { status: 500 });
}
// 创建流式响应
const stream = new ReadableStream({
async start(controller) {
const reader = openRouterResponse.body?.getReader();
if (!reader) {
controller.close();
return;
}
let fullResponse = "";
let inputTokens = 0;
let outputTokens = 0;
const startTime = Date.now();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = new TextDecoder().decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
// 计算最终数据并更新数据库
const duration = Date.now() - startTime;
const estimatedCost = (inputTokens * 0.001) + (outputTokens * 0.002); // 示例定价
await prisma.simulatorRun.update({
where: { id },
data: {
status: "completed",
output: fullResponse,
inputTokens,
outputTokens,
totalCost: estimatedCost,
duration,
completedAt: new Date(),
},
});
controller.enqueue(new TextEncoder().encode(`data: [DONE]\n\n`));
controller.close();
return;
}
try {
const parsed = JSON.parse(data);
if (parsed.choices?.[0]?.delta?.content) {
const content = parsed.choices[0].delta.content;
fullResponse += content;
}
// 估算token使用量简化版本
if (parsed.usage) {
inputTokens = parsed.usage.prompt_tokens || 0;
outputTokens = parsed.usage.completion_tokens || 0;
}
} catch {
// 忽略解析错误,继续处理其他数据
}
controller.enqueue(new TextEncoder().encode(`data: ${data}\n\n`));
}
}
}
} catch (error) {
console.error("Stream processing error:", error);
await prisma.simulatorRun.update({
where: { id },
data: {
status: "failed",
error: `Stream processing error: ${error}`,
},
});
controller.close();
}
},
});
return new NextResponse(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
} catch (error) {
console.error("Error executing simulator run:", error);
// 更新运行状态为失败
await prisma.simulatorRun.update({
where: { id },
data: {
status: "failed",
error: `Execution error: ${error}`,
},
}).catch(() => {}); // 忽略更新失败
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,117 @@
import { NextRequest, NextResponse } from "next/server";
import { createServerSupabaseClient } from "@/lib/supabase-server";
import { prisma } from "@/lib/prisma";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const run = await prisma.simulatorRun.findFirst({
where: {
id,
userId: user.id,
},
include: {
prompt: {
select: { id: true, name: true, content: true }
},
promptVersion: {
select: { id: true, version: true, content: true }
},
model: {
select: {
id: true,
name: true,
provider: true,
modelId: true,
description: true,
maxTokens: true
}
}
}
});
if (!run) {
return NextResponse.json({ error: "Run not found" }, { status: 404 });
}
return NextResponse.json(run);
} catch (error) {
console.error("Error fetching simulator run:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { status, output, error, inputTokens, outputTokens, totalCost, duration } = body;
const run = await prisma.simulatorRun.findFirst({
where: {
id,
userId: user.id,
},
});
if (!run) {
return NextResponse.json({ error: "Run not found" }, { status: 404 });
}
const updatedRun = await prisma.simulatorRun.update({
where: { id },
data: {
...(status && { status }),
...(output !== undefined && { output }),
...(error !== undefined && { error }),
...(inputTokens !== undefined && { inputTokens }),
...(outputTokens !== undefined && { outputTokens }),
...(totalCost !== undefined && { totalCost }),
...(duration !== undefined && { duration }),
...(status === "completed" && { completedAt: new Date() }),
},
include: {
prompt: {
select: { id: true, name: true }
},
promptVersion: {
select: { id: true, version: true }
},
model: {
select: { id: true, name: true, provider: true }
}
}
});
return NextResponse.json(updatedRun);
} catch (error) {
console.error("Error updating simulator run:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import { createServerSupabaseClient } from "@/lib/supabase-server";
import { prisma } from "@/lib/prisma";
export async function GET() {
try {
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// 获取用户信息和订阅套餐
const userWithPlan = await prisma.user.findUnique({
where: { id: user.id },
include: {
subscriptionPlan: {
include: {
models: {
where: { isActive: true },
orderBy: [
{ provider: "asc" },
{ name: "asc" }
]
}
}
}
}
});
if (!userWithPlan) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const models = userWithPlan.subscriptionPlan.models.map(model => ({
id: model.id,
modelId: model.modelId,
name: model.name,
provider: model.provider,
description: model.description,
maxTokens: model.maxTokens,
inputCostPer1k: model.inputCostPer1k,
outputCostPer1k: model.outputCostPer1k,
supportedFeatures: model.supportedFeatures,
}));
return NextResponse.json({ models });
} catch (error) {
console.error("Error fetching available models:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,145 @@
import { NextRequest, NextResponse } from "next/server";
import { createServerSupabaseClient } from "@/lib/supabase-server";
import { prisma } from "@/lib/prisma";
export async function GET(request: NextRequest) {
try {
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") || "1");
const limit = parseInt(searchParams.get("limit") || "20");
const status = searchParams.get("status");
const skip = (page - 1) * limit;
const where = {
userId: user.id,
...(status && { status }),
};
const [runs, total] = await Promise.all([
prisma.simulatorRun.findMany({
where,
include: {
prompt: {
select: { id: true, name: true }
},
promptVersion: {
select: { id: true, version: true }
},
model: {
select: { id: true, name: true, provider: true }
}
},
orderBy: { createdAt: "desc" },
skip,
take: limit,
}),
prisma.simulatorRun.count({ where }),
]);
return NextResponse.json({
runs,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
} catch (error) {
console.error("Error fetching simulator runs:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const {
promptId,
promptVersionId,
modelId,
userInput,
temperature = 0.7,
maxTokens,
topP,
frequencyPenalty,
presencePenalty,
} = body;
// 验证用户是否拥有该prompt
const prompt = await prisma.prompt.findFirst({
where: {
id: promptId,
userId: user.id,
},
});
if (!prompt) {
return NextResponse.json({ error: "Prompt not found" }, { status: 404 });
}
// 验证模型是否可用
const model = await prisma.model.findUnique({
where: { id: modelId },
include: { subscriptionPlan: true }
});
if (!model || !model.isActive) {
return NextResponse.json({ error: "Model not available" }, { status: 400 });
}
// 创建运行记录
const run = await prisma.simulatorRun.create({
data: {
userId: user.id,
promptId,
promptVersionId,
modelId,
userInput,
temperature,
maxTokens,
topP,
frequencyPenalty,
presencePenalty,
status: "pending",
},
include: {
prompt: {
select: { id: true, name: true }
},
promptVersion: {
select: { id: true, version: true, content: true }
},
model: {
select: { id: true, name: true, provider: true, modelId: true }
}
}
});
return NextResponse.json(run, { status: 201 });
} catch (error) {
console.error("Error creating simulator run:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,534 @@
'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 { 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'
interface SimulatorRun {
id: string
status: string
userInput: string
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
}
promptVersion?: {
id: string
version: number
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 = run.promptVersion?.content || run.prompt.content
const currentOutput = (isExecuting && run.status === 'pending') ? streamOutput : run.output
return (
<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.prompt.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.promptVersion && (
<Badge variant="outline">
v{run.promptVersion.version}
</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>
)
}

View File

@ -0,0 +1,479 @@
'use client'
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { useAuthUser } from '@/hooks/useAuthUser'
import { useRouter } from 'next/navigation'
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 { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
ArrowLeft,
Play,
Settings,
Zap,
DollarSign,
ChevronRight,
ChevronDown
} from 'lucide-react'
import Link from 'next/link'
interface Prompt {
id: string
name: string
content: string
versions: Array<{
id: string
version: number
content: string
}>
}
interface Model {
id: string
modelId: string
name: string
provider: string
description?: string
maxTokens?: number
inputCostPer1k?: number
outputCostPer1k?: number
supportedFeatures?: Record<string, unknown>
}
export default function NewSimulatorRunPage() {
const { user, loading: authLoading } = useAuthUser()
const router = useRouter()
const t = useTranslations('simulator')
const [prompts, setPrompts] = useState<Prompt[]>([])
const [models, setModels] = useState<Model[]>([])
const [selectedPromptId, setSelectedPromptId] = useState('')
const [selectedVersionId, setSelectedVersionId] = useState('')
const [selectedModelId, setSelectedModelId] = useState('')
const [userInput, setUserInput] = useState('')
const [isLoading, setIsLoading] = useState(true)
const [isCreating, setIsCreating] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
// Advanced settings
const [temperature, setTemperature] = useState('0.7')
const [maxTokens, setMaxTokens] = useState('')
const [topP, setTopP] = useState('1')
const [frequencyPenalty, setFrequencyPenalty] = useState('0')
const [presencePenalty, setPresencePenalty] = useState('0')
const selectedPrompt = prompts.find(p => p.id === selectedPromptId)
const selectedVersion = selectedPrompt?.versions.find(v => v.id === selectedVersionId)
const selectedModel = models.find(m => m.id === selectedModelId)
const promptContent = selectedVersion?.content || selectedPrompt?.content || ''
useEffect(() => {
if (!authLoading && user) {
fetchData()
}
}, [user, authLoading])
const fetchData = async () => {
try {
setIsLoading(true)
const [promptsResponse, modelsResponse] = await Promise.all([
fetch('/api/prompts?limit=100'),
fetch('/api/simulator/models')
])
if (promptsResponse.ok) {
const promptsData = await promptsResponse.json()
setPrompts(promptsData.prompts || [])
}
if (modelsResponse.ok) {
const modelsData = await modelsResponse.json()
setModels(modelsData.models || [])
// Auto-select first model
if (modelsData.models?.length > 0) {
setSelectedModelId(modelsData.models[0].id)
}
}
} catch (error) {
console.error('Error fetching data:', error)
} finally {
setIsLoading(false)
}
}
const handlePromptChange = (promptId: string) => {
setSelectedPromptId(promptId)
setSelectedVersionId('')
// Auto-select latest version
const prompt = prompts.find(p => p.id === promptId)
if (prompt && prompt.versions.length > 0) {
const latestVersion = prompt.versions.reduce((latest, current) =>
current.version > latest.version ? current : latest
)
setSelectedVersionId(latestVersion.id)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!selectedPromptId || !selectedModelId || !userInput.trim()) {
return
}
setIsCreating(true)
try {
const response = await fetch('/api/simulator', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
promptId: selectedPromptId,
promptVersionId: selectedVersionId || undefined,
modelId: selectedModelId,
userInput,
temperature: parseFloat(temperature),
maxTokens: maxTokens ? parseInt(maxTokens) : undefined,
topP: parseFloat(topP),
frequencyPenalty: parseFloat(frequencyPenalty),
presencePenalty: parseFloat(presencePenalty),
}),
})
if (response.ok) {
const run = await response.json()
router.push(`/simulator/${run.id}`)
} else {
console.error('Error creating run:', await response.text())
}
} catch (error) {
console.error('Error creating run:', error)
} finally {
setIsCreating(false)
}
}
if (authLoading) {
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...</p>
</div>
</div>
)
}
if (!user) {
return null
}
return (
<div className="container mx-auto px-4 py-8 max-w-4xl">
{/* 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>
<h1 className="text-3xl font-bold text-foreground mb-2">{t('newRun')}</h1>
<p className="text-muted-foreground">
{t('newRunDescription')}
</p>
</div>
{isLoading ? (
<div className="text-center py-12">
<LoadingSpinner size="lg" />
<p className="mt-4 text-muted-foreground">Loading data...</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Select Prompt */}
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold mb-2 flex items-center">
<Zap className="h-5 w-5 mr-2" />
{t('selectPrompt')}
</h2>
<p className="text-muted-foreground">
Choose the prompt you want to test with the AI model
</p>
</div>
</div>
<div className="space-y-4">
<div>
<Label htmlFor="prompt" className="text-sm font-medium">{t('prompt')}</Label>
<select
id="prompt"
value={selectedPromptId}
onChange={(e) => handlePromptChange(e.target.value)}
className="w-full mt-1 bg-background border border-input rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-ring"
required
>
<option value="">{t('selectPromptPlaceholder')}</option>
{prompts.map(prompt => (
<option key={prompt.id} value={prompt.id}>
{prompt.name}
</option>
))}
</select>
</div>
{selectedPrompt && selectedPrompt.versions.length > 0 && (
<div>
<Label htmlFor="version" className="text-sm font-medium">{t('version')}</Label>
<select
id="version"
value={selectedVersionId}
onChange={(e) => setSelectedVersionId(e.target.value)}
className="w-full mt-1 bg-background border border-input rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">{t('useLatestVersion')}</option>
{selectedPrompt.versions
.sort((a, b) => b.version - a.version)
.map(version => (
<option key={version.id} value={version.id}>
v{version.version}
</option>
))}
</select>
</div>
)}
{promptContent && (
<div>
<Label className="text-sm font-medium">{t('promptContent')}</Label>
<div className="mt-1 p-4 bg-muted rounded-md border max-h-48 overflow-y-auto">
<pre className="text-sm text-foreground whitespace-pre-wrap font-mono">
{promptContent}
</pre>
</div>
</div>
)}
</div>
</Card>
{/* Select Model */}
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold mb-2">{t('selectModel')}</h2>
<p className="text-muted-foreground">
Choose the AI model to run your prompt
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{models.map(model => (
<div
key={model.id}
className={`border rounded-lg p-4 cursor-pointer transition-all ${
selectedModelId === model.id
? 'border-primary bg-primary/5 shadow-sm'
: 'border-border hover:border-border/80 hover:bg-accent/50'
}`}
onClick={() => setSelectedModelId(model.id)}
>
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold text-foreground">{model.name}</h3>
<Badge variant="outline" className="text-xs">
{model.provider}
</Badge>
</div>
{model.description && (
<p className="text-sm text-muted-foreground mb-3 line-clamp-2">
{model.description}
</p>
)}
<div className="flex items-center space-x-4 text-xs text-muted-foreground">
{model.maxTokens && (
<span className="flex items-center">
<Zap className="h-3 w-3 mr-1" />
{model.maxTokens.toLocaleString()} tokens
</span>
)}
{model.inputCostPer1k && (
<span className="flex items-center">
<DollarSign className="h-3 w-3 mr-1" />
${model.inputCostPer1k}/1K
</span>
)}
</div>
</div>
))}
</div>
</Card>
{/* User Input */}
<Card className="p-6">
<div className="mb-6">
<h2 className="text-xl font-semibold mb-2">{t('userInput')}</h2>
<p className="text-muted-foreground">
Enter the input you want to test with your prompt
</p>
</div>
<Textarea
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
placeholder={t('userInputPlaceholder')}
className="min-h-32 resize-none"
required
/>
</Card>
{/* Advanced Settings */}
<Card className="p-6">
<div
className="flex items-center justify-between cursor-pointer"
onClick={() => setShowAdvanced(!showAdvanced)}
>
<div className="flex items-center">
<Settings className="h-5 w-5 mr-2" />
<h2 className="text-xl font-semibold">{t('advancedSettings')}</h2>
</div>
{showAdvanced ? (
<ChevronDown className="h-5 w-5" />
) : (
<ChevronRight className="h-5 w-5" />
)}
</div>
{showAdvanced && (
<div className="mt-6 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<Label htmlFor="temperature" className="text-sm font-medium">
{t('temperature')}: {temperature}
</Label>
<Input
id="temperature"
type="number"
value={temperature}
onChange={(e) => setTemperature(e.target.value)}
min="0"
max="2"
step="0.1"
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
{t('temperatureDescription')}
</p>
</div>
<div>
<Label htmlFor="topP" className="text-sm font-medium">
{t('topP')}: {topP}
</Label>
<Input
id="topP"
type="number"
value={topP}
onChange={(e) => setTopP(e.target.value)}
min="0"
max="1"
step="0.1"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="maxTokens" className="text-sm font-medium">
{t('maxTokens')}
</Label>
<Input
id="maxTokens"
type="number"
value={maxTokens}
onChange={(e) => setMaxTokens(e.target.value)}
placeholder={selectedModel?.maxTokens?.toString() || "2048"}
min="1"
max={selectedModel?.maxTokens || 4096}
className="mt-1"
/>
</div>
</div>
<div className="space-y-4">
<div>
<Label htmlFor="frequencyPenalty" className="text-sm font-medium">
{t('frequencyPenalty')}: {frequencyPenalty}
</Label>
<Input
id="frequencyPenalty"
type="number"
value={frequencyPenalty}
onChange={(e) => setFrequencyPenalty(e.target.value)}
min="-2"
max="2"
step="0.1"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="presencePenalty" className="text-sm font-medium">
{t('presencePenalty')}: {presencePenalty}
</Label>
<Input
id="presencePenalty"
type="number"
value={presencePenalty}
onChange={(e) => setPresencePenalty(e.target.value)}
min="-2"
max="2"
step="0.1"
className="mt-1"
/>
</div>
</div>
</div>
</div>
)}
</Card>
{/* Submit */}
<div className="flex items-center justify-end space-x-4">
<Link href="/simulator">
<Button type="button" variant="outline">
{t('cancel')}
</Button>
</Link>
<Button
type="submit"
disabled={isCreating || !selectedPromptId || !selectedModelId || !userInput.trim()}
>
{isCreating ? (
<>
<LoadingSpinner size="sm" />
<span className="ml-2">{t('creating')}</span>
</>
) : (
<>
<Play className="h-4 w-4 mr-2" />
{t('runSimulation')}
</>
)}
</Button>
</div>
</form>
)}
</div>
)
}

447
src/app/simulator/page.tsx Normal file
View File

@ -0,0 +1,447 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { useAuthUser } from '@/hooks/useAuthUser'
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 {
Play,
Database,
Plus,
Clock,
CheckCircle2,
XCircle,
AlertCircle,
Eye,
RefreshCw,
Zap,
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'
interface SimulatorRun {
id: string
status: string
userInput: string
output?: string
error?: string
createdAt: string
completedAt?: string
prompt: {
id: string
name: string
}
promptVersion?: {
id: string
version: number
}
model: {
id: string
name: string
provider: string
}
inputTokens?: number
outputTokens?: number
totalCost?: number
duration?: number
}
interface PaginationInfo {
page: number
limit: number
total: number
totalPages: number
}
export default function SimulatorPage() {
const { user, loading: authLoading } = useAuthUser()
const t = useTranslations('simulator')
const locale = useLocale()
const [runs, setRuns] = useState<SimulatorRun[]>([])
const [pagination, setPagination] = useState<PaginationInfo>({
page: 1,
limit: 20,
total: 0,
totalPages: 0
})
const [isLoading, setIsLoading] = useState(true)
const [statusFilter, setStatusFilter] = useState<string>('')
const fetchRuns = useCallback(async () => {
try {
setIsLoading(true)
const params = new URLSearchParams({
page: pagination.page.toString(),
limit: pagination.limit.toString(),
...(statusFilter && { status: statusFilter })
})
const response = await fetch(`/api/simulator?${params}`)
if (response.ok) {
const data = await response.json()
setRuns(data.runs)
setPagination(data.pagination)
}
} catch (error) {
console.error('Error fetching simulator runs:', error)
} finally {
setIsLoading(false)
}
}, [pagination.page, pagination.limit, statusFilter])
useEffect(() => {
if (!authLoading && user) {
fetchRuns()
}
}, [user, authLoading, pagination.page, statusFilter, fetchRuns])
const getStatusIcon = (status: string) => {
switch (status) {
case 'pending':
return <Clock className="h-4 w-4" />
case 'running':
return <RefreshCw className="h-4 w-4 animate-spin" />
case 'completed':
return <CheckCircle2 className="h-4 w-4" />
case 'failed':
return <XCircle className="h-4 w-4" />
default:
return <Clock className="h-4 w-4" />
}
}
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'
}
}
const getRunStats = () => {
const total = runs.length
const completed = runs.filter(run => run.status === 'completed').length
const running = runs.filter(run => run.status === 'running').length
const failed = runs.filter(run => run.status === 'failed').length
return { total, completed, running, failed }
}
if (authLoading) {
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">{t('loadingRuns')}</p>
</div>
</div>
)
}
if (!user) {
return null
}
const stats = getRunStats()
return (
<div className="container mx-auto px-4 py-8 max-w-7xl">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground mb-2">{t('title')}</h1>
<p className="text-muted-foreground">
{t('description')}
</p>
</div>
<Link href="/simulator/new">
<Button className="flex items-center space-x-2">
<Plus className="h-4 w-4" />
<span>{t('newRun')}</span>
</Button>
</Link>
</div>
{/* Stats */}
{runs.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
<Card className="p-4">
<div className="flex items-center space-x-2">
<Database className="h-4 w-4 text-muted-foreground" />
<div>
<div className="text-2xl font-bold">{stats.total}</div>
<div className="text-xs text-muted-foreground">Total Runs</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center space-x-2">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<div>
<div className="text-2xl font-bold">{stats.completed}</div>
<div className="text-xs text-muted-foreground">Completed</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center space-x-2">
<RefreshCw className="h-4 w-4 text-blue-500" />
<div>
<div className="text-2xl font-bold">{stats.running}</div>
<div className="text-xs text-muted-foreground">Running</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center space-x-2">
<XCircle className="h-4 w-4 text-red-500" />
<div>
<div className="text-2xl font-bold">{stats.failed}</div>
<div className="text-xs text-muted-foreground">Failed</div>
</div>
</div>
</Card>
</div>
)}
</div>
{/* Filters */}
<Card className="p-6 mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Filter Runs</h2>
<Button
variant="outline"
size="sm"
onClick={() => {
setStatusFilter('')
setPagination(prev => ({ ...prev, page: 1 }))
}}
>
Clear Filters
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant={statusFilter === '' ? 'default' : 'outline'}
size="sm"
onClick={() => {
setStatusFilter('')
setPagination(prev => ({ ...prev, page: 1 }))
}}
>
{t('allRuns')}
</Button>
<Button
variant={statusFilter === 'completed' ? 'default' : 'outline'}
size="sm"
onClick={() => {
setStatusFilter('completed')
setPagination(prev => ({ ...prev, page: 1 }))
}}
>
{t('completed')}
</Button>
<Button
variant={statusFilter === 'running' ? 'default' : 'outline'}
size="sm"
onClick={() => {
setStatusFilter('running')
setPagination(prev => ({ ...prev, page: 1 }))
}}
>
{t('running')}
</Button>
<Button
variant={statusFilter === 'failed' ? 'default' : 'outline'}
size="sm"
onClick={() => {
setStatusFilter('failed')
setPagination(prev => ({ ...prev, page: 1 }))
}}
>
{t('failed')}
</Button>
</div>
</Card>
{/* Runs List */}
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-foreground">
Simulation Runs
</h2>
<p className="text-sm text-muted-foreground">
{pagination.total} runs total
</p>
</div>
</div>
{isLoading ? (
<div className="text-center py-8">
<LoadingSpinner size="lg" />
<p className="mt-4 text-muted-foreground">{t('loadingRuns')}</p>
</div>
) : runs.length === 0 ? (
<div className="text-center py-12">
<Database className="h-16 w-16 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold text-foreground mb-2">{t('noRuns')}</h3>
<p className="text-muted-foreground mb-6">
{t('noRunsDescription')}
</p>
<Link href="/simulator/new">
<Button>
<Play className="h-4 w-4 mr-2" />
{t('createFirstRun')}
</Button>
</Link>
</div>
) : (
<div className="space-y-4">
{runs.map(run => (
<div
key={run.id}
className="border border-border rounded-lg p-6 hover:shadow-md transition-shadow"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-3">
<h3 className="font-semibold text-foreground">{run.prompt.name}</h3>
<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.promptVersion && (
<Badge variant="outline" className="text-xs">
v{run.promptVersion.version}
</Badge>
)}
</div>
<div className="flex items-center space-x-4 text-sm text-muted-foreground mb-3">
<span className="flex items-center">
<Zap className="h-3 w-3 mr-1" />
{run.model.provider} {run.model.name}
</span>
<span>
{formatDistanceToNow(new Date(run.createdAt), {
addSuffix: true,
locale: locale === 'zh' ? zhCN : enUS
})}
</span>
{run.inputTokens && run.outputTokens && (
<span>
{run.inputTokens + run.outputTokens} tokens
</span>
)}
{run.totalCost && (
<span className="flex items-center">
<DollarSign className="h-3 w-3 mr-1" />
${run.totalCost.toFixed(4)}
</span>
)}
</div>
<div className="space-y-3">
<div>
<h4 className="text-sm font-medium text-foreground mb-1">
{t('userInput')}
</h4>
<p className="text-sm text-muted-foreground line-clamp-2">
{run.userInput}
</p>
</div>
{run.output && (
<div>
<h4 className="text-sm font-medium text-foreground mb-1">
{t('output')}
</h4>
<p className="text-sm text-muted-foreground line-clamp-3">
{run.output}
</p>
</div>
)}
{run.error && (
<div>
<h4 className="text-sm font-medium text-red-600 dark:text-red-400 mb-1 flex items-center">
<AlertCircle className="h-4 w-4 mr-1" />
{t('error')}
</h4>
<p className="text-sm text-red-600 dark:text-red-400 line-clamp-2">
{run.error}
</p>
</div>
)}
</div>
</div>
<div className="flex items-center space-x-2 ml-6">
<Link href={`/simulator/${run.id}`}>
<Button size="sm" variant="outline">
<Eye className="h-4 w-4 mr-1" />
{t('viewDetails')}
</Button>
</Link>
</div>
</div>
</div>
))}
</div>
)}
</Card>
{/* Pagination */}
{pagination.totalPages > 1 && (
<div className="flex items-center justify-center space-x-2 mt-8">
<Button
variant="outline"
disabled={pagination.page <= 1}
onClick={() => setPagination(prev => ({ ...prev, page: prev.page - 1 }))}
>
{t('previous')}
</Button>
{[...Array(Math.min(5, pagination.totalPages))].map((_, i) => {
const page = Math.max(1, Math.min(pagination.totalPages - 4, pagination.page - 2)) + i
if (page > pagination.totalPages) return null
return (
<Button
key={page}
variant={pagination.page === page ? 'default' : 'outline'}
onClick={() => setPagination(prev => ({ ...prev, page }))}
>
{page}
</Button>
)
})}
<Button
variant="outline"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPagination(prev => ({ ...prev, page: prev.page + 1 }))}
>
{t('next')}
</Button>
</div>
)}
</div>
)
}

View File

@ -35,6 +35,9 @@ export function Header() {
<Link href="/studio" className="text-muted-foreground hover:text-foreground px-3 py-2 text-sm font-medium transition-colors">
{t('studio')}
</Link>
<Link href="/simulator" className="text-muted-foreground hover:text-foreground px-3 py-2 text-sm font-medium transition-colors">
{t('simulator')}
</Link>
<Link href="/plaza" className="text-muted-foreground hover:text-foreground px-3 py-2 text-sm font-medium transition-colors">
{t('plaza')}
</Link>
@ -92,6 +95,9 @@ export function Header() {
<Link href="/studio" className="block text-muted-foreground hover:text-foreground px-3 py-2 text-base font-medium transition-colors rounded-md hover:bg-accent">
{t('studio')}
</Link>
<Link href="/simulator" className="block text-muted-foreground hover:text-foreground px-3 py-2 text-base font-medium transition-colors rounded-md hover:bg-accent">
{t('simulator')}
</Link>
<Link href="/plaza" className="block text-muted-foreground hover:text-foreground px-3 py-2 text-base font-medium transition-colors rounded-md hover:bg-accent">
{t('plaza')}
</Link>