add simulator
This commit is contained in:
parent
f4de70302b
commit
06c1a60d1a
@ -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.",
|
||||
|
@ -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": "网络错误,请检查您的网络连接。",
|
||||
|
@ -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")
|
||||
}
|
||||
|
193
src/app/api/simulator/[id]/execute/route.ts
Normal file
193
src/app/api/simulator/[id]/execute/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
117
src/app/api/simulator/[id]/route.ts
Normal file
117
src/app/api/simulator/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
56
src/app/api/simulator/models/route.ts
Normal file
56
src/app/api/simulator/models/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
145
src/app/api/simulator/route.ts
Normal file
145
src/app/api/simulator/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
534
src/app/simulator/[id]/page.tsx
Normal file
534
src/app/simulator/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
479
src/app/simulator/new/page.tsx
Normal file
479
src/app/simulator/new/page.tsx
Normal 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
447
src/app/simulator/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user