finished prompt studio
This commit is contained in:
parent
0371a75497
commit
921143f6a2
82
CLAUDE.md
82
CLAUDE.md
@ -18,9 +18,25 @@ npm run lint # Run ESLint for code quality checks
|
|||||||
|
|
||||||
**Database operations:**
|
**Database operations:**
|
||||||
```bash
|
```bash
|
||||||
npx prisma generate # Generate Prisma client after schema changes
|
npm run db:generate # Generate Prisma client after schema changes
|
||||||
npx prisma db push # Push schema changes to database
|
npm run db:push # Push schema changes to database (development)
|
||||||
npx prisma studio # Open Prisma Studio database GUI
|
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
|
## Architecture Overview
|
||||||
@ -91,40 +107,40 @@ Required environment variables:
|
|||||||
- [ ] Version Limit (Max By Subscribe)
|
- [ ] Version Limit (Max By Subscribe)
|
||||||
- [ ] Credit (Max By Subscribe)
|
- [ ] Credit (Max By Subscribe)
|
||||||
- [ ] Subscribe Plan (Free, Pro)
|
- [ ] Subscribe Plan (Free, Pro)
|
||||||
- [ ] AI Prompt Studio
|
- [x] AI Prompt Studio
|
||||||
- [ ] Manager
|
- [x] Manager
|
||||||
- [ ] CRUD
|
- [x] CRUD
|
||||||
- [ ] Search
|
- [x] Search
|
||||||
- [ ] Filter
|
- [x] Filter
|
||||||
- [ ] Sort
|
- [x] Sort
|
||||||
- [ ] Pagination
|
- [x] Pagination
|
||||||
- [ ] Bulk Actions
|
- [x] Bulk Actions
|
||||||
- [ ] DB Fields
|
- [x] DB Fields
|
||||||
- [ ] ID
|
- [x] ID
|
||||||
- [ ] Name
|
- [x] Name
|
||||||
- [ ] Content
|
- [x] Content
|
||||||
- [ ] Album
|
- [x] Album
|
||||||
- [ ] Tag
|
- [x] Tag
|
||||||
- [ ] Version
|
- [x] Version
|
||||||
- [ ] Created At
|
- [x] Created At
|
||||||
- [ ] Updated At
|
- [x] Updated At
|
||||||
- [ ] Primissions (Set By User)
|
- [x] Primissions (Set By User)
|
||||||
- [ ] Private
|
- [x] Private
|
||||||
- [ ] Public
|
- [x] Public
|
||||||
- [ ] Visibility (Set by admin when User Share)
|
- [ ] Visibility (Set by admin when User Share)
|
||||||
- [ ] Under Review
|
- [ ] Under Review
|
||||||
- [ ] Published
|
- [ ] Published
|
||||||
- [ ] View Count
|
- [ ] View Count
|
||||||
- [ ] Prompt Version Controll
|
- [x] Prompt Version Controll
|
||||||
- Generate a new version when save
|
- [x] Generate a new version when save
|
||||||
- Save last [LIMIT] versions
|
- [x] Save last [LIMIT] versions
|
||||||
- [LIMIT] can setting in user profile
|
- [ ] [LIMIT] can setting in user profile
|
||||||
- [LIMIT] max is by Subscribe
|
- [ ] [LIMIT] max is by Subscribe
|
||||||
- [ ] Prompt Debugger run
|
- [x] Prompt Debugger run
|
||||||
- Select AI Model
|
- [x] Select AI Model
|
||||||
- Input Prompt Content
|
- [x] Input Prompt Content
|
||||||
- Show Test Result
|
- [x] Show Test Result
|
||||||
- Need to User Credit
|
- [x] Need to User Credit
|
||||||
- [ ] Subscribe
|
- [ ] Subscribe
|
||||||
- [ ] Free
|
- [ ] Free
|
||||||
- [ ] 20 Prompt Limit
|
- [ ] 20 Prompt Limit
|
||||||
|
@ -94,6 +94,7 @@
|
|||||||
"title": "AI Prompt Studio",
|
"title": "AI Prompt Studio",
|
||||||
"myPrompts": "My Prompts",
|
"myPrompts": "My Prompts",
|
||||||
"createPrompt": "Create Prompt",
|
"createPrompt": "Create Prompt",
|
||||||
|
"newPrompt": "New Prompt",
|
||||||
"promptName": "Prompt Name",
|
"promptName": "Prompt Name",
|
||||||
"promptContent": "Prompt Content",
|
"promptContent": "Prompt Content",
|
||||||
"promptAlbum": "Album",
|
"promptAlbum": "Album",
|
||||||
@ -108,7 +109,47 @@
|
|||||||
"loadingStudio": "Loading Studio...",
|
"loadingStudio": "Loading Studio...",
|
||||||
"searchPrompts": "Search prompts...",
|
"searchPrompts": "Search prompts...",
|
||||||
"filter": "Filter",
|
"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": {
|
"home": {
|
||||||
"hero": {
|
"hero": {
|
||||||
|
@ -63,7 +63,7 @@
|
|||||||
"currentPassword": "当前密码",
|
"currentPassword": "当前密码",
|
||||||
"newPassword": "新密码",
|
"newPassword": "新密码",
|
||||||
"confirmNewPassword": "确认新密码",
|
"confirmNewPassword": "确认新密码",
|
||||||
"profilePicture": "头像",
|
"profilePicture": "个人头像",
|
||||||
"accessDenied": "访问被拒绝",
|
"accessDenied": "访问被拒绝",
|
||||||
"pleaseSignIn": "请登录以访问您的个人资料",
|
"pleaseSignIn": "请登录以访问您的个人资料",
|
||||||
"failedToLoadProfile": "加载个人资料失败",
|
"failedToLoadProfile": "加载个人资料失败",
|
||||||
@ -85,7 +85,7 @@
|
|||||||
"charactersLimit": "个字符",
|
"charactersLimit": "个字符",
|
||||||
"noBioAdded": "暂未添加个人简介",
|
"noBioAdded": "暂未添加个人简介",
|
||||||
"enterNewPassword": "输入新密码",
|
"enterNewPassword": "输入新密码",
|
||||||
"confirmNewPassword": "确认新密码",
|
"confirmNewPasswordLabel": "确认新密码",
|
||||||
"updatePassword": "更新密码",
|
"updatePassword": "更新密码",
|
||||||
"english": "English",
|
"english": "English",
|
||||||
"chinese": "中文"
|
"chinese": "中文"
|
||||||
@ -94,6 +94,7 @@
|
|||||||
"title": "AI 提示词工作室",
|
"title": "AI 提示词工作室",
|
||||||
"myPrompts": "我的提示词",
|
"myPrompts": "我的提示词",
|
||||||
"createPrompt": "创建提示词",
|
"createPrompt": "创建提示词",
|
||||||
|
"newPrompt": "新建提示词",
|
||||||
"promptName": "提示词名称",
|
"promptName": "提示词名称",
|
||||||
"promptContent": "提示词内容",
|
"promptContent": "提示词内容",
|
||||||
"promptAlbum": "专辑",
|
"promptAlbum": "专辑",
|
||||||
@ -108,7 +109,47 @@
|
|||||||
"loadingStudio": "加载工作室中...",
|
"loadingStudio": "加载工作室中...",
|
||||||
"searchPrompts": "搜索提示词...",
|
"searchPrompts": "搜索提示词...",
|
||||||
"filter": "筛选",
|
"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": {
|
"home": {
|
||||||
"hero": {
|
"hero": {
|
||||||
|
13
package.json
13
package.json
@ -3,10 +3,17 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "npm run db:generate && next dev",
|
||||||
"build": "next build",
|
"build": "npm run db:generate && next build",
|
||||||
"start": "next start",
|
"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": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.12.0",
|
"@prisma/client": "^6.12.0",
|
||||||
|
48
scripts/dev-check.js
Normal file
48
scripts/dev-check.js
Normal 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
29
scripts/setup-db.sh
Normal 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"
|
61
scripts/setup-local-dev.sh
Normal file
61
scripts/setup-local-dev.sh
Normal 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"
|
65
scripts/test-db-connection.js
Normal file
65
scripts/test-db-connection.js
Normal 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();
|
179
src/app/api/prompts/[id]/route.ts
Normal file
179
src/app/api/prompts/[id]/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
238
src/app/api/prompts/[id]/test/route.ts
Normal file
238
src/app/api/prompts/[id]/test/route.ts
Normal 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))
|
||||||
|
}
|
82
src/app/api/prompts/[id]/tests/route.ts
Normal file
82
src/app/api/prompts/[id]/tests/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
119
src/app/api/prompts/[id]/versions/[versionId]/route.ts
Normal file
119
src/app/api/prompts/[id]/versions/[versionId]/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
118
src/app/api/prompts/[id]/versions/compare/route.ts
Normal file
118
src/app/api/prompts/[id]/versions/compare/route.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
106
src/app/api/prompts/[id]/versions/route.ts
Normal file
106
src/app/api/prompts/[id]/versions/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
173
src/app/api/prompts/bulk/route.ts
Normal file
173
src/app/api/prompts/bulk/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
162
src/app/api/prompts/route.ts
Normal file
162
src/app/api/prompts/route.ts
Normal 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
81
src/app/api/tags/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
507
src/app/studio/[id]/page.tsx
Normal file
507
src/app/studio/[id]/page.tsx
Normal 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
293
src/app/studio/new/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@ -3,44 +3,268 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { Header } from '@/components/layout/Header'
|
import { Header } from '@/components/layout/Header'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
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 { LoadingSpinner } from '@/components/ui/loading-spinner'
|
||||||
import {
|
import { EditPromptModal } from '@/components/studio/EditPromptModal'
|
||||||
Play,
|
import {
|
||||||
Save,
|
|
||||||
Copy,
|
|
||||||
Settings,
|
|
||||||
Folder,
|
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Filter,
|
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Zap,
|
Edit,
|
||||||
History
|
Trash2,
|
||||||
|
Play,
|
||||||
|
FileText,
|
||||||
|
Calendar,
|
||||||
|
ChevronDown,
|
||||||
|
Grid,
|
||||||
|
List
|
||||||
} from 'lucide-react'
|
} 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() {
|
export default function StudioPage() {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
const t = useTranslations('studio')
|
const t = useTranslations('studio')
|
||||||
const tCommon = useTranslations('common')
|
const tCommon = useTranslations('common')
|
||||||
const [promptContent, setPromptContent] = useState('')
|
|
||||||
const [promptTitle, setPromptTitle] = useState('Untitled Prompt')
|
const [prompts, setPrompts] = useState<Prompt[]>([])
|
||||||
const [testResult, setTestResult] = useState('')
|
const [filteredPrompts, setFilteredPrompts] = useState<Prompt[]>([])
|
||||||
const [isRunning, setIsRunning] = useState(false)
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
// Redirect to sign in if not authenticated
|
|
||||||
if (!loading && !user) {
|
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 (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@ -52,275 +276,364 @@ export default function StudioPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return null
|
||||||
<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 (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<div className="flex h-[calc(100vh-4rem)]">
|
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||||
{/* Sidebar */}
|
{/* Header */}
|
||||||
<div className="w-80 border-r bg-muted/30 p-4 overflow-y-auto">
|
<div className="mb-8">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold text-foreground">Prompt Studio</h2>
|
<div>
|
||||||
<Button size="sm" variant="outline">
|
<h1 className="text-3xl font-bold text-foreground">{t('title')}</h1>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<p className="text-muted-foreground mt-1">{t('myPrompts')}</p>
|
||||||
New
|
</div>
|
||||||
|
<Button onClick={handleCreatePrompt} className="flex items-center space-x-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
<span>{t('createPrompt')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search and Filters */}
|
||||||
<div className="relative mb-4">
|
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
{/* Search */}
|
||||||
<Input
|
<div className="flex-1 relative">
|
||||||
placeholder={t('searchPrompts')}
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
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">
|
|
||||||
<Input
|
<Input
|
||||||
value={promptTitle}
|
placeholder={t('searchPrompts')}
|
||||||
onChange={(e) => setPromptTitle(e.target.value)}
|
value={searchQuery}
|
||||||
className="text-lg font-semibold border-none p-0 h-auto bg-transparent focus-visible:ring-0"
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="Prompt title..."
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
{/* Tag Filter */}
|
||||||
<Button
|
<div className="relative">
|
||||||
size="sm"
|
<select
|
||||||
variant="outline"
|
value={selectedTag}
|
||||||
onClick={() => copyToClipboard(promptContent)}
|
onChange={(e) => setSelectedTag(e.target.value)}
|
||||||
disabled={!promptContent}
|
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" />
|
<option value="">{t('allTags')}</option>
|
||||||
Copy
|
{allTags.map(tag => (
|
||||||
</Button>
|
<option key={tag} value={tag}>{tag}</option>
|
||||||
|
))}
|
||||||
<Button
|
</select>
|
||||||
size="sm"
|
<ChevronDown className="absolute right-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||||
variant="outline"
|
</div>
|
||||||
onClick={handleSavePrompt}
|
|
||||||
disabled={!promptContent || isSaving}
|
{/* 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 ? (
|
<option value="updatedAt-desc">{t('sortByUpdated')} ({t('descending')})</option>
|
||||||
<LoadingSpinner size="sm" className="mr-2" />
|
<option value="updatedAt-asc">{t('sortByUpdated')} ({t('ascending')})</option>
|
||||||
) : (
|
<option value="createdAt-desc">{t('sortByDate')} ({t('descending')})</option>
|
||||||
<Save className="h-4 w-4 mr-2" />
|
<option value="createdAt-asc">{t('sortByDate')} ({t('ascending')})</option>
|
||||||
)}
|
<option value="name-asc">{t('sortByName')} ({t('ascending')})</option>
|
||||||
{tCommon('save')}
|
<option value="name-desc">{t('sortByName')} ({t('descending')})</option>
|
||||||
</Button>
|
<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
|
<Button
|
||||||
|
variant={viewMode === 'grid' ? 'default' : 'ghost'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleRunPrompt}
|
onClick={() => setViewMode('grid')}
|
||||||
disabled={!promptContent || isRunning}
|
className="rounded-r-none"
|
||||||
>
|
>
|
||||||
{isRunning ? (
|
<Grid className="h-4 w-4" />
|
||||||
<LoadingSpinner size="sm" className="mr-2" />
|
|
||||||
) : (
|
|
||||||
<Play className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{t('runTest')}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
<Button size="sm" variant="outline">
|
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||||
<Settings className="h-4 w-4" />
|
size="sm"
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
className="rounded-l-none"
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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:
|
{/* Bulk Actions */}
|
||||||
You are a helpful assistant that helps users write professional emails.
|
{selectedPrompts.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between mb-4 p-3 bg-muted rounded-lg">
|
||||||
Given the following context:
|
<span className="text-sm text-muted-foreground">
|
||||||
- Recipient: {{recipient}}
|
{selectedPrompts.length} {t('selectedItems')}
|
||||||
- Purpose: {{purpose}}
|
</span>
|
||||||
- Tone: {{tone}}
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
Please write a professional email that..."
|
variant="outline"
|
||||||
className="flex-1 min-h-[400px] resize-none font-mono text-sm"
|
size="sm"
|
||||||
/>
|
onClick={handleBulkDelete}
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
<div className="flex items-center justify-between mt-4 text-sm text-muted-foreground">
|
>
|
||||||
<span>{promptContent.length} characters</span>
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
<span>~{Math.ceil(promptContent.length / 4)} tokens</span>
|
{t('bulkDelete')}
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{/* Results Panel */}
|
</div>
|
||||||
<div className="w-1/2 border-l p-6">
|
|
||||||
<div className="h-full flex flex-col">
|
{/* Content */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
{filteredPrompts.length === 0 ? (
|
||||||
<Label className="text-base font-medium">Test Results</Label>
|
<div className="text-center py-12">
|
||||||
{testResult && (
|
<FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
<Button
|
<h3 className="text-lg font-semibold text-foreground mb-2">{t('noPrompts')}</h3>
|
||||||
size="sm"
|
<p className="text-muted-foreground mb-4">{t('createFirstPrompt')}</p>
|
||||||
variant="outline"
|
<Button onClick={handleCreatePrompt}>
|
||||||
onClick={() => copyToClipboard(testResult)}
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
>
|
{t('createPrompt')}
|
||||||
<Copy className="h-4 w-4 mr-1" />
|
</Button>
|
||||||
Copy
|
</div>
|
||||||
</Button>
|
) : (
|
||||||
)}
|
<>
|
||||||
</div>
|
{/* Prompts Grid/List */}
|
||||||
|
{viewMode === 'grid' ? (
|
||||||
<div className="flex-1 bg-muted/30 rounded-lg p-4 overflow-y-auto">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 mb-8">
|
||||||
{isRunning ? (
|
{currentPrompts.map((prompt) => (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div
|
||||||
<div className="text-center">
|
key={prompt.id}
|
||||||
<LoadingSpinner size="lg" />
|
className={`bg-card rounded-lg border border-border p-4 hover:shadow-md transition-shadow cursor-pointer ${
|
||||||
<p className="mt-4 text-muted-foreground">Running your prompt...</p>
|
selectedPrompts.includes(prompt.id) ? 'ring-2 ring-primary' : ''
|
||||||
</div>
|
}`}
|
||||||
</div>
|
onClick={() => handleSelectPrompt(prompt.id)}
|
||||||
) : testResult ? (
|
>
|
||||||
<pre className="whitespace-pre-wrap text-sm text-foreground font-mono">
|
<div className="flex items-start justify-between mb-3">
|
||||||
{testResult}
|
<div className="flex-1">
|
||||||
</pre>
|
<h3 className="font-semibold text-foreground truncate mb-1">
|
||||||
) : (
|
{prompt.name}
|
||||||
<div className="flex items-center justify-center h-full text-center">
|
</h3>
|
||||||
<div>
|
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||||
<Zap className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
{prompt.description}
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{t('clickRunTestToSee')}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</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>
|
</>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
{editingPrompt && (
|
||||||
|
<EditPromptModal
|
||||||
|
prompt={editingPrompt}
|
||||||
|
isOpen={isEditModalOpen}
|
||||||
|
onClose={handleEditModalClose}
|
||||||
|
onSave={handleEditModalSave}
|
||||||
|
userId={user?.id || ''}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
281
src/components/studio/EditPromptModal.tsx
Normal file
281
src/components/studio/EditPromptModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
363
src/components/studio/PromptDebugger.tsx
Normal file
363
src/components/studio/PromptDebugger.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
285
src/components/studio/VersionHistory.tsx
Normal file
285
src/components/studio/VersionHistory.tsx
Normal 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
9
src/lib/prisma.ts
Normal 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
|
Loading…
Reference in New Issue
Block a user