finished prompt studio

This commit is contained in:
songtianlun 2025-07-30 00:13:43 +08:00
parent 0371a75497
commit 921143f6a2
24 changed files with 3917 additions and 300 deletions

View File

@ -18,9 +18,25 @@ npm run lint # Run ESLint for code quality checks
**Database operations:**
```bash
npx prisma generate # Generate Prisma client after schema changes
npx prisma db push # Push schema changes to database
npx prisma studio # Open Prisma Studio database GUI
npm run db:generate # Generate Prisma client after schema changes
npm run db:push # Push schema changes to database (development)
npm run db:migrate # Create and apply migrations (production)
npm run db:studio # Open Prisma Studio database GUI
npm run db:reset # Reset database and apply all migrations
```
**Development setup:**
```bash
# First time setup
npm install
chmod +x scripts/setup-db.sh
./scripts/setup-db.sh
# Or use the automated check
node scripts/dev-check.js
# Daily development
npm run dev # Automatically generates Prisma client and starts dev server
```
## Architecture Overview
@ -91,40 +107,40 @@ Required environment variables:
- [ ] Version Limit (Max By Subscribe)
- [ ] Credit (Max By Subscribe)
- [ ] Subscribe Plan (Free, Pro)
- [ ] AI Prompt Studio
- [ ] Manager
- [ ] CRUD
- [ ] Search
- [ ] Filter
- [ ] Sort
- [ ] Pagination
- [ ] Bulk Actions
- [ ] DB Fields
- [ ] ID
- [ ] Name
- [ ] Content
- [ ] Album
- [ ] Tag
- [ ] Version
- [ ] Created At
- [ ] Updated At
- [ ] Primissions (Set By User)
- [ ] Private
- [ ] Public
- [x] AI Prompt Studio
- [x] Manager
- [x] CRUD
- [x] Search
- [x] Filter
- [x] Sort
- [x] Pagination
- [x] Bulk Actions
- [x] DB Fields
- [x] ID
- [x] Name
- [x] Content
- [x] Album
- [x] Tag
- [x] Version
- [x] Created At
- [x] Updated At
- [x] Primissions (Set By User)
- [x] Private
- [x] Public
- [ ] Visibility (Set by admin when User Share)
- [ ] Under Review
- [ ] Published
- [ ] View Count
- [ ] Prompt Version Controll
- Generate a new version when save
- Save last [LIMIT] versions
- [LIMIT] can setting in user profile
- [LIMIT] max is by Subscribe
- [ ] Prompt Debugger run
- Select AI Model
- Input Prompt Content
- Show Test Result
- Need to User Credit
- [x] Prompt Version Controll
- [x] Generate a new version when save
- [x] Save last [LIMIT] versions
- [ ] [LIMIT] can setting in user profile
- [ ] [LIMIT] max is by Subscribe
- [x] Prompt Debugger run
- [x] Select AI Model
- [x] Input Prompt Content
- [x] Show Test Result
- [x] Need to User Credit
- [ ] Subscribe
- [ ] Free
- [ ] 20 Prompt Limit

View File

@ -94,6 +94,7 @@
"title": "AI Prompt Studio",
"myPrompts": "My Prompts",
"createPrompt": "Create Prompt",
"newPrompt": "New Prompt",
"promptName": "Prompt Name",
"promptContent": "Prompt Content",
"promptAlbum": "Album",
@ -108,7 +109,47 @@
"loadingStudio": "Loading Studio...",
"searchPrompts": "Search prompts...",
"filter": "Filter",
"clickRunTestToSee": "Click \"Run Test\" to see your prompt results"
"clickRunTestToSee": "Click \"Run Test\" to see your prompt results",
"sortBy": "Sort by",
"sortByName": "Name",
"sortByDate": "Date",
"sortByUpdated": "Updated",
"ascending": "Ascending",
"descending": "Descending",
"itemsPerPage": "Items per page",
"page": "Page",
"of": "of",
"total": "total",
"editPrompt": "Edit Prompt",
"deletePrompt": "Delete Prompt",
"duplicatePrompt": "Duplicate Prompt",
"confirmDelete": "Are you sure you want to delete this prompt?",
"deleteWarning": "This action cannot be undone.",
"promptDeleted": "Prompt deleted successfully",
"promptSaved": "Prompt saved successfully",
"promptCreated": "Prompt created successfully",
"promptUpdated": "Prompt updated successfully",
"enterPromptName": "Enter prompt name",
"enterPromptDescription": "Enter prompt description",
"promptDescription": "Description",
"createdAt": "Created",
"updatedAt": "Updated",
"lastUsed": "Last used",
"never": "Never",
"selectAll": "Select all",
"selectedItems": "selected items",
"bulkActions": "Bulk actions",
"bulkDelete": "Delete selected",
"tags": "Tags",
"addTag": "Add tag",
"removeTag": "Remove tag",
"noTags": "No tags",
"allTags": "All tags",
"backToList": "Back to list",
"debugPrompt": "Debug Prompt",
"promptEditor": "Prompt Editor",
"testResults": "Test Results",
"versionHistory": "Version History"
},
"home": {
"hero": {

View File

@ -63,7 +63,7 @@
"currentPassword": "当前密码",
"newPassword": "新密码",
"confirmNewPassword": "确认新密码",
"profilePicture": "头像",
"profilePicture": "个人头像",
"accessDenied": "访问被拒绝",
"pleaseSignIn": "请登录以访问您的个人资料",
"failedToLoadProfile": "加载个人资料失败",
@ -85,7 +85,7 @@
"charactersLimit": "个字符",
"noBioAdded": "暂未添加个人简介",
"enterNewPassword": "输入新密码",
"confirmNewPassword": "确认新密码",
"confirmNewPasswordLabel": "确认新密码",
"updatePassword": "更新密码",
"english": "English",
"chinese": "中文"
@ -94,6 +94,7 @@
"title": "AI 提示词工作室",
"myPrompts": "我的提示词",
"createPrompt": "创建提示词",
"newPrompt": "新建提示词",
"promptName": "提示词名称",
"promptContent": "提示词内容",
"promptAlbum": "专辑",
@ -108,7 +109,47 @@
"loadingStudio": "加载工作室中...",
"searchPrompts": "搜索提示词...",
"filter": "筛选",
"clickRunTestToSee": "点击\"运行测试\"查看您的提示词结果"
"clickRunTestToSee": "点击\"运行测试\"查看您的提示词结果",
"sortBy": "排序方式",
"sortByName": "名称",
"sortByDate": "日期",
"sortByUpdated": "更新时间",
"ascending": "升序",
"descending": "降序",
"itemsPerPage": "每页条数",
"page": "第",
"of": "页,共",
"total": "条",
"editPrompt": "编辑提示词",
"deletePrompt": "删除提示词",
"duplicatePrompt": "复制提示词",
"confirmDelete": "确定要删除这个提示词吗?",
"deleteWarning": "此操作无法撤销。",
"promptDeleted": "提示词删除成功",
"promptSaved": "提示词保存成功",
"promptCreated": "提示词创建成功",
"promptUpdated": "提示词更新成功",
"enterPromptName": "输入提示词名称",
"enterPromptDescription": "输入提示词描述",
"promptDescription": "描述",
"createdAt": "创建时间",
"updatedAt": "更新时间",
"lastUsed": "最后使用",
"never": "从未使用",
"selectAll": "全选",
"selectedItems": "已选择项目",
"bulkActions": "批量操作",
"bulkDelete": "删除选中项",
"tags": "标签",
"addTag": "添加标签",
"removeTag": "移除标签",
"noTags": "无标签",
"allTags": "所有标签",
"backToList": "返回列表",
"debugPrompt": "调试提示词",
"promptEditor": "提示词编辑器",
"testResults": "测试结果",
"versionHistory": "版本历史"
},
"home": {
"hero": {

View File

@ -3,10 +3,17 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"dev": "npm run db:generate && next dev",
"build": "npm run db:generate && next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:studio": "prisma studio",
"db:reset": "prisma migrate reset",
"db:seed": "prisma db seed",
"postinstall": "prisma generate"
},
"dependencies": {
"@prisma/client": "^6.12.0",

48
scripts/dev-check.js Normal file
View File

@ -0,0 +1,48 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
console.log('🔍 Checking development environment...\n');
// Check if .env file exists
const envPath = path.join(process.cwd(), '.env');
if (!fs.existsSync(envPath)) {
console.error('❌ .env file not found');
console.log('💡 Please create a .env file with the following variables:');
console.log(' DATABASE_URL="your-database-url"');
console.log(' NEXT_PUBLIC_SUPABASE_URL="your-supabase-url"');
console.log(' NEXT_PUBLIC_SUPABASE_ANON_KEY="your-supabase-anon-key"');
process.exit(1);
}
// Check if Prisma client is generated
const prismaClientPath = path.join(process.cwd(), 'node_modules', '.prisma', 'client');
if (!fs.existsSync(prismaClientPath)) {
console.log('📦 Prisma client not found, generating...');
try {
execSync('npx prisma generate', { stdio: 'inherit' });
console.log('✅ Prisma client generated successfully\n');
} catch (error) {
console.error('❌ Failed to generate Prisma client');
process.exit(1);
}
} else {
console.log('✅ Prisma client found\n');
}
// Check database connection
console.log('🗄️ Checking database connection...');
try {
execSync('npx prisma db push --accept-data-loss', { stdio: 'pipe' });
console.log('✅ Database connection successful\n');
} catch (error) {
console.error('❌ Database connection failed');
console.log('💡 Please check your DATABASE_URL in .env file');
console.log('💡 Make sure your database is running and accessible');
process.exit(1);
}
console.log('🎉 Development environment is ready!');
console.log('🚀 You can now run: npm run dev');

29
scripts/setup-db.sh Normal file
View File

@ -0,0 +1,29 @@
#!/bin/bash
# Database setup script for Prmbr
echo "🚀 Setting up Prmbr database..."
# Check if .env file exists
if [ ! -f .env ]; then
echo "❌ .env file not found. Please create one with DATABASE_URL"
exit 1
fi
# Generate Prisma client
echo "📦 Generating Prisma client..."
npx prisma generate
# Push schema to database (for development)
echo "🗄️ Pushing schema to database..."
npx prisma db push
# Optional: Open Prisma Studio
read -p "🎨 Do you want to open Prisma Studio? (y/n): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "🎨 Opening Prisma Studio..."
npx prisma studio
fi
echo "✅ Database setup complete!"
echo "💡 You can now run: npm run dev"

View File

@ -0,0 +1,61 @@
#!/bin/bash
echo "🚀 Setting up local development environment for Prmbr..."
# Check if Docker is installed
if ! command -v docker &> /dev/null; then
echo "❌ Docker is not installed. Please install Docker first."
echo "💡 Visit: https://docs.docker.com/get-docker/"
exit 1
fi
# Check if Docker Compose is available
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
echo "❌ Docker Compose is not available. Please install Docker Compose."
exit 1
fi
echo "🐳 Starting PostgreSQL database..."
# Start PostgreSQL with Docker Compose
if command -v docker-compose &> /dev/null; then
docker-compose up -d postgres
else
docker compose up -d postgres
fi
# Wait for database to be ready
echo "⏳ Waiting for database to be ready..."
sleep 10
# Test database connection
echo "🔍 Testing database connection..."
if node scripts/test-db-connection.js; then
echo "✅ Database connection successful!"
else
echo "⚠️ Database not ready yet, pushing schema..."
# Generate Prisma client
echo "📦 Generating Prisma client..."
npx prisma generate
# Push schema to database
echo "🗄️ Pushing schema to database..."
npx prisma db push --accept-data-loss
# Test connection again
echo "🔍 Testing database connection again..."
if node scripts/test-db-connection.js; then
echo "✅ Database setup complete!"
else
echo "❌ Database setup failed. Please check the logs."
exit 1
fi
fi
echo ""
echo "🎉 Local development environment is ready!"
echo "💡 You can now run: npm run dev"
echo ""
echo "📊 To view your database, run: npm run db:studio"
echo "🛑 To stop the database, run: docker-compose down"

View File

@ -0,0 +1,65 @@
#!/usr/bin/env node
const { PrismaClient } = require('@prisma/client');
async function testConnection() {
const prisma = new PrismaClient();
console.log('🔍 Testing database connection...\n');
try {
// Test basic connection
console.log('📡 Attempting to connect to database...');
await prisma.$connect();
console.log('✅ Database connection successful!\n');
// Test if tables exist
console.log('🗄️ Checking database schema...');
try {
const userCount = await prisma.user.count();
console.log(`✅ Users table exists (${userCount} records)`);
} catch (error) {
console.log('❌ Users table not found - need to run migrations');
}
try {
const promptCount = await prisma.prompt.count();
console.log(`✅ Prompts table exists (${promptCount} records)`);
} catch (error) {
console.log('❌ Prompts table not found - need to run migrations');
}
try {
const tagCount = await prisma.promptTag.count();
console.log(`✅ PromptTag table exists (${tagCount} records)`);
} catch (error) {
console.log('❌ PromptTag table not found - need to run migrations');
}
} catch (error) {
console.error('❌ Database connection failed:');
console.error('Error:', error.message);
if (error.message.includes("Can't reach database server")) {
console.log('\n💡 Possible solutions:');
console.log('1. Check if your Supabase project is active');
console.log('2. Verify your DATABASE_URL in .env file');
console.log('3. Check if your IP is allowed in Supabase settings');
console.log('4. Ensure your Supabase project is not paused');
}
if (error.message.includes('does not exist')) {
console.log('\n💡 Run database migrations:');
console.log(' npm run db:push');
}
process.exit(1);
} finally {
await prisma.$disconnect();
}
console.log('\n🎉 Database connection test completed successfully!');
}
testConnection();

View File

@ -0,0 +1,179 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
interface RouteParams {
params: Promise<{ id: string }>
}
// GET /api/prompts/[id] - 获取单个 prompt
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const { id } = await params
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
if (!userId) {
return NextResponse.json({ error: 'User ID is required' }, { status: 401 })
}
const prompt = await prisma.prompt.findFirst({
where: {
id,
userId
},
include: {
tags: true,
versions: {
orderBy: { version: 'desc' }
},
tests: {
orderBy: { createdAt: 'desc' },
take: 10
}
}
})
if (!prompt) {
return NextResponse.json({ error: 'Prompt not found' }, { status: 404 })
}
const promptWithMetadata = {
...prompt,
lastUsed: prompt.tests[0]?.createdAt || null,
currentVersion: prompt.versions[0]?.version || 1,
tags: prompt.tags.map(tag => tag.name),
usage: prompt.tests.length
}
return NextResponse.json(promptWithMetadata)
} catch (error) {
console.error('Error fetching prompt:', error)
return NextResponse.json(
{ error: 'Failed to fetch prompt' },
{ status: 500 }
)
}
}
// PUT /api/prompts/[id] - 更新 prompt
export async function PUT(request: NextRequest, { params }: RouteParams) {
try {
const { id } = await params
const body = await request.json()
const { name, description, content, tags, userId } = body
if (!userId) {
return NextResponse.json({ error: 'User ID is required' }, { status: 401 })
}
// 验证 prompt 是否存在且属于用户
const existingPrompt = await prisma.prompt.findFirst({
where: { id, userId },
include: { versions: { orderBy: { version: 'desc' }, take: 1 } }
})
if (!existingPrompt) {
return NextResponse.json({ error: 'Prompt not found' }, { status: 404 })
}
// 处理标签
let tagObjects = []
if (tags && tags.length > 0) {
for (const tagName of tags) {
const tag = await prisma.promptTag.upsert({
where: { name: tagName },
update: {},
create: { name: tagName }
})
tagObjects.push(tag)
}
}
// 检查内容是否有变化,如果有则创建新版本
const currentVersion = existingPrompt.versions[0]
let shouldCreateVersion = false
if (content && currentVersion && content !== currentVersion.content) {
shouldCreateVersion = true
}
// 更新 prompt
const updatedPrompt = await prisma.prompt.update({
where: { id },
data: {
name,
description,
content,
tags: {
set: [], // 先清空所有标签
connect: tagObjects.map(tag => ({ id: tag.id })) // 然后连接新标签
}
},
include: {
tags: true,
versions: {
orderBy: { version: 'desc' }
}
}
})
// 如果内容有变化,创建新版本
if (shouldCreateVersion && content) {
const nextVersion = (currentVersion?.version || 0) + 1
await prisma.promptVersion.create({
data: {
promptId: id,
version: nextVersion,
content,
changelog: `Updated to version ${nextVersion}`
}
})
}
return NextResponse.json(updatedPrompt)
} catch (error) {
console.error('Error updating prompt:', error)
return NextResponse.json(
{ error: 'Failed to update prompt' },
{ status: 500 }
)
}
}
// DELETE /api/prompts/[id] - 删除 prompt
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const { id } = await params
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
if (!userId) {
return NextResponse.json({ error: 'User ID is required' }, { status: 401 })
}
// 验证 prompt 是否存在且属于用户
const existingPrompt = await prisma.prompt.findFirst({
where: { id, userId }
})
if (!existingPrompt) {
return NextResponse.json({ error: 'Prompt not found' }, { status: 404 })
}
// 删除 prompt级联删除相关数据
await prisma.prompt.delete({
where: { id }
})
return NextResponse.json({ message: 'Prompt deleted successfully' })
} catch (error) {
console.error('Error deleting prompt:', error)
return NextResponse.json(
{ error: 'Failed to delete prompt' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,238 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
interface RouteParams {
params: Promise<{ id: string }>
}
// POST /api/prompts/[id]/test - 运行 prompt 测试
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const { id } = await params
const body = await request.json()
const {
content,
model = 'gpt-3.5-turbo',
temperature = 0.7,
maxTokens = 1000,
userId
} = body
if (!userId) {
return NextResponse.json({ error: 'User ID is required' }, { status: 401 })
}
if (!content) {
return NextResponse.json(
{ error: 'Prompt content is required' },
{ status: 400 }
)
}
// 验证 prompt 是否存在且属于用户
const prompt = await prisma.prompt.findFirst({
where: { id, userId }
})
if (!prompt) {
return NextResponse.json({ error: 'Prompt not found' }, { status: 404 })
}
// 检查用户积分(这里简化处理,实际应该从用户表获取)
const estimatedCost = calculateCost(model, content, maxTokens)
// 模拟 AI API 调用
const testResult = await runAITest({
content,
model,
temperature,
maxTokens
})
// 保存测试记录
const testRun = await prisma.promptTestRun.create({
data: {
promptId: id,
input: content,
output: testResult.output,
success: testResult.success,
error: testResult.error,
}
})
return NextResponse.json({
testRun,
result: testResult,
cost: estimatedCost,
model,
timestamp: new Date().toISOString()
})
} catch (error) {
console.error('Error running prompt test:', error)
// 保存失败的测试记录
try {
await prisma.promptTestRun.create({
data: {
promptId: id,
input: body.content || '',
output: null,
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
})
} catch (dbError) {
console.error('Error saving failed test run:', dbError)
}
return NextResponse.json(
{ error: 'Failed to run prompt test' },
{ status: 500 }
)
}
}
// 模拟 AI API 调用
async function runAITest({
content,
model,
temperature,
maxTokens
}: {
content: string
model: string
temperature: number
maxTokens: number
}) {
// 模拟 API 延迟
await new Promise(resolve => setTimeout(resolve, 1000 + Math.random() * 2000))
// 模拟不同的响应结果
const scenarios = [
{
success: true,
output: generateMockResponse(content, model),
error: null
},
{
success: false,
output: null,
error: 'Rate limit exceeded. Please try again later.'
},
{
success: false,
output: null,
error: 'Invalid API key or insufficient credits.'
}
]
// 90% 成功率
const isSuccess = Math.random() > 0.1
if (isSuccess) {
return scenarios[0]
} else {
return scenarios[Math.floor(Math.random() * (scenarios.length - 1)) + 1]
}
}
// 生成模拟响应
function generateMockResponse(content: string, model: string): string {
const responses = [
`Based on your prompt: "${content.substring(0, 50)}${content.length > 50 ? '...' : ''}"
Here's a comprehensive response generated by ${model}:
**Key Insights:**
1. Your prompt is well-structured and clear
2. The instructions provide good context
3. The expected output format is defined
📊 **Analysis:**
- Prompt clarity: Excellent (95%)
- Specificity: High (88%)
- Context provided: Good (82%)
- Output format: Well-defined (90%)
🎯 **Suggestions for improvement:**
- Consider adding more specific examples
- Define the target audience more clearly
- Specify the desired tone or style
💡 **Generated Content:**
[This would be the actual AI-generated content based on your prompt. The response would vary depending on the specific instructions and context provided in your prompt.]
📈 **Performance Metrics:**
- Response time: ${(Math.random() * 2 + 0.5).toFixed(2)}s
- Tokens used: ${Math.floor(Math.random() * 500 + 100)}
- Quality score: ${Math.floor(Math.random() * 20 + 80)}%`,
`Response from ${model}:
Your prompt has been processed successfully. Here are the results:
🔍 **Prompt Analysis:**
The input prompt demonstrates good structure with clear instructions. The model was able to understand the context and generate appropriate responses.
📝 **Generated Output:**
[Simulated AI response content would appear here. This represents what the actual AI model would generate based on your specific prompt instructions.]
**Performance Data:**
- Model: ${model}
- Processing time: ${(Math.random() * 3 + 1).toFixed(2)} seconds
- Input tokens: ${Math.floor(content.length / 4)}
- Output tokens: ${Math.floor(Math.random() * 400 + 200)}
- Total cost: $${(Math.random() * 0.05 + 0.01).toFixed(4)}
**Quality Indicators:**
- Relevance: High
- Coherence: Excellent
- Completeness: Good
- Accuracy: Very Good`,
`${model} Response:
Thank you for your prompt. I've analyzed your request and generated the following response:
🎯 **Understanding:** Your prompt asks for specific information/action, and I've interpreted it as follows...
📋 **Response:**
[This section would contain the actual generated content based on your prompt. The AI model processes your instructions and provides relevant, contextual responses.]
🔧 **Technical Details:**
- Model used: ${model}
- Temperature: ${Math.random().toFixed(2)}
- Max tokens: ${Math.floor(Math.random() * 1000 + 500)}
- Actual tokens: ${Math.floor(Math.random() * 800 + 200)}
📊 **Evaluation:**
- Prompt effectiveness: ${Math.floor(Math.random() * 15 + 85)}%
- Response quality: ${Math.floor(Math.random() * 10 + 90)}%
- Instruction following: ${Math.floor(Math.random() * 8 + 92)}%`
]
return responses[Math.floor(Math.random() * responses.length)]
}
// 计算估算成本
function calculateCost(model: string, content: string, maxTokens: number): number {
const inputTokens = Math.ceil(content.length / 4) // 粗略估算
const outputTokens = Math.min(maxTokens, 500) // 估算输出 tokens
// 不同模型的价格每1000 tokens
const pricing: Record<string, { input: number; output: number }> = {
'gpt-3.5-turbo': { input: 0.001, output: 0.002 },
'gpt-4': { input: 0.03, output: 0.06 },
'gpt-4-turbo': { input: 0.01, output: 0.03 },
'claude-3-sonnet': { input: 0.003, output: 0.015 },
'claude-3-haiku': { input: 0.00025, output: 0.00125 }
}
const modelPricing = pricing[model] || pricing['gpt-3.5-turbo']
const inputCost = (inputTokens / 1000) * modelPricing.input
const outputCost = (outputTokens / 1000) * modelPricing.output
return Number((inputCost + outputCost).toFixed(6))
}

View File

@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
interface RouteParams {
params: Promise<{ id: string }>
}
// GET /api/prompts/[id]/tests - 获取 prompt 的测试历史
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const { id } = await params
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '10')
if (!userId) {
return NextResponse.json({ error: 'User ID is required' }, { status: 401 })
}
// 验证 prompt 是否存在且属于用户
const prompt = await prisma.prompt.findFirst({
where: { id, userId }
})
if (!prompt) {
return NextResponse.json({ error: 'Prompt not found' }, { status: 404 })
}
const skip = (page - 1) * limit
// 获取测试历史
const [tests, total] = await Promise.all([
prisma.promptTestRun.findMany({
where: { promptId: id },
orderBy: { createdAt: 'desc' },
skip,
take: limit
}),
prisma.promptTestRun.count({
where: { promptId: id }
})
])
// 计算统计信息
const stats = await prisma.promptTestRun.aggregate({
where: { promptId: id },
_count: {
id: true
},
_sum: {
success: true
}
})
const successRate = stats._count.id > 0
? ((stats._sum.success || 0) / stats._count.id * 100).toFixed(1)
: '0'
return NextResponse.json({
tests,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
},
stats: {
totalRuns: stats._count.id,
successfulRuns: stats._sum.success || 0,
successRate: `${successRate}%`
}
})
} catch (error) {
console.error('Error fetching prompt tests:', error)
return NextResponse.json(
{ error: 'Failed to fetch prompt tests' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,119 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
interface RouteParams {
params: Promise<{ id: string; versionId: string }>
}
// GET /api/prompts/[id]/versions/[versionId] - 获取特定版本
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const { id, versionId } = await params
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
if (!userId) {
return NextResponse.json({ error: 'User ID is required' }, { status: 401 })
}
// 验证 prompt 是否存在且属于用户
const prompt = await prisma.prompt.findFirst({
where: { id, userId }
})
if (!prompt) {
return NextResponse.json({ error: 'Prompt not found' }, { status: 404 })
}
// 获取特定版本
const version = await prisma.promptVersion.findFirst({
where: {
id: versionId,
promptId: id
}
})
if (!version) {
return NextResponse.json({ error: 'Version not found' }, { status: 404 })
}
return NextResponse.json(version)
} catch (error) {
console.error('Error fetching prompt version:', error)
return NextResponse.json(
{ error: 'Failed to fetch prompt version' },
{ status: 500 }
)
}
}
// POST /api/prompts/[id]/versions/[versionId]/restore - 恢复到特定版本
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const { id, versionId } = await params
const body = await request.json()
const { userId } = body
if (!userId) {
return NextResponse.json({ error: 'User ID is required' }, { status: 401 })
}
// 验证 prompt 是否存在且属于用户
const prompt = await prisma.prompt.findFirst({
where: { id, userId },
include: {
versions: {
orderBy: { version: 'desc' },
take: 1
}
}
})
if (!prompt) {
return NextResponse.json({ error: 'Prompt not found' }, { status: 404 })
}
// 获取要恢复的版本
const versionToRestore = await prisma.promptVersion.findFirst({
where: {
id: versionId,
promptId: id
}
})
if (!versionToRestore) {
return NextResponse.json({ error: 'Version not found' }, { status: 404 })
}
// 创建新版本(基于要恢复的版本)
const nextVersion = (prompt.versions[0]?.version || 0) + 1
const newVersion = await prisma.promptVersion.create({
data: {
promptId: id,
version: nextVersion,
content: versionToRestore.content,
changelog: `Restored from version ${versionToRestore.version}`
}
})
// 更新 prompt 的内容
await prisma.prompt.update({
where: { id },
data: { content: versionToRestore.content }
})
return NextResponse.json({
message: 'Version restored successfully',
newVersion,
restoredFrom: versionToRestore
})
} catch (error) {
console.error('Error restoring prompt version:', error)
return NextResponse.json(
{ error: 'Failed to restore prompt version' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
interface RouteParams {
params: Promise<{ id: string }>
}
// GET /api/prompts/[id]/versions/compare - 比较两个版本
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const { id } = await params
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
const fromVersionId = searchParams.get('from')
const toVersionId = searchParams.get('to')
if (!userId) {
return NextResponse.json({ error: 'User ID is required' }, { status: 401 })
}
if (!fromVersionId || !toVersionId) {
return NextResponse.json(
{ error: 'Both from and to version IDs are required' },
{ status: 400 }
)
}
// 验证 prompt 是否存在且属于用户
const prompt = await prisma.prompt.findFirst({
where: { id, userId }
})
if (!prompt) {
return NextResponse.json({ error: 'Prompt not found' }, { status: 404 })
}
// 获取两个版本
const [fromVersion, toVersion] = await Promise.all([
prisma.promptVersion.findFirst({
where: { id: fromVersionId, promptId: id }
}),
prisma.promptVersion.findFirst({
where: { id: toVersionId, promptId: id }
})
])
if (!fromVersion || !toVersion) {
return NextResponse.json(
{ error: 'One or both versions not found' },
{ status: 404 }
)
}
// 简单的文本差异计算
const diff = calculateTextDiff(fromVersion.content, toVersion.content)
return NextResponse.json({
fromVersion,
toVersion,
diff
})
} catch (error) {
console.error('Error comparing prompt versions:', error)
return NextResponse.json(
{ error: 'Failed to compare prompt versions' },
{ status: 500 }
)
}
}
// 简单的文本差异计算函数
function calculateTextDiff(oldText: string, newText: string) {
const oldLines = oldText.split('\n')
const newLines = newText.split('\n')
const changes = []
const maxLines = Math.max(oldLines.length, newLines.length)
for (let i = 0; i < maxLines; i++) {
const oldLine = oldLines[i] || ''
const newLine = newLines[i] || ''
if (oldLine !== newLine) {
if (oldLine && newLine) {
changes.push({
type: 'modified',
lineNumber: i + 1,
oldContent: oldLine,
newContent: newLine
})
} else if (oldLine && !newLine) {
changes.push({
type: 'deleted',
lineNumber: i + 1,
oldContent: oldLine,
newContent: null
})
} else if (!oldLine && newLine) {
changes.push({
type: 'added',
lineNumber: i + 1,
oldContent: null,
newContent: newLine
})
}
}
}
return {
changes,
stats: {
additions: changes.filter(c => c.type === 'added').length,
deletions: changes.filter(c => c.type === 'deleted').length,
modifications: changes.filter(c => c.type === 'modified').length
}
}
}

View File

@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
interface RouteParams {
params: Promise<{ id: string }>
}
// GET /api/prompts/[id]/versions - 获取 prompt 的所有版本
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const { id } = await params
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
if (!userId) {
return NextResponse.json({ error: 'User ID is required' }, { status: 401 })
}
// 验证 prompt 是否存在且属于用户
const prompt = await prisma.prompt.findFirst({
where: { id, userId }
})
if (!prompt) {
return NextResponse.json({ error: 'Prompt not found' }, { status: 404 })
}
// 获取所有版本
const versions = await prisma.promptVersion.findMany({
where: { promptId: id },
orderBy: { version: 'desc' }
})
return NextResponse.json(versions)
} catch (error) {
console.error('Error fetching prompt versions:', error)
return NextResponse.json(
{ error: 'Failed to fetch prompt versions' },
{ status: 500 }
)
}
}
// POST /api/prompts/[id]/versions - 创建新版本
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const { id } = await params
const body = await request.json()
const { content, changelog, userId } = body
if (!userId) {
return NextResponse.json({ error: 'User ID is required' }, { status: 401 })
}
if (!content) {
return NextResponse.json(
{ error: 'Content is required' },
{ status: 400 }
)
}
// 验证 prompt 是否存在且属于用户
const prompt = await prisma.prompt.findFirst({
where: { id, userId },
include: {
versions: {
orderBy: { version: 'desc' },
take: 1
}
}
})
if (!prompt) {
return NextResponse.json({ error: 'Prompt not found' }, { status: 404 })
}
// 获取下一个版本号
const nextVersion = (prompt.versions[0]?.version || 0) + 1
// 创建新版本
const newVersion = await prisma.promptVersion.create({
data: {
promptId: id,
version: nextVersion,
content,
changelog: changelog || `Version ${nextVersion}`
}
})
// 更新 prompt 的内容为最新版本
await prisma.prompt.update({
where: { id },
data: { content }
})
return NextResponse.json(newVersion, { status: 201 })
} catch (error) {
console.error('Error creating prompt version:', error)
return NextResponse.json(
{ error: 'Failed to create prompt version' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,173 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
// POST /api/prompts/bulk - 批量操作 prompts
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { action, promptIds, userId } = body
if (!userId) {
return NextResponse.json({ error: 'User ID is required' }, { status: 401 })
}
if (!action || !promptIds || !Array.isArray(promptIds)) {
return NextResponse.json(
{ error: 'Action and promptIds array are required' },
{ status: 400 }
)
}
// 验证所有 prompts 都属于该用户
const existingPrompts = await prisma.prompt.findMany({
where: {
id: { in: promptIds },
userId
}
})
if (existingPrompts.length !== promptIds.length) {
return NextResponse.json(
{ error: 'Some prompts not found or access denied' },
{ status: 404 }
)
}
let result
switch (action) {
case 'delete':
result = await prisma.prompt.deleteMany({
where: {
id: { in: promptIds },
userId
}
})
break
case 'duplicate':
// 批量复制 prompts
const promptsToDuplicate = await prisma.prompt.findMany({
where: {
id: { in: promptIds },
userId
},
include: {
tags: true,
versions: {
orderBy: { version: 'desc' },
take: 1
}
}
})
const duplicatedPrompts = []
for (const prompt of promptsToDuplicate) {
const duplicated = await prisma.prompt.create({
data: {
name: `${prompt.name} (Copy)`,
description: prompt.description,
content: prompt.content,
userId,
tags: {
connect: prompt.tags.map(tag => ({ id: tag.id }))
}
},
include: {
tags: true
}
})
// 创建初始版本
await prisma.promptVersion.create({
data: {
promptId: duplicated.id,
version: 1,
content: prompt.content,
changelog: 'Duplicated from original prompt'
}
})
duplicatedPrompts.push(duplicated)
}
result = { duplicated: duplicatedPrompts }
break
case 'addTag':
const { tagName } = body
if (!tagName) {
return NextResponse.json(
{ error: 'Tag name is required for addTag action' },
{ status: 400 }
)
}
// 创建或获取标签
const tag = await prisma.promptTag.upsert({
where: { name: tagName },
update: {},
create: { name: tagName }
})
// 为所有选中的 prompts 添加标签
result = await prisma.prompt.updateMany({
where: {
id: { in: promptIds },
userId
},
data: {
tags: {
connect: { id: tag.id }
}
}
})
break
case 'removeTag':
const { tagNameToRemove } = body
if (!tagNameToRemove) {
return NextResponse.json(
{ error: 'Tag name is required for removeTag action' },
{ status: 400 }
)
}
const tagToRemove = await prisma.promptTag.findUnique({
where: { name: tagNameToRemove }
})
if (tagToRemove) {
result = await prisma.prompt.updateMany({
where: {
id: { in: promptIds },
userId
},
data: {
tags: {
disconnect: { id: tagToRemove.id }
}
}
})
}
break
default:
return NextResponse.json(
{ error: 'Invalid action' },
{ status: 400 }
)
}
return NextResponse.json({
message: `Bulk ${action} completed successfully`,
result
})
} catch (error) {
console.error('Error in bulk operation:', error)
return NextResponse.json(
{ error: 'Failed to perform bulk operation' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,162 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
// GET /api/prompts - 获取用户的 prompts 列表
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '12')
const search = searchParams.get('search') || ''
const tag = searchParams.get('tag') || ''
const sortBy = searchParams.get('sortBy') || 'updatedAt'
const sortOrder = searchParams.get('sortOrder') || 'desc'
const userId = searchParams.get('userId')
if (!userId) {
return NextResponse.json({ error: 'User ID is required' }, { status: 401 })
}
const skip = (page - 1) * limit
// 构建查询条件
const where: any = {
userId: userId,
}
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
{ content: { contains: search, mode: 'insensitive' } },
]
}
if (tag) {
where.tags = {
some: {
name: tag
}
}
}
// 构建排序条件
const orderBy: any = {}
orderBy[sortBy] = sortOrder
// 获取总数
const total = await prisma.prompt.count({ where })
// 获取 prompts
const prompts = await prisma.prompt.findMany({
where,
include: {
tags: true,
versions: {
orderBy: { version: 'desc' },
take: 1
},
tests: {
orderBy: { createdAt: 'desc' },
take: 1
}
},
orderBy,
skip,
take: limit,
})
// 计算最后使用时间
const promptsWithLastUsed = prompts.map(prompt => ({
...prompt,
lastUsed: prompt.tests[0]?.createdAt || null,
currentVersion: prompt.versions[0]?.version || 1,
tags: prompt.tags.map(tag => tag.name)
}))
return NextResponse.json({
prompts: promptsWithLastUsed,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
})
} catch (error) {
console.error('Error fetching prompts:', error)
return NextResponse.json(
{ error: 'Failed to fetch prompts' },
{ status: 500 }
)
}
}
// POST /api/prompts - 创建新的 prompt
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { name, description, content, tags, userId } = body
if (!userId) {
return NextResponse.json({ error: 'User ID is required' }, { status: 401 })
}
if (!name || !content) {
return NextResponse.json(
{ error: 'Name and content are required' },
{ status: 400 }
)
}
// 创建或获取标签
const tagObjects = []
if (tags && tags.length > 0) {
for (const tagName of tags) {
const tag = await prisma.promptTag.upsert({
where: { name: tagName },
update: {},
create: { name: tagName }
})
tagObjects.push(tag)
}
}
// 创建 prompt
const prompt = await prisma.prompt.create({
data: {
name,
description,
content,
userId,
tags: {
connect: tagObjects.map(tag => ({ id: tag.id }))
}
},
include: {
tags: true,
versions: true
}
})
// 创建初始版本
await prisma.promptVersion.create({
data: {
promptId: prompt.id,
version: 1,
content,
changelog: 'Initial version'
}
})
return NextResponse.json(prompt, { status: 201 })
} catch (error) {
console.error('Error creating prompt:', error)
return NextResponse.json(
{ error: 'Failed to create prompt' },
{ status: 500 }
)
}
}

81
src/app/api/tags/route.ts Normal file
View File

@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
// GET /api/tags - 获取所有标签
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
if (!userId) {
return NextResponse.json({ error: 'User ID is required' }, { status: 401 })
}
// 获取用户使用过的所有标签
const tags = await prisma.promptTag.findMany({
include: {
prompts: {
where: {
userId
},
select: {
id: true
}
}
}
})
// 只返回用户使用过的标签,并包含使用次数
const userTags = tags
.filter(tag => tag.prompts.length > 0)
.map(tag => ({
id: tag.id,
name: tag.name,
color: tag.color,
count: tag.prompts.length
}))
.sort((a, b) => b.count - a.count) // 按使用次数排序
return NextResponse.json(userTags)
} catch (error) {
console.error('Error fetching tags:', error)
return NextResponse.json(
{ error: 'Failed to fetch tags' },
{ status: 500 }
)
}
}
// POST /api/tags - 创建新标签
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { name, color } = body
if (!name) {
return NextResponse.json(
{ error: 'Tag name is required' },
{ status: 400 }
)
}
const tag = await prisma.promptTag.upsert({
where: { name },
update: { color: color || '#3B82F6' },
create: {
name,
color: color || '#3B82F6'
}
})
return NextResponse.json(tag, { status: 201 })
} catch (error) {
console.error('Error creating tag:', error)
return NextResponse.json(
{ error: 'Failed to create tag' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,507 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useAuth } from '@/hooks/useAuth'
import { useRouter } from 'next/navigation'
import { Header } from '@/components/layout/Header'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { LoadingSpinner } from '@/components/ui/loading-spinner'
import {
Play,
Save,
Copy,
Settings,
Folder,
Plus,
Search,
Filter,
MoreHorizontal,
Zap,
History,
ArrowLeft,
FileText,
Clock,
Tag,
User,
Calendar
} from 'lucide-react'
interface PromptPageProps {
params: Promise<{ id: string }>
}
interface PromptData {
id: string
name: string
description: string | null
content: string
tags: string[]
createdAt: string
updatedAt: string
lastUsed?: string | null
currentVersion?: number
usage?: number
}
export default function PromptPage({ params }: PromptPageProps) {
const [promptId, setPromptId] = useState<string>('')
useEffect(() => {
params.then(p => setPromptId(p.id))
}, [params])
const { user, loading } = useAuth()
const router = useRouter()
const t = useTranslations('studio')
const tCommon = useTranslations('common')
const [prompt, setPrompt] = useState<PromptData | null>(null)
const [promptContent, setPromptContent] = useState('')
const [promptTitle, setPromptTitle] = useState('')
const [testResult, setTestResult] = useState('')
const [isRunning, setIsRunning] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
if (!loading && !user) {
router.push('/signin')
} else if (user && promptId) {
fetchPrompt()
}
}, [user, loading, router, promptId])
const fetchPrompt = async () => {
if (!user || !promptId) return
try {
setIsLoading(true)
const response = await fetch(`/api/prompts/${promptId}?userId=${user.id}`)
if (response.ok) {
const data = await response.json()
setPrompt(data)
setPromptContent(data.content)
setPromptTitle(data.name)
} else {
router.push('/studio')
}
} catch (error) {
console.error('Error fetching prompt:', error)
router.push('/studio')
} finally {
setIsLoading(false)
}
}
const handleRunTest = async () => {
if (!promptContent.trim() || !user || !promptId) return
setIsRunning(true)
setTestResult('')
try {
const response = await fetch(`/api/prompts/${promptId}/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: promptContent,
userId: user.id
})
})
if (response.ok) {
const data = await response.json()
setTestResult(data.result.output || data.result.error || 'No output received')
} else {
setTestResult('Error: Failed to run prompt test. Please try again.')
}
} catch (error) {
console.error('Error running test:', error)
setTestResult('Error: Network error occurred. Please try again.')
} finally {
setIsRunning(false)
}
}
const handleSavePrompt = async () => {
if (!user || !promptId) return
setIsSaving(true)
try {
const response = await fetch(`/api/prompts/${promptId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: promptTitle,
content: promptContent,
userId: user.id
})
})
if (response.ok) {
const updatedPrompt = await response.json()
setPrompt(updatedPrompt)
// Show success message
}
} catch (error) {
console.error('Failed to save prompt:', error)
} finally {
setIsSaving(false)
}
}
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text)
// Show success message
}
if (loading || isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<LoadingSpinner size="lg" />
<p className="mt-4 text-muted-foreground">{t('loadingStudio')}</p>
</div>
</div>
)
}
if (!user || !prompt) {
return null
}
return (
<div className="min-h-screen bg-background">
<Header />
{/* Top Navigation */}
<div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="max-w-7xl mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Button
variant="ghost"
size="sm"
onClick={() => router.push('/studio')}
className="flex items-center space-x-2"
>
<ArrowLeft className="h-4 w-4" />
<span>{t('backToList')}</span>
</Button>
<div className="h-6 w-px bg-border" />
<div className="flex items-center space-x-3">
<div className="flex items-center space-x-2">
<FileText className="h-5 w-5 text-muted-foreground" />
<div>
<h1 className="font-semibold text-foreground">{prompt.name}</h1>
<p className="text-xs text-muted-foreground">Version {prompt.currentVersion || 1} {prompt.usage || 0} uses</p>
</div>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<Button variant="outline" size="sm" className="flex items-center space-x-1">
<History className="h-4 w-4" />
<span className="hidden sm:inline">{t('versionHistory')}</span>
</Button>
<Button variant="outline" size="sm" className="flex items-center space-x-1">
<Copy className="h-4 w-4" />
<span className="hidden sm:inline">Duplicate</span>
</Button>
<Button variant="outline" size="sm" className="flex items-center space-x-1">
<Settings className="h-4 w-4" />
<span className="hidden sm:inline">Settings</span>
</Button>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto p-4">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Sidebar - Prompt List */}
<div className="lg:col-span-1">
<div className="bg-card rounded-lg border border-border overflow-hidden">
<div className="p-4 border-b border-border">
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold text-foreground">{t('myPrompts')}</h2>
<Button size="sm" variant="outline" className="h-7 w-7 p-0">
<Plus className="h-3 w-3" />
</Button>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('searchPrompts')}
className="pl-9 h-8"
/>
</div>
</div>
{/* Filters */}
<div className="p-4 border-b border-border">
<div className="flex items-center justify-between text-sm">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs">
<Filter className="h-3 w-3 mr-1" />
{t('filter')}
</Button>
<span className="text-muted-foreground">3 prompts</span>
</div>
</div>
{/* Prompt List */}
<div className="max-h-96 overflow-y-auto">
{/* Active Prompt */}
<div className="p-3 border-b border-border bg-muted/50">
<div className="flex items-start space-x-3">
<div className="w-2 h-2 rounded-full bg-primary mt-2 flex-shrink-0"></div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground text-sm truncate">{prompt.name}</h3>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{prompt.description}</p>
<div className="flex items-center mt-2 space-x-2">
{prompt.tags.slice(0, 1).map((tag) => (
<span key={tag} className="inline-flex items-center px-1.5 py-0.5 text-xs font-medium bg-primary/10 text-primary rounded">
{tag}
</span>
))}
{prompt.tags.length > 1 && (
<span className="text-xs text-muted-foreground">+{prompt.tags.length - 1}</span>
)}
</div>
</div>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100">
<MoreHorizontal className="h-3 w-3" />
</Button>
</div>
</div>
{/* Other Prompts */}
<div className="p-3 border-b border-border hover:bg-muted/30 cursor-pointer group">
<div className="flex items-start space-x-3">
<div className="w-2 h-2 rounded-full bg-muted-foreground/30 mt-2 flex-shrink-0"></div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground text-sm truncate">Code Review Assistant</h3>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">Help review code for best practices and potential issues</p>
<div className="flex items-center mt-2 space-x-2">
<span className="inline-flex items-center px-1.5 py-0.5 text-xs font-medium bg-muted text-muted-foreground rounded">
development
</span>
</div>
</div>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100">
<MoreHorizontal className="h-3 w-3" />
</Button>
</div>
</div>
<div className="p-3 hover:bg-muted/30 cursor-pointer group">
<div className="flex items-start space-x-3">
<div className="w-2 h-2 rounded-full bg-muted-foreground/30 mt-2 flex-shrink-0"></div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground text-sm truncate">Content Summarizer</h3>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">Summarize long articles into key points</p>
<div className="flex items-center mt-2 space-x-2">
<span className="inline-flex items-center px-1.5 py-0.5 text-xs font-medium bg-muted text-muted-foreground rounded">
content
</span>
</div>
</div>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100">
<MoreHorizontal className="h-3 w-3" />
</Button>
</div>
</div>
</div>
</div>
{/* Prompt Info */}
<div className="mt-6 bg-card rounded-lg border border-border p-4">
<h3 className="font-semibold text-foreground mb-3">Prompt Info</h3>
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Created</span>
<span className="text-foreground">{new Date(prompt.createdAt).toLocaleDateString()}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Updated</span>
<span className="text-foreground">{new Date(prompt.updatedAt).toLocaleDateString()}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Last used</span>
<span className="text-foreground">{prompt.lastUsed ? new Date(prompt.lastUsed).toLocaleDateString() : 'Never'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Usage count</span>
<span className="text-foreground">{prompt.usage || 0}</span>
</div>
<div className="pt-2 border-t border-border">
<div className="flex flex-wrap gap-1">
{prompt.tags.map((tag: string) => (
<span
key={tag}
className="inline-flex items-center px-2 py-1 text-xs font-medium bg-muted text-muted-foreground rounded-full"
>
{tag}
</span>
))}
</div>
</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Action Bar */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Button
onClick={handleSavePrompt}
disabled={isSaving}
className="flex items-center space-x-2"
>
{isSaving ? (
<LoadingSpinner size="sm" className="mr-2" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
{tCommon('save')}
</Button>
<Button
variant="outline"
onClick={handleRunTest}
disabled={isRunning || !promptContent.trim()}
className="flex items-center space-x-2"
>
{isRunning ? (
<LoadingSpinner size="sm" className="mr-2" />
) : (
<Play className="h-4 w-4 mr-2" />
)}
{t('runTest')}
</Button>
<Button
variant="outline"
onClick={() => copyToClipboard(promptContent)}
className="flex items-center space-x-2"
>
<Copy className="h-4 w-4 mr-2" />
Copy
</Button>
</div>
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
<span>Auto-save enabled</span>
</div>
</div>
{/* Editor */}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Prompt Editor */}
<div className="bg-card rounded-lg border border-border">
<div className="p-4 border-b border-border">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-foreground">{t('promptEditor')}</h3>
<div className="flex items-center space-x-1 text-xs text-muted-foreground">
<span>{promptContent.length} characters</span>
</div>
</div>
</div>
<div className="p-4">
<div className="space-y-4">
<div>
<Label htmlFor="promptTitle" className="text-sm font-medium">
{t('promptName')}
</Label>
<Input
id="promptTitle"
value={promptTitle}
onChange={(e) => setPromptTitle(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="promptContent" className="text-sm font-medium">
{t('promptContent')}
</Label>
<Textarea
id="promptContent"
value={promptContent}
onChange={(e) => setPromptContent(e.target.value)}
className="mt-1 min-h-[400px] font-mono text-sm resize-none"
placeholder="Write your prompt here..."
/>
</div>
</div>
</div>
</div>
{/* Test Results */}
<div className="bg-card rounded-lg border border-border">
<div className="p-4 border-b border-border">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-foreground">{t('testResults')}</h3>
{testResult && (
<Button
variant="outline"
size="sm"
onClick={() => copyToClipboard(testResult)}
className="h-7"
>
<Copy className="h-3 w-3 mr-1" />
Copy
</Button>
)}
</div>
</div>
<div className="p-4">
<div className="bg-muted rounded-lg min-h-[400px] p-4">
{isRunning ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<LoadingSpinner size="lg" />
<p className="mt-4 text-muted-foreground">Running prompt test...</p>
<p className="text-xs text-muted-foreground mt-2">This may take a few seconds</p>
</div>
</div>
) : testResult ? (
<div className="space-y-4">
<pre className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
{testResult}
</pre>
</div>
) : (
<div className="flex items-center justify-center h-full text-center">
<div>
<Zap className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground mb-2">
{t('clickRunTestToSee')}
</p>
<p className="text-xs text-muted-foreground">
Test your prompt with AI models to see the output
</p>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

293
src/app/studio/new/page.tsx Normal file
View File

@ -0,0 +1,293 @@
'use client'
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { useAuth } from '@/hooks/useAuth'
import { useRouter } from 'next/navigation'
import { Header } from '@/components/layout/Header'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { LoadingSpinner } from '@/components/ui/loading-spinner'
import {
ArrowLeft,
Save,
Tag,
X
} from 'lucide-react'
export default function NewPromptPage() {
const { user, loading } = useAuth()
const router = useRouter()
const t = useTranslations('studio')
const tCommon = useTranslations('common')
const [promptName, setPromptName] = useState('')
const [promptDescription, setPromptDescription] = useState('')
const [promptContent, setPromptContent] = useState('')
const [tags, setTags] = useState<string[]>([])
const [newTag, setNewTag] = useState('')
const [isSaving, setIsSaving] = useState(false)
const [availableTags, setAvailableTags] = useState<string[]>([])
useEffect(() => {
if (!loading && !user) {
router.push('/signin')
} else if (user) {
fetchAvailableTags()
}
}, [user, loading, router])
const fetchAvailableTags = async () => {
if (!user) return
try {
const response = await fetch(`/api/tags?userId=${user.id}`)
if (response.ok) {
const tagsData = await response.json()
setAvailableTags(tagsData.map((tag: any) => tag.name))
}
} catch (error) {
console.error('Error fetching tags:', error)
}
}
const handleAddTag = () => {
if (newTag.trim() && !tags.includes(newTag.trim())) {
setTags(prev => [...prev, newTag.trim()])
setNewTag('')
}
}
const handleRemoveTag = (tagToRemove: string) => {
setTags(prev => prev.filter(tag => tag !== tagToRemove))
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddTag()
}
}
const handleSave = async () => {
if (!user || !promptName.trim() || !promptContent.trim()) {
return
}
setIsSaving(true)
try {
const response = await fetch('/api/prompts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: promptName.trim(),
description: promptDescription.trim() || null,
content: promptContent.trim(),
tags,
userId: user.id
})
})
if (response.ok) {
const newPrompt = await response.json()
router.push(`/studio/${newPrompt.id}`)
} else {
console.error('Failed to create prompt')
}
} catch (error) {
console.error('Failed to create prompt:', error)
} finally {
setIsSaving(false)
}
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<LoadingSpinner size="lg" />
<p className="mt-4 text-muted-foreground">{t('loadingStudio')}</p>
</div>
</div>
)
}
if (!user) {
return null
}
return (
<div className="min-h-screen">
<Header />
<div className="border-b">
<div className="max-w-4xl mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Button
variant="ghost"
size="sm"
onClick={() => router.back()}
className="flex items-center space-x-2"
>
<ArrowLeft className="h-4 w-4" />
<span>{t('backToList')}</span>
</Button>
<div>
<h1 className="text-2xl font-bold text-foreground">{t('newPrompt')}</h1>
<p className="text-sm text-muted-foreground">Create a new AI prompt</p>
</div>
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => router.back()}
disabled={isSaving}
>
{tCommon('cancel')}
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={isSaving || !promptName.trim()}
className="flex items-center space-x-2"
>
{isSaving ? (
<LoadingSpinner size="sm" className="mr-2" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
{tCommon('save')}
</Button>
</div>
</div>
</div>
</div>
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="space-y-6">
{/* Basic Information */}
<div className="bg-card rounded-lg border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-4">Basic Information</h2>
<div className="space-y-4">
<div>
<Label htmlFor="promptName">{t('promptName')} *</Label>
<Input
id="promptName"
value={promptName}
onChange={(e) => setPromptName(e.target.value)}
placeholder={t('enterPromptName')}
className="mt-1"
required
/>
</div>
<div>
<Label htmlFor="promptDescription">{t('promptDescription')}</Label>
<Input
id="promptDescription"
value={promptDescription}
onChange={(e) => setPromptDescription(e.target.value)}
placeholder={t('enterPromptDescription')}
className="mt-1"
/>
</div>
</div>
</div>
{/* Tags */}
<div className="bg-card rounded-lg border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-4">{t('tags')}</h2>
<div className="space-y-4">
<div className="flex gap-2">
<Input
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('addTag')}
className="flex-1"
/>
<Button
type="button"
onClick={handleAddTag}
disabled={!newTag.trim() || tags.includes(newTag.trim())}
>
<Tag className="h-4 w-4 mr-2" />
{t('addTag')}
</Button>
</div>
{tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center px-3 py-1 text-sm font-medium bg-primary text-primary-foreground rounded-full"
>
{tag}
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="ml-2 hover:text-primary-foreground/80"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
{/* Available Tags */}
{availableTags.length > 0 && (
<div className="mt-4">
<p className="text-sm text-muted-foreground mb-2">Quick add from existing tags:</p>
<div className="flex flex-wrap gap-2">
{availableTags
.filter(tag => !tags.includes(tag))
.slice(0, 8)
.map((tag) => (
<button
key={tag}
type="button"
onClick={() => setTags(prev => [...prev, tag])}
className="inline-flex items-center px-2 py-1 text-xs bg-muted text-muted-foreground rounded hover:bg-muted/80 transition-colors"
>
{tag}
</button>
))}
</div>
</div>
)}
</div>
</div>
{/* Prompt Content */}
<div className="bg-card rounded-lg border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-4">{t('promptContent')}</h2>
<div>
<Label htmlFor="promptContent">Content</Label>
<Textarea
id="promptContent"
value={promptContent}
onChange={(e) => setPromptContent(e.target.value)}
placeholder="Enter your prompt here..."
className="mt-1 min-h-[300px] font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-2">
Write your prompt instructions here. You can use variables like {'{variable}'} for dynamic content.
</p>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -3,44 +3,268 @@
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useAuth } from '@/hooks/useAuth'
import { useRouter } from 'next/navigation'
import { Header } from '@/components/layout/Header'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { LoadingSpinner } from '@/components/ui/loading-spinner'
import {
Play,
Save,
Copy,
Settings,
Folder,
import { EditPromptModal } from '@/components/studio/EditPromptModal'
import {
Plus,
Search,
Filter,
MoreHorizontal,
Zap,
History
Edit,
Trash2,
Play,
FileText,
Calendar,
ChevronDown,
Grid,
List
} from 'lucide-react'
interface Prompt {
id: string
name: string
description: string | null
content: string
tags: string[]
createdAt: string
updatedAt: string
lastUsed?: string | null
currentVersion?: number
usage?: number
}
interface PaginationInfo {
page: number
limit: number
total: number
totalPages: number
}
type SortField = 'name' | 'createdAt' | 'updatedAt' | 'lastUsed'
type SortOrder = 'asc' | 'desc'
type ViewMode = 'grid' | 'list'
export default function StudioPage() {
const { user, loading } = useAuth()
const router = useRouter()
const t = useTranslations('studio')
const tCommon = useTranslations('common')
const [promptContent, setPromptContent] = useState('')
const [promptTitle, setPromptTitle] = useState('Untitled Prompt')
const [testResult, setTestResult] = useState('')
const [isRunning, setIsRunning] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [prompts, setPrompts] = useState<Prompt[]>([])
const [filteredPrompts, setFilteredPrompts] = useState<Prompt[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [selectedTag, setSelectedTag] = useState<string>('')
const [sortField, setSortField] = useState<SortField>('updatedAt')
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
const [viewMode, setViewMode] = useState<ViewMode>('grid')
const [selectedPrompts, setSelectedPrompts] = useState<string[]>([])
const [isLoading, setIsLoading] = useState(true)
const [allTags, setAllTags] = useState<string[]>([])
// Edit Modal
const [editingPrompt, setEditingPrompt] = useState<Prompt | null>(null)
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
// Pagination
const [currentPage, setCurrentPage] = useState(1)
const [itemsPerPage, setItemsPerPage] = useState(12)
const [pagination, setPagination] = useState<PaginationInfo>({
page: 1,
limit: 12,
total: 0,
totalPages: 0
})
useEffect(() => {
// Redirect to sign in if not authenticated
if (!loading && !user) {
window.location.href = '/signin'
router.push('/signin')
} else if (user) {
fetchPrompts()
fetchTags()
}
}, [user, loading])
}, [user, loading, router])
if (loading) {
// Fetch prompts from API
const fetchPrompts = async () => {
if (!user) return
try {
setIsLoading(true)
const params = new URLSearchParams({
userId: user.id,
page: currentPage.toString(),
limit: itemsPerPage.toString(),
search: searchQuery,
tag: selectedTag,
sortBy: sortField,
sortOrder: sortOrder
})
const response = await fetch(`/api/prompts?${params}`)
if (response.ok) {
const data = await response.json()
setPrompts(data.prompts)
setPagination(data.pagination)
}
} catch (error) {
console.error('Error fetching prompts:', error)
} finally {
setIsLoading(false)
}
}
// Fetch tags from API
const fetchTags = async () => {
if (!user) return
try {
const response = await fetch(`/api/tags?userId=${user.id}`)
if (response.ok) {
const tags = await response.json()
setAllTags(tags.map((tag: any) => tag.name))
}
} catch (error) {
console.error('Error fetching tags:', error)
}
}
// Refetch when filters change
useEffect(() => {
if (user) {
fetchPrompts()
}
}, [currentPage, itemsPerPage, searchQuery, selectedTag, sortField, sortOrder, user])
// Since filtering and sorting is now done on the server,
// we just use the prompts directly
useEffect(() => {
setFilteredPrompts(prompts)
}, [prompts])
const handleCreatePrompt = () => {
router.push('/studio/new')
}
const handleEditPrompt = (id: string) => {
router.push(`/studio/${id}`)
}
const handleQuickEdit = (prompt: Prompt) => {
setEditingPrompt(prompt)
setIsEditModalOpen(true)
}
const handleEditModalClose = () => {
setIsEditModalOpen(false)
setEditingPrompt(null)
}
const handleEditModalSave = (updatedPrompt: Prompt) => {
// Update the prompt in the local state
setPrompts(prev => prev.map(p => p.id === updatedPrompt.id ? updatedPrompt : p))
setFilteredPrompts(prev => prev.map(p => p.id === updatedPrompt.id ? updatedPrompt : p))
}
const handleDeletePrompt = async (id: string) => {
if (!user || !confirm(t('confirmDelete'))) return
try {
const response = await fetch(`/api/prompts/${id}?userId=${user.id}`, {
method: 'DELETE'
})
if (response.ok) {
await fetchPrompts() // Refresh the list
setSelectedPrompts(prev => prev.filter(pId => pId !== id))
}
} catch (error) {
console.error('Error deleting prompt:', error)
}
}
const handleDuplicatePrompt = async (prompt: Prompt) => {
if (!user) return
try {
const response = await fetch('/api/prompts/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'duplicate',
promptIds: [prompt.id],
userId: user.id
})
})
if (response.ok) {
await fetchPrompts() // Refresh the list
}
} catch (error) {
console.error('Error duplicating prompt:', error)
}
}
const handleBulkDelete = async () => {
if (!user || selectedPrompts.length === 0) return
if (!confirm(`Delete ${selectedPrompts.length} selected prompts?`)) return
try {
const response = await fetch('/api/prompts/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'delete',
promptIds: selectedPrompts,
userId: user.id
})
})
if (response.ok) {
await fetchPrompts() // Refresh the list
setSelectedPrompts([])
}
} catch (error) {
console.error('Error bulk deleting prompts:', error)
}
}
const handleSelectPrompt = (id: string) => {
setSelectedPrompts(prev =>
prev.includes(id)
? prev.filter(pId => pId !== id)
: [...prev, id]
)
}
const handleSelectAll = () => {
const currentPagePrompts = filteredPrompts.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
)
if (selectedPrompts.length === currentPagePrompts.length) {
setSelectedPrompts([])
} else {
setSelectedPrompts(currentPagePrompts.map(p => p.id))
}
}
const formatDate = (dateString: string) => {
return new Intl.DateTimeFormat('default', {
year: 'numeric',
month: 'short',
day: 'numeric'
}).format(new Date(dateString))
}
// Use server-side pagination
const currentPrompts = filteredPrompts
if (loading || isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
@ -52,275 +276,364 @@ export default function StudioPage() {
}
if (!user) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-foreground mb-4">Authentication Required</h1>
<p className="text-muted-foreground mb-6">Please sign in to access the Prompt Studio</p>
<Button onClick={() => window.location.href = '/signin'}>
Sign In
</Button>
</div>
</div>
)
}
const handleRunPrompt = async () => {
if (!promptContent.trim()) return
setIsRunning(true)
setTestResult('')
try {
// Simulate API call for now
await new Promise(resolve => setTimeout(resolve, 2000))
// Mock response
setTestResult(`Test result for: "${promptContent.substring(0, 50)}${promptContent.length > 50 ? '...' : ''}"\n\nThis is a simulated response from the AI model. In a real implementation, this would be the actual output from your chosen AI provider (OpenAI, Anthropic, etc.).\n\nResponse quality: Good\nToken usage: 150 tokens\nLatency: 1.2s`)
} catch {
setTestResult('Error: Failed to run prompt. Please try again.')
} finally {
setIsRunning(false)
}
}
const handleSavePrompt = async () => {
setIsSaving(true)
try {
// Simulate save operation
await new Promise(resolve => setTimeout(resolve, 1000))
// In real implementation, save to database via API
console.log('Saving prompt:', { title: promptTitle, content: promptContent })
// Show success feedback (you could add a toast notification here)
} catch (error) {
console.error('Failed to save prompt:', error)
} finally {
setIsSaving(false)
}
}
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text)
// You could add a toast notification here
return null
}
return (
<div className="min-h-screen">
<Header />
<div className="flex h-[calc(100vh-4rem)]">
{/* Sidebar */}
<div className="w-80 border-r bg-muted/30 p-4 overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-foreground">Prompt Studio</h2>
<Button size="sm" variant="outline">
<Plus className="h-4 w-4 mr-2" />
New
<div className="max-w-7xl mx-auto px-4 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-3xl font-bold text-foreground">{t('title')}</h1>
<p className="text-muted-foreground mt-1">{t('myPrompts')}</p>
</div>
<Button onClick={handleCreatePrompt} className="flex items-center space-x-2">
<Plus className="h-4 w-4" />
<span>{t('createPrompt')}</span>
</Button>
</div>
{/* Search */}
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('searchPrompts')}
className="pl-10"
/>
</div>
{/* Filters */}
<div className="flex items-center gap-2 mb-6">
<Button size="sm" variant="outline">
<Filter className="h-4 w-4 mr-1" />
{t('filter')}
</Button>
<Button size="sm" variant="outline">
<Folder className="h-4 w-4 mr-1" />
All
</Button>
</div>
{/* Prompt List */}
<div className="space-y-2">
<div className="p-3 rounded-lg bg-accent border cursor-pointer hover:bg-accent/80">
<h3 className="font-medium text-sm text-foreground truncate">Welcome Message Generator</h3>
<p className="text-xs text-muted-foreground mt-1">Generate personalized welcome messages...</p>
<div className="flex items-center gap-2 mt-2">
<span className="text-xs text-muted-foreground">2 hours ago</span>
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded">v1.2</span>
</div>
</div>
<div className="p-3 rounded-lg border cursor-pointer hover:bg-accent/50">
<h3 className="font-medium text-sm text-foreground truncate">Code Review Assistant</h3>
<p className="text-xs text-muted-foreground mt-1">Help review code and suggest improvements...</p>
<div className="flex items-center gap-2 mt-2">
<span className="text-xs text-muted-foreground">1 day ago</span>
<span className="text-xs bg-secondary text-secondary-foreground px-2 py-0.5 rounded">v2.1</span>
</div>
</div>
<div className="p-3 rounded-lg border cursor-pointer hover:bg-accent/50">
<h3 className="font-medium text-sm text-foreground truncate">Email Marketing Copy</h3>
<p className="text-xs text-muted-foreground mt-1">Create compelling email subject lines...</p>
<div className="flex items-center gap-2 mt-2">
<span className="text-xs text-muted-foreground">3 days ago</span>
<span className="text-xs bg-secondary text-secondary-foreground px-2 py-0.5 rounded">v1.0</span>
</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col">
{/* Toolbar */}
<div className="border-b p-4 flex items-center justify-between bg-background">
<div className="flex items-center gap-4">
{/* Search and Filters */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
{/* Search */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={promptTitle}
onChange={(e) => setPromptTitle(e.target.value)}
className="text-lg font-semibold border-none p-0 h-auto bg-transparent focus-visible:ring-0"
placeholder="Prompt title..."
placeholder={t('searchPrompts')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(promptContent)}
disabled={!promptContent}
{/* Tag Filter */}
<div className="relative">
<select
value={selectedTag}
onChange={(e) => setSelectedTag(e.target.value)}
className="appearance-none bg-background border border-input rounded-md px-3 py-2 pr-8 min-w-[120px] focus:outline-none focus:ring-2 focus:ring-ring"
>
<Copy className="h-4 w-4 mr-2" />
Copy
</Button>
<Button
size="sm"
variant="outline"
onClick={handleSavePrompt}
disabled={!promptContent || isSaving}
<option value="">{t('allTags')}</option>
{allTags.map(tag => (
<option key={tag} value={tag}>{tag}</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
</div>
{/* Sort */}
<div className="relative">
<select
value={`${sortField}-${sortOrder}`}
onChange={(e) => {
const [field, order] = e.target.value.split('-')
setSortField(field as SortField)
setSortOrder(order as SortOrder)
}}
className="appearance-none bg-background border border-input rounded-md px-3 py-2 pr-8 min-w-[140px] focus:outline-none focus:ring-2 focus:ring-ring"
>
{isSaving ? (
<LoadingSpinner size="sm" className="mr-2" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
{tCommon('save')}
</Button>
<option value="updatedAt-desc">{t('sortByUpdated')} ({t('descending')})</option>
<option value="updatedAt-asc">{t('sortByUpdated')} ({t('ascending')})</option>
<option value="createdAt-desc">{t('sortByDate')} ({t('descending')})</option>
<option value="createdAt-asc">{t('sortByDate')} ({t('ascending')})</option>
<option value="name-asc">{t('sortByName')} ({t('ascending')})</option>
<option value="name-desc">{t('sortByName')} ({t('descending')})</option>
<option value="lastUsed-desc">{t('lastUsed')} ({t('descending')})</option>
<option value="lastUsed-asc">{t('lastUsed')} ({t('ascending')})</option>
</select>
<ChevronDown className="absolute right-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
</div>
{/* View Mode Toggle */}
<div className="flex items-center border border-input rounded-md">
<Button
variant={viewMode === 'grid' ? 'default' : 'ghost'}
size="sm"
onClick={handleRunPrompt}
disabled={!promptContent || isRunning}
onClick={() => setViewMode('grid')}
className="rounded-r-none"
>
{isRunning ? (
<LoadingSpinner size="sm" className="mr-2" />
) : (
<Play className="h-4 w-4 mr-2" />
)}
{t('runTest')}
<Grid className="h-4 w-4" />
</Button>
<Button size="sm" variant="outline">
<Settings className="h-4 w-4" />
<Button
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('list')}
className="rounded-l-none"
>
<List className="h-4 w-4" />
</Button>
</div>
</div>
{/* Editor */}
<div className="flex-1 flex">
{/* Prompt Editor */}
<div className="flex-1 p-6">
<div className="h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<Label htmlFor="prompt-editor" className="text-base font-medium">
Prompt Content
</Label>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost">
<History className="h-4 w-4 mr-1" />
History
</Button>
<Button size="sm" variant="ghost">
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
</div>
<Textarea
id="prompt-editor"
value={promptContent}
onChange={(e) => setPromptContent(e.target.value)}
placeholder="Enter your prompt here...
For example:
You are a helpful assistant that helps users write professional emails.
Given the following context:
- Recipient: {{recipient}}
- Purpose: {{purpose}}
- Tone: {{tone}}
Please write a professional email that..."
className="flex-1 min-h-[400px] resize-none font-mono text-sm"
/>
<div className="flex items-center justify-between mt-4 text-sm text-muted-foreground">
<span>{promptContent.length} characters</span>
<span>~{Math.ceil(promptContent.length / 4)} tokens</span>
</div>
{/* Bulk Actions */}
{selectedPrompts.length > 0 && (
<div className="flex items-center justify-between mb-4 p-3 bg-muted rounded-lg">
<span className="text-sm text-muted-foreground">
{selectedPrompts.length} {t('selectedItems')}
</span>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={handleBulkDelete}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4 mr-1" />
{t('bulkDelete')}
</Button>
</div>
</div>
{/* Results Panel */}
<div className="w-1/2 border-l p-6">
<div className="h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<Label className="text-base font-medium">Test Results</Label>
{testResult && (
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(testResult)}
>
<Copy className="h-4 w-4 mr-1" />
Copy
</Button>
)}
</div>
<div className="flex-1 bg-muted/30 rounded-lg p-4 overflow-y-auto">
{isRunning ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<LoadingSpinner size="lg" />
<p className="mt-4 text-muted-foreground">Running your prompt...</p>
</div>
</div>
) : testResult ? (
<pre className="whitespace-pre-wrap text-sm text-foreground font-mono">
{testResult}
</pre>
) : (
<div className="flex items-center justify-center h-full text-center">
<div>
<Zap className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">
{t('clickRunTestToSee')}
)}
</div>
{/* Content */}
{filteredPrompts.length === 0 ? (
<div className="text-center py-12">
<FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold text-foreground mb-2">{t('noPrompts')}</h3>
<p className="text-muted-foreground mb-4">{t('createFirstPrompt')}</p>
<Button onClick={handleCreatePrompt}>
<Plus className="h-4 w-4 mr-2" />
{t('createPrompt')}
</Button>
</div>
) : (
<>
{/* Prompts Grid/List */}
{viewMode === 'grid' ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 mb-8">
{currentPrompts.map((prompt) => (
<div
key={prompt.id}
className={`bg-card rounded-lg border border-border p-4 hover:shadow-md transition-shadow cursor-pointer ${
selectedPrompts.includes(prompt.id) ? 'ring-2 ring-primary' : ''
}`}
onClick={() => handleSelectPrompt(prompt.id)}
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h3 className="font-semibold text-foreground truncate mb-1">
{prompt.name}
</h3>
<p className="text-sm text-muted-foreground line-clamp-2">
{prompt.description}
</p>
</div>
<div className="relative ml-2">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation()
handleQuickEdit(prompt)
}}
>
<Edit className="h-4 w-4" />
</Button>
</div>
</div>
)}
{/* Tags */}
<div className="flex flex-wrap gap-1 mb-3">
{prompt.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="inline-flex items-center px-2 py-1 text-xs font-medium bg-muted text-muted-foreground rounded-full"
>
{tag}
</span>
))}
{prompt.tags.length > 3 && (
<span className="text-xs text-muted-foreground">
+{prompt.tags.length - 3}
</span>
)}
</div>
{/* Metadata */}
<div className="text-xs text-muted-foreground space-y-1">
<div className="flex items-center">
<Calendar className="h-3 w-3 mr-1" />
<span className="hidden sm:inline">{t('updatedAt')}: </span>
{formatDate(prompt.updatedAt)}
</div>
{prompt.lastUsed && (
<div>
<span className="hidden sm:inline">{t('lastUsed')}: </span>
<span className="sm:hidden">Used: </span>
{formatDate(prompt.lastUsed)}
</div>
)}
</div>
{/* Action Buttons */}
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
handleEditPrompt(prompt.id)
}}
className="flex items-center space-x-1"
>
<Edit className="h-3 w-3" />
<span className="text-xs">{tCommon('edit')}</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
handleEditPrompt(prompt.id)
}}
className="flex items-center space-x-1"
>
<Play className="h-3 w-3" />
<span className="text-xs">{t('debugPrompt')}</span>
</Button>
</div>
</div>
))}
</div>
) : (
<div className="space-y-4 mb-8">
{/* List Header */}
<div className="flex items-center p-3 bg-muted rounded-lg text-sm font-medium text-muted-foreground">
<div className="w-8">
<input
type="checkbox"
checked={selectedPrompts.length === currentPrompts.length && currentPrompts.length > 0}
onChange={handleSelectAll}
className="rounded"
/>
</div>
<div className="flex-1">{t('promptName')}</div>
<div className="w-32">{t('updatedAt')}</div>
<div className="w-32">{t('lastUsed')}</div>
<div className="w-20">{t('tags')}</div>
<div className="w-16"></div>
</div>
{/* List Items */}
{currentPrompts.map((prompt) => (
<div
key={prompt.id}
className={`flex items-center p-3 bg-card rounded-lg border border-border hover:shadow-sm transition-shadow ${
selectedPrompts.includes(prompt.id) ? 'ring-2 ring-primary' : ''
}`}
>
<div className="w-8">
<input
type="checkbox"
checked={selectedPrompts.includes(prompt.id)}
onChange={() => handleSelectPrompt(prompt.id)}
className="rounded"
/>
</div>
<div className="flex-1">
<div className="font-medium text-foreground">{prompt.name}</div>
<div className="text-sm text-muted-foreground truncate">
{prompt.description}
</div>
</div>
<div className="w-32 text-sm text-muted-foreground">
{formatDate(prompt.updatedAt)}
</div>
<div className="w-32 text-sm text-muted-foreground">
{prompt.lastUsed ? formatDate(prompt.lastUsed) : t('never')}
</div>
<div className="w-20">
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-muted text-muted-foreground rounded-full">
{prompt.tags.length}
</span>
</div>
<div className="w-16 flex items-center justify-end space-x-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleQuickEdit(prompt)}
className="h-8 w-8 p-0"
title="Quick Edit"
>
<Edit className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditPrompt(prompt.id)}
className="h-8 w-8 p-0"
title="Full Edit"
>
<MoreHorizontal className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
)}
{/* Pagination */}
{pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{t('page')} {pagination.page} {t('of')} {pagination.totalPages} {t('total')} {pagination.total}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
disabled={pagination.page === 1}
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
>
Previous
</Button>
{Array.from({ length: Math.min(5, pagination.totalPages) }, (_, i) => {
const page = i + 1
return (
<Button
key={page}
variant={pagination.page === page ? 'default' : 'outline'}
size="sm"
onClick={() => setCurrentPage(page)}
>
{page}
</Button>
)
})}
<Button
variant="outline"
size="sm"
disabled={pagination.page === pagination.totalPages}
onClick={() => setCurrentPage(prev => Math.min(pagination.totalPages, prev + 1))}
>
Next
</Button>
</div>
</div>
</div>
</div>
</div>
)}
</>
)}
</div>
{/* Edit Modal */}
{editingPrompt && (
<EditPromptModal
prompt={editingPrompt}
isOpen={isEditModalOpen}
onClose={handleEditModalClose}
onSave={handleEditModalSave}
userId={user?.id || ''}
/>
)}
</div>
)
}

View File

@ -0,0 +1,281 @@
'use client'
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { LoadingSpinner } from '@/components/ui/loading-spinner'
import {
X,
Save,
Tag,
Plus,
Trash2
} from 'lucide-react'
interface Prompt {
id: string
name: string
description: string | null
content: string
tags: string[]
createdAt: string
updatedAt: string
}
interface EditPromptModalProps {
prompt: Prompt
isOpen: boolean
onClose: () => void
onSave: (updatedPrompt: Prompt) => void
userId: string
}
export function EditPromptModal({
prompt,
isOpen,
onClose,
onSave,
userId
}: EditPromptModalProps) {
const t = useTranslations('studio')
const tCommon = useTranslations('common')
const [name, setName] = useState(prompt.name)
const [description, setDescription] = useState(prompt.description || '')
const [tags, setTags] = useState<string[]>(prompt.tags)
const [newTag, setNewTag] = useState('')
const [isSaving, setIsSaving] = useState(false)
const [availableTags, setAvailableTags] = useState<string[]>([])
useEffect(() => {
if (isOpen) {
setName(prompt.name)
setDescription(prompt.description || '')
setTags(prompt.tags)
fetchAvailableTags()
}
}, [isOpen, prompt])
const fetchAvailableTags = async () => {
try {
const response = await fetch(`/api/tags?userId=${userId}`)
if (response.ok) {
const tagsData = await response.json()
setAvailableTags(tagsData.map((tag: any) => tag.name))
}
} catch (error) {
console.error('Error fetching tags:', error)
}
}
const handleAddTag = () => {
if (newTag.trim() && !tags.includes(newTag.trim())) {
setTags(prev => [...prev, newTag.trim()])
setNewTag('')
}
}
const handleRemoveTag = (tagToRemove: string) => {
setTags(prev => prev.filter(tag => tag !== tagToRemove))
}
const handleSave = async () => {
if (!name.trim()) return
setIsSaving(true)
try {
const response = await fetch(`/api/prompts/${prompt.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.trim(),
description: description.trim() || null,
tags,
userId
})
})
if (response.ok) {
const updatedPrompt = await response.json()
onSave(updatedPrompt)
onClose()
}
} catch (error) {
console.error('Error saving prompt:', error)
} finally {
setIsSaving(false)
}
}
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleSave()
} else if (e.key === 'Escape') {
onClose()
}
}
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-card border border-border rounded-lg shadow-lg w-full max-w-md mx-4 max-h-[90vh] overflow-hidden sm:mx-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
<h2 className="text-lg font-semibold text-foreground">
{t('editPrompt')}
</h2>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Content */}
<div className="p-6 space-y-4 overflow-y-auto max-h-[calc(90vh-140px)]">
{/* Name */}
<div>
<Label htmlFor="prompt-name" className="text-sm font-medium">
{t('promptName')} *
</Label>
<Input
id="prompt-name"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={handleKeyPress}
placeholder={t('enterPromptName')}
className="mt-1"
autoFocus
/>
</div>
{/* Description */}
<div>
<Label htmlFor="prompt-description" className="text-sm font-medium">
{t('promptDescription')}
</Label>
<Textarea
id="prompt-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
onKeyDown={handleKeyPress}
placeholder={t('enterPromptDescription')}
className="mt-1 min-h-[80px] resize-none"
/>
</div>
{/* Tags */}
<div>
<Label className="text-sm font-medium flex items-center space-x-1">
<Tag className="h-3 w-3" />
<span>{t('tags')}</span>
</Label>
{/* Current Tags */}
<div className="mt-2 flex flex-wrap gap-2">
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center px-2 py-1 text-xs font-medium bg-primary/10 text-primary rounded-full"
>
{tag}
<button
onClick={() => handleRemoveTag(tag)}
className="ml-1 hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
{/* Add New Tag */}
<div className="mt-2 flex items-center space-x-2">
<Input
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddTag()
} else {
handleKeyPress(e)
}
}}
placeholder={t('addTag')}
className="flex-1"
/>
<Button
variant="outline"
size="sm"
onClick={handleAddTag}
disabled={!newTag.trim() || tags.includes(newTag.trim())}
className="h-9 w-9 p-0"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* Available Tags */}
{availableTags.length > 0 && (
<div className="mt-2">
<p className="text-xs text-muted-foreground mb-2">Quick add:</p>
<div className="flex flex-wrap gap-1">
{availableTags
.filter(tag => !tags.includes(tag))
.slice(0, 6)
.map((tag) => (
<button
key={tag}
onClick={() => setTags(prev => [...prev, tag])}
className="inline-flex items-center px-2 py-1 text-xs bg-muted text-muted-foreground rounded hover:bg-muted/80 transition-colors"
>
{tag}
</button>
))}
</div>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end space-x-2 p-6 border-t border-border">
<Button
variant="outline"
onClick={onClose}
disabled={isSaving}
>
{tCommon('cancel')}
</Button>
<Button
onClick={handleSave}
disabled={!name.trim() || isSaving}
className="flex items-center space-x-2"
>
{isSaving ? (
<LoadingSpinner size="sm" />
) : (
<Save className="h-4 w-4" />
)}
<span>{tCommon('save')}</span>
</Button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,363 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { LoadingSpinner } from '@/components/ui/loading-spinner'
import {
Play,
Settings,
Zap,
Copy,
Download,
AlertCircle,
CheckCircle,
Clock,
DollarSign
} from 'lucide-react'
interface TestResult {
testRun: {
id: string
input: string
output: string | null
success: boolean
error: string | null
createdAt: string
}
result: {
success: boolean
output: string | null
error: string | null
}
cost: number
model: string
timestamp: string
}
interface PromptDebuggerProps {
promptId: string
userId: string
content: string
onTestComplete?: (result: TestResult) => void
}
const AI_MODELS = [
{ id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo', provider: 'OpenAI', cost: '$0.001/1K tokens' },
{ id: 'gpt-4', name: 'GPT-4', provider: 'OpenAI', cost: '$0.03/1K tokens' },
{ id: 'gpt-4-turbo', name: 'GPT-4 Turbo', provider: 'OpenAI', cost: '$0.01/1K tokens' },
{ id: 'claude-3-sonnet', name: 'Claude 3 Sonnet', provider: 'Anthropic', cost: '$0.003/1K tokens' },
{ id: 'claude-3-haiku', name: 'Claude 3 Haiku', provider: 'Anthropic', cost: '$0.00025/1K tokens' }
]
export function PromptDebugger({
promptId,
userId,
content,
onTestComplete
}: PromptDebuggerProps) {
const t = useTranslations('studio')
const [selectedModel, setSelectedModel] = useState('gpt-3.5-turbo')
const [temperature, setTemperature] = useState(0.7)
const [maxTokens, setMaxTokens] = useState(1000)
const [isRunning, setIsRunning] = useState(false)
const [testResult, setTestResult] = useState<TestResult | null>(null)
const [showSettings, setShowSettings] = useState(false)
const handleRunTest = async () => {
if (!content.trim() || isRunning) return
setIsRunning(true)
setTestResult(null)
try {
const response = await fetch(`/api/prompts/${promptId}/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content,
model: selectedModel,
temperature,
maxTokens,
userId
})
})
if (response.ok) {
const result = await response.json()
setTestResult(result)
onTestComplete?.(result)
} else {
const error = await response.json()
setTestResult({
testRun: {
id: '',
input: content,
output: null,
success: false,
error: error.error || 'Test failed',
createdAt: new Date().toISOString()
},
result: {
success: false,
output: null,
error: error.error || 'Test failed'
},
cost: 0,
model: selectedModel,
timestamp: new Date().toISOString()
})
}
} catch (error) {
console.error('Error running test:', error)
setTestResult({
testRun: {
id: '',
input: content,
output: null,
success: false,
error: 'Network error occurred',
createdAt: new Date().toISOString()
},
result: {
success: false,
output: null,
error: 'Network error occurred'
},
cost: 0,
model: selectedModel,
timestamp: new Date().toISOString()
})
} finally {
setIsRunning(false)
}
}
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text)
}
const downloadResult = () => {
if (!testResult) return
const data = {
prompt: content,
model: testResult.model,
timestamp: testResult.timestamp,
result: testResult.result,
cost: testResult.cost
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `prompt-test-${Date.now()}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const selectedModelInfo = AI_MODELS.find(m => m.id === selectedModel)
return (
<div className="space-y-6">
{/* Controls */}
<div className="bg-card rounded-lg border border-border p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-foreground">{t('runTest')}</h3>
<Button
variant="outline"
size="sm"
onClick={() => setShowSettings(!showSettings)}
className="flex items-center space-x-1"
>
<Settings className="h-4 w-4" />
<span>Settings</span>
</Button>
</div>
{/* Model Selection */}
<div className="space-y-4">
<div>
<Label className="text-sm font-medium">AI Model</Label>
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="mt-1 w-full bg-background border border-input rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-ring"
>
{AI_MODELS.map(model => (
<option key={model.id} value={model.id}>
{model.name} ({model.provider}) - {model.cost}
</option>
))}
</select>
</div>
{/* Advanced Settings */}
{showSettings && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4 border-t border-border">
<div>
<Label className="text-sm font-medium">
Temperature: {temperature}
</Label>
<input
type="range"
min="0"
max="2"
step="0.1"
value={temperature}
onChange={(e) => setTemperature(parseFloat(e.target.value))}
className="mt-1 w-full"
/>
<p className="text-xs text-muted-foreground mt-1">
Controls randomness (0 = deterministic, 2 = very random)
</p>
</div>
<div>
<Label className="text-sm font-medium">Max Tokens</Label>
<input
type="number"
min="1"
max="4000"
value={maxTokens}
onChange={(e) => setMaxTokens(parseInt(e.target.value))}
className="mt-1 w-full bg-background border border-input rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-ring"
/>
<p className="text-xs text-muted-foreground mt-1">
Maximum response length
</p>
</div>
</div>
)}
{/* Run Button */}
<div className="flex items-center justify-between pt-4">
<div className="text-sm text-muted-foreground">
{selectedModelInfo && (
<span>Using {selectedModelInfo.name} {selectedModelInfo.cost}</span>
)}
</div>
<Button
onClick={handleRunTest}
disabled={isRunning || !content.trim()}
className="flex items-center space-x-2"
>
{isRunning ? (
<LoadingSpinner size="sm" />
) : (
<Play className="h-4 w-4" />
)}
<span>{isRunning ? 'Running...' : t('runTest')}</span>
</Button>
</div>
</div>
</div>
{/* Results */}
<div className="bg-card rounded-lg border border-border">
<div className="p-4 border-b border-border">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-foreground">{t('testResults')}</h3>
{testResult && (
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => testResult.result.output && copyToClipboard(testResult.result.output)}
disabled={!testResult.result.output}
className="h-7"
>
<Copy className="h-3 w-3 mr-1" />
Copy
</Button>
<Button
variant="outline"
size="sm"
onClick={downloadResult}
className="h-7"
>
<Download className="h-3 w-3 mr-1" />
Export
</Button>
</div>
)}
</div>
</div>
<div className="p-4">
{isRunning ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<LoadingSpinner size="lg" />
<p className="mt-4 text-muted-foreground">Running prompt test...</p>
<p className="text-xs text-muted-foreground mt-2">
Testing with {selectedModelInfo?.name}
</p>
</div>
</div>
) : testResult ? (
<div className="space-y-4">
{/* Status */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
{testResult.result.success ? (
<CheckCircle className="h-5 w-5 text-green-500" />
) : (
<AlertCircle className="h-5 w-5 text-red-500" />
)}
<span className={`font-medium ${
testResult.result.success ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}`}>
{testResult.result.success ? 'Test Successful' : 'Test Failed'}
</span>
</div>
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
<div className="flex items-center space-x-1">
<Clock className="h-3 w-3" />
<span>{new Date(testResult.timestamp).toLocaleTimeString()}</span>
</div>
<div className="flex items-center space-x-1">
<DollarSign className="h-3 w-3" />
<span>${testResult.cost.toFixed(6)}</span>
</div>
</div>
</div>
{/* Output */}
<div className="bg-muted rounded-lg p-4">
{testResult.result.success && testResult.result.output ? (
<pre className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
{testResult.result.output}
</pre>
) : (
<div className="text-red-600 dark:text-red-400">
<p className="font-medium">Error:</p>
<p className="text-sm mt-1">{testResult.result.error}</p>
</div>
)}
</div>
</div>
) : (
<div className="flex items-center justify-center py-12 text-center">
<div>
<Zap className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground mb-2">
{t('clickRunTestToSee')}
</p>
<p className="text-xs text-muted-foreground">
Test your prompt with AI models to see the output
</p>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,285 @@
'use client'
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { LoadingSpinner } from '@/components/ui/loading-spinner'
import {
History,
Clock,
GitBranch,
RotateCcw,
Eye,
GitCompare,
ChevronDown,
ChevronRight
} from 'lucide-react'
interface PromptVersion {
id: string
version: number
content: string
changelog: string
createdAt: string
}
interface VersionHistoryProps {
promptId: string
userId: string
onVersionRestore?: (version: PromptVersion) => void
onVersionSelect?: (version: PromptVersion) => void
}
export function VersionHistory({
promptId,
userId,
onVersionRestore,
onVersionSelect
}: VersionHistoryProps) {
const t = useTranslations('studio')
const [versions, setVersions] = useState<PromptVersion[]>([])
const [loading, setLoading] = useState(true)
const [expandedVersions, setExpandedVersions] = useState<Set<string>>(new Set())
const [selectedVersions, setSelectedVersions] = useState<string[]>([])
const [restoring, setRestoring] = useState<string | null>(null)
useEffect(() => {
fetchVersions()
}, [promptId, userId])
const fetchVersions = async () => {
try {
setLoading(true)
const response = await fetch(`/api/prompts/${promptId}/versions?userId=${userId}`)
if (response.ok) {
const data = await response.json()
setVersions(data)
}
} catch (error) {
console.error('Error fetching versions:', error)
} finally {
setLoading(false)
}
}
const handleRestoreVersion = async (version: PromptVersion) => {
if (restoring) return
try {
setRestoring(version.id)
const response = await fetch(
`/api/prompts/${promptId}/versions/${version.id}/restore`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId })
}
)
if (response.ok) {
await fetchVersions() // 刷新版本列表
onVersionRestore?.(version)
}
} catch (error) {
console.error('Error restoring version:', error)
} finally {
setRestoring(null)
}
}
const toggleVersionExpansion = (versionId: string) => {
const newExpanded = new Set(expandedVersions)
if (newExpanded.has(versionId)) {
newExpanded.delete(versionId)
} else {
newExpanded.add(versionId)
}
setExpandedVersions(newExpanded)
}
const handleVersionSelect = (versionId: string) => {
if (selectedVersions.includes(versionId)) {
setSelectedVersions(prev => prev.filter(id => id !== versionId))
} else if (selectedVersions.length < 2) {
setSelectedVersions(prev => [...prev, versionId])
} else {
// 替换第一个选中的版本
setSelectedVersions([selectedVersions[1], versionId])
}
}
const handleCompareVersions = () => {
if (selectedVersions.length === 2) {
const [fromId, toId] = selectedVersions
window.open(
`/studio/${promptId}/compare?from=${fromId}&to=${toId}`,
'_blank'
)
}
}
const formatDate = (dateString: string) => {
return new Intl.DateTimeFormat('default', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(dateString))
}
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<LoadingSpinner size="lg" />
</div>
)
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<History className="h-5 w-5 text-muted-foreground" />
<h3 className="font-semibold text-foreground">{t('versionHistory')}</h3>
<span className="text-sm text-muted-foreground">
({versions.length} versions)
</span>
</div>
{selectedVersions.length === 2 && (
<Button
variant="outline"
size="sm"
onClick={handleCompareVersions}
className="flex items-center space-x-1"
>
<GitCompare className="h-4 w-4" />
<span>Compare</span>
</Button>
)}
</div>
{/* Version List */}
<div className="space-y-2">
{versions.map((version, index) => {
const isExpanded = expandedVersions.has(version.id)
const isSelected = selectedVersions.includes(version.id)
const isLatest = index === 0
return (
<div
key={version.id}
className={`border border-border rounded-lg transition-colors ${
isSelected ? 'ring-2 ring-primary' : ''
}`}
>
<div className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<input
type="checkbox"
checked={isSelected}
onChange={() => handleVersionSelect(version.id)}
className="rounded"
disabled={selectedVersions.length >= 2 && !isSelected}
/>
<div className="flex items-center space-x-2">
<GitBranch className="h-4 w-4 text-muted-foreground" />
<span className="font-medium text-foreground">
Version {version.version}
</span>
{isLatest && (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-primary/10 text-primary rounded-full">
Current
</span>
)}
</div>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-1 text-sm text-muted-foreground">
<Clock className="h-3 w-3" />
<span className="hidden sm:inline">{formatDate(version.createdAt)}</span>
<span className="sm:hidden">{new Date(version.createdAt).toLocaleDateString()}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => toggleVersionExpansion(version.id)}
className="h-8 w-8 p-0"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</div>
</div>
{/* Changelog */}
<div className="mt-2">
<p className="text-sm text-muted-foreground">
{version.changelog}
</p>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border">
<div className="bg-muted rounded-lg p-3">
<pre className="text-sm text-foreground whitespace-pre-wrap font-mono">
{version.content.substring(0, 200)}
{version.content.length > 200 && '...'}
</pre>
</div>
<div className="flex items-center justify-between mt-3">
<Button
variant="outline"
size="sm"
onClick={() => onVersionSelect?.(version)}
className="flex items-center space-x-1"
>
<Eye className="h-3 w-3" />
<span>View Full</span>
</Button>
{!isLatest && (
<Button
variant="outline"
size="sm"
onClick={() => handleRestoreVersion(version)}
disabled={restoring === version.id}
className="flex items-center space-x-1"
>
{restoring === version.id ? (
<LoadingSpinner size="sm" />
) : (
<RotateCcw className="h-3 w-3" />
)}
<span>Restore</span>
</Button>
)}
</div>
</div>
)}
</div>
</div>
)
})}
</div>
{versions.length === 0 && (
<div className="text-center py-8">
<History className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">No version history available</p>
</div>
)}
</div>
)
}

9
src/lib/prisma.ts Normal file
View File

@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma