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:**
|
||||
```bash
|
||||
npx prisma generate # Generate Prisma client after schema changes
|
||||
npx prisma db push # Push schema changes to database
|
||||
npx prisma studio # Open Prisma Studio database GUI
|
||||
npm run db:generate # Generate Prisma client after schema changes
|
||||
npm run db:push # Push schema changes to database (development)
|
||||
npm run db:migrate # Create and apply migrations (production)
|
||||
npm run db:studio # Open Prisma Studio database GUI
|
||||
npm run db:reset # Reset database and apply all migrations
|
||||
```
|
||||
|
||||
**Development setup:**
|
||||
```bash
|
||||
# First time setup
|
||||
npm install
|
||||
chmod +x scripts/setup-db.sh
|
||||
./scripts/setup-db.sh
|
||||
|
||||
# Or use the automated check
|
||||
node scripts/dev-check.js
|
||||
|
||||
# Daily development
|
||||
npm run dev # Automatically generates Prisma client and starts dev server
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
@ -91,40 +107,40 @@ Required environment variables:
|
||||
- [ ] Version Limit (Max By Subscribe)
|
||||
- [ ] Credit (Max By Subscribe)
|
||||
- [ ] Subscribe Plan (Free, Pro)
|
||||
- [ ] AI Prompt Studio
|
||||
- [ ] Manager
|
||||
- [ ] CRUD
|
||||
- [ ] Search
|
||||
- [ ] Filter
|
||||
- [ ] Sort
|
||||
- [ ] Pagination
|
||||
- [ ] Bulk Actions
|
||||
- [ ] DB Fields
|
||||
- [ ] ID
|
||||
- [ ] Name
|
||||
- [ ] Content
|
||||
- [ ] Album
|
||||
- [ ] Tag
|
||||
- [ ] Version
|
||||
- [ ] Created At
|
||||
- [ ] Updated At
|
||||
- [ ] Primissions (Set By User)
|
||||
- [ ] Private
|
||||
- [ ] Public
|
||||
- [x] AI Prompt Studio
|
||||
- [x] Manager
|
||||
- [x] CRUD
|
||||
- [x] Search
|
||||
- [x] Filter
|
||||
- [x] Sort
|
||||
- [x] Pagination
|
||||
- [x] Bulk Actions
|
||||
- [x] DB Fields
|
||||
- [x] ID
|
||||
- [x] Name
|
||||
- [x] Content
|
||||
- [x] Album
|
||||
- [x] Tag
|
||||
- [x] Version
|
||||
- [x] Created At
|
||||
- [x] Updated At
|
||||
- [x] Primissions (Set By User)
|
||||
- [x] Private
|
||||
- [x] Public
|
||||
- [ ] Visibility (Set by admin when User Share)
|
||||
- [ ] Under Review
|
||||
- [ ] Published
|
||||
- [ ] View Count
|
||||
- [ ] Prompt Version Controll
|
||||
- Generate a new version when save
|
||||
- Save last [LIMIT] versions
|
||||
- [LIMIT] can setting in user profile
|
||||
- [LIMIT] max is by Subscribe
|
||||
- [ ] Prompt Debugger run
|
||||
- Select AI Model
|
||||
- Input Prompt Content
|
||||
- Show Test Result
|
||||
- Need to User Credit
|
||||
- [x] Prompt Version Controll
|
||||
- [x] Generate a new version when save
|
||||
- [x] Save last [LIMIT] versions
|
||||
- [ ] [LIMIT] can setting in user profile
|
||||
- [ ] [LIMIT] max is by Subscribe
|
||||
- [x] Prompt Debugger run
|
||||
- [x] Select AI Model
|
||||
- [x] Input Prompt Content
|
||||
- [x] Show Test Result
|
||||
- [x] Need to User Credit
|
||||
- [ ] Subscribe
|
||||
- [ ] Free
|
||||
- [ ] 20 Prompt Limit
|
||||
|
@ -94,6 +94,7 @@
|
||||
"title": "AI Prompt Studio",
|
||||
"myPrompts": "My Prompts",
|
||||
"createPrompt": "Create Prompt",
|
||||
"newPrompt": "New Prompt",
|
||||
"promptName": "Prompt Name",
|
||||
"promptContent": "Prompt Content",
|
||||
"promptAlbum": "Album",
|
||||
@ -108,7 +109,47 @@
|
||||
"loadingStudio": "Loading Studio...",
|
||||
"searchPrompts": "Search prompts...",
|
||||
"filter": "Filter",
|
||||
"clickRunTestToSee": "Click \"Run Test\" to see your prompt results"
|
||||
"clickRunTestToSee": "Click \"Run Test\" to see your prompt results",
|
||||
"sortBy": "Sort by",
|
||||
"sortByName": "Name",
|
||||
"sortByDate": "Date",
|
||||
"sortByUpdated": "Updated",
|
||||
"ascending": "Ascending",
|
||||
"descending": "Descending",
|
||||
"itemsPerPage": "Items per page",
|
||||
"page": "Page",
|
||||
"of": "of",
|
||||
"total": "total",
|
||||
"editPrompt": "Edit Prompt",
|
||||
"deletePrompt": "Delete Prompt",
|
||||
"duplicatePrompt": "Duplicate Prompt",
|
||||
"confirmDelete": "Are you sure you want to delete this prompt?",
|
||||
"deleteWarning": "This action cannot be undone.",
|
||||
"promptDeleted": "Prompt deleted successfully",
|
||||
"promptSaved": "Prompt saved successfully",
|
||||
"promptCreated": "Prompt created successfully",
|
||||
"promptUpdated": "Prompt updated successfully",
|
||||
"enterPromptName": "Enter prompt name",
|
||||
"enterPromptDescription": "Enter prompt description",
|
||||
"promptDescription": "Description",
|
||||
"createdAt": "Created",
|
||||
"updatedAt": "Updated",
|
||||
"lastUsed": "Last used",
|
||||
"never": "Never",
|
||||
"selectAll": "Select all",
|
||||
"selectedItems": "selected items",
|
||||
"bulkActions": "Bulk actions",
|
||||
"bulkDelete": "Delete selected",
|
||||
"tags": "Tags",
|
||||
"addTag": "Add tag",
|
||||
"removeTag": "Remove tag",
|
||||
"noTags": "No tags",
|
||||
"allTags": "All tags",
|
||||
"backToList": "Back to list",
|
||||
"debugPrompt": "Debug Prompt",
|
||||
"promptEditor": "Prompt Editor",
|
||||
"testResults": "Test Results",
|
||||
"versionHistory": "Version History"
|
||||
},
|
||||
"home": {
|
||||
"hero": {
|
||||
|
@ -63,7 +63,7 @@
|
||||
"currentPassword": "当前密码",
|
||||
"newPassword": "新密码",
|
||||
"confirmNewPassword": "确认新密码",
|
||||
"profilePicture": "头像",
|
||||
"profilePicture": "个人头像",
|
||||
"accessDenied": "访问被拒绝",
|
||||
"pleaseSignIn": "请登录以访问您的个人资料",
|
||||
"failedToLoadProfile": "加载个人资料失败",
|
||||
@ -85,7 +85,7 @@
|
||||
"charactersLimit": "个字符",
|
||||
"noBioAdded": "暂未添加个人简介",
|
||||
"enterNewPassword": "输入新密码",
|
||||
"confirmNewPassword": "确认新密码",
|
||||
"confirmNewPasswordLabel": "确认新密码",
|
||||
"updatePassword": "更新密码",
|
||||
"english": "English",
|
||||
"chinese": "中文"
|
||||
@ -94,6 +94,7 @@
|
||||
"title": "AI 提示词工作室",
|
||||
"myPrompts": "我的提示词",
|
||||
"createPrompt": "创建提示词",
|
||||
"newPrompt": "新建提示词",
|
||||
"promptName": "提示词名称",
|
||||
"promptContent": "提示词内容",
|
||||
"promptAlbum": "专辑",
|
||||
@ -108,7 +109,47 @@
|
||||
"loadingStudio": "加载工作室中...",
|
||||
"searchPrompts": "搜索提示词...",
|
||||
"filter": "筛选",
|
||||
"clickRunTestToSee": "点击\"运行测试\"查看您的提示词结果"
|
||||
"clickRunTestToSee": "点击\"运行测试\"查看您的提示词结果",
|
||||
"sortBy": "排序方式",
|
||||
"sortByName": "名称",
|
||||
"sortByDate": "日期",
|
||||
"sortByUpdated": "更新时间",
|
||||
"ascending": "升序",
|
||||
"descending": "降序",
|
||||
"itemsPerPage": "每页条数",
|
||||
"page": "第",
|
||||
"of": "页,共",
|
||||
"total": "条",
|
||||
"editPrompt": "编辑提示词",
|
||||
"deletePrompt": "删除提示词",
|
||||
"duplicatePrompt": "复制提示词",
|
||||
"confirmDelete": "确定要删除这个提示词吗?",
|
||||
"deleteWarning": "此操作无法撤销。",
|
||||
"promptDeleted": "提示词删除成功",
|
||||
"promptSaved": "提示词保存成功",
|
||||
"promptCreated": "提示词创建成功",
|
||||
"promptUpdated": "提示词更新成功",
|
||||
"enterPromptName": "输入提示词名称",
|
||||
"enterPromptDescription": "输入提示词描述",
|
||||
"promptDescription": "描述",
|
||||
"createdAt": "创建时间",
|
||||
"updatedAt": "更新时间",
|
||||
"lastUsed": "最后使用",
|
||||
"never": "从未使用",
|
||||
"selectAll": "全选",
|
||||
"selectedItems": "已选择项目",
|
||||
"bulkActions": "批量操作",
|
||||
"bulkDelete": "删除选中项",
|
||||
"tags": "标签",
|
||||
"addTag": "添加标签",
|
||||
"removeTag": "移除标签",
|
||||
"noTags": "无标签",
|
||||
"allTags": "所有标签",
|
||||
"backToList": "返回列表",
|
||||
"debugPrompt": "调试提示词",
|
||||
"promptEditor": "提示词编辑器",
|
||||
"testResults": "测试结果",
|
||||
"versionHistory": "版本历史"
|
||||
},
|
||||
"home": {
|
||||
"hero": {
|
||||
|
13
package.json
13
package.json
@ -3,10 +3,17 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"dev": "npm run db:generate && next dev",
|
||||
"build": "npm run db:generate && next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:studio": "prisma studio",
|
||||
"db:reset": "prisma migrate reset",
|
||||
"db:seed": "prisma db seed",
|
||||
"postinstall": "prisma generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.12.0",
|
||||
|
48
scripts/dev-check.js
Normal file
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 { useTranslations } from 'next-intl'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner'
|
||||
import {
|
||||
Play,
|
||||
Save,
|
||||
Copy,
|
||||
Settings,
|
||||
Folder,
|
||||
import { EditPromptModal } from '@/components/studio/EditPromptModal'
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
MoreHorizontal,
|
||||
Zap,
|
||||
History
|
||||
Edit,
|
||||
Trash2,
|
||||
Play,
|
||||
FileText,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
Grid,
|
||||
List
|
||||
} from 'lucide-react'
|
||||
|
||||
interface Prompt {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
content: string
|
||||
tags: string[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
lastUsed?: string | null
|
||||
currentVersion?: number
|
||||
usage?: number
|
||||
}
|
||||
|
||||
interface PaginationInfo {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
type SortField = 'name' | 'createdAt' | 'updatedAt' | 'lastUsed'
|
||||
type SortOrder = 'asc' | 'desc'
|
||||
type ViewMode = 'grid' | 'list'
|
||||
|
||||
export default function StudioPage() {
|
||||
const { user, loading } = useAuth()
|
||||
const router = useRouter()
|
||||
const t = useTranslations('studio')
|
||||
const tCommon = useTranslations('common')
|
||||
const [promptContent, setPromptContent] = useState('')
|
||||
const [promptTitle, setPromptTitle] = useState('Untitled Prompt')
|
||||
const [testResult, setTestResult] = useState('')
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const [prompts, setPrompts] = useState<Prompt[]>([])
|
||||
const [filteredPrompts, setFilteredPrompts] = useState<Prompt[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedTag, setSelectedTag] = useState<string>('')
|
||||
const [sortField, setSortField] = useState<SortField>('updatedAt')
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid')
|
||||
const [selectedPrompts, setSelectedPrompts] = useState<string[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [allTags, setAllTags] = useState<string[]>([])
|
||||
|
||||
// Edit Modal
|
||||
const [editingPrompt, setEditingPrompt] = useState<Prompt | null>(null)
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
|
||||
|
||||
// Pagination
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [itemsPerPage, setItemsPerPage] = useState(12)
|
||||
const [pagination, setPagination] = useState<PaginationInfo>({
|
||||
page: 1,
|
||||
limit: 12,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// Redirect to sign in if not authenticated
|
||||
if (!loading && !user) {
|
||||
window.location.href = '/signin'
|
||||
router.push('/signin')
|
||||
} else if (user) {
|
||||
fetchPrompts()
|
||||
fetchTags()
|
||||
}
|
||||
}, [user, loading])
|
||||
}, [user, loading, router])
|
||||
|
||||
if (loading) {
|
||||
// Fetch prompts from API
|
||||
const fetchPrompts = async () => {
|
||||
if (!user) return
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const params = new URLSearchParams({
|
||||
userId: user.id,
|
||||
page: currentPage.toString(),
|
||||
limit: itemsPerPage.toString(),
|
||||
search: searchQuery,
|
||||
tag: selectedTag,
|
||||
sortBy: sortField,
|
||||
sortOrder: sortOrder
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/prompts?${params}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setPrompts(data.prompts)
|
||||
setPagination(data.pagination)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching prompts:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch tags from API
|
||||
const fetchTags = async () => {
|
||||
if (!user) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/tags?userId=${user.id}`)
|
||||
if (response.ok) {
|
||||
const tags = await response.json()
|
||||
setAllTags(tags.map((tag: any) => tag.name))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching tags:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Refetch when filters change
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchPrompts()
|
||||
}
|
||||
}, [currentPage, itemsPerPage, searchQuery, selectedTag, sortField, sortOrder, user])
|
||||
|
||||
// Since filtering and sorting is now done on the server,
|
||||
// we just use the prompts directly
|
||||
useEffect(() => {
|
||||
setFilteredPrompts(prompts)
|
||||
}, [prompts])
|
||||
|
||||
const handleCreatePrompt = () => {
|
||||
router.push('/studio/new')
|
||||
}
|
||||
|
||||
const handleEditPrompt = (id: string) => {
|
||||
router.push(`/studio/${id}`)
|
||||
}
|
||||
|
||||
const handleQuickEdit = (prompt: Prompt) => {
|
||||
setEditingPrompt(prompt)
|
||||
setIsEditModalOpen(true)
|
||||
}
|
||||
|
||||
const handleEditModalClose = () => {
|
||||
setIsEditModalOpen(false)
|
||||
setEditingPrompt(null)
|
||||
}
|
||||
|
||||
const handleEditModalSave = (updatedPrompt: Prompt) => {
|
||||
// Update the prompt in the local state
|
||||
setPrompts(prev => prev.map(p => p.id === updatedPrompt.id ? updatedPrompt : p))
|
||||
setFilteredPrompts(prev => prev.map(p => p.id === updatedPrompt.id ? updatedPrompt : p))
|
||||
}
|
||||
|
||||
const handleDeletePrompt = async (id: string) => {
|
||||
if (!user || !confirm(t('confirmDelete'))) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/prompts/${id}?userId=${user.id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
await fetchPrompts() // Refresh the list
|
||||
setSelectedPrompts(prev => prev.filter(pId => pId !== id))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting prompt:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDuplicatePrompt = async (prompt: Prompt) => {
|
||||
if (!user) return
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/prompts/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'duplicate',
|
||||
promptIds: [prompt.id],
|
||||
userId: user.id
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
await fetchPrompts() // Refresh the list
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error duplicating prompt:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
if (!user || selectedPrompts.length === 0) return
|
||||
|
||||
if (!confirm(`Delete ${selectedPrompts.length} selected prompts?`)) return
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/prompts/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'delete',
|
||||
promptIds: selectedPrompts,
|
||||
userId: user.id
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
await fetchPrompts() // Refresh the list
|
||||
setSelectedPrompts([])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error bulk deleting prompts:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectPrompt = (id: string) => {
|
||||
setSelectedPrompts(prev =>
|
||||
prev.includes(id)
|
||||
? prev.filter(pId => pId !== id)
|
||||
: [...prev, id]
|
||||
)
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
const currentPagePrompts = filteredPrompts.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
)
|
||||
|
||||
if (selectedPrompts.length === currentPagePrompts.length) {
|
||||
setSelectedPrompts([])
|
||||
} else {
|
||||
setSelectedPrompts(currentPagePrompts.map(p => p.id))
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Intl.DateTimeFormat('default', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).format(new Date(dateString))
|
||||
}
|
||||
|
||||
// Use server-side pagination
|
||||
const currentPrompts = filteredPrompts
|
||||
|
||||
if (loading || isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
@ -52,275 +276,364 @@ export default function StudioPage() {
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-foreground mb-4">Authentication Required</h1>
|
||||
<p className="text-muted-foreground mb-6">Please sign in to access the Prompt Studio</p>
|
||||
<Button onClick={() => window.location.href = '/signin'}>
|
||||
Sign In
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleRunPrompt = async () => {
|
||||
if (!promptContent.trim()) return
|
||||
|
||||
setIsRunning(true)
|
||||
setTestResult('')
|
||||
|
||||
try {
|
||||
// Simulate API call for now
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
// Mock response
|
||||
setTestResult(`Test result for: "${promptContent.substring(0, 50)}${promptContent.length > 50 ? '...' : ''}"\n\nThis is a simulated response from the AI model. In a real implementation, this would be the actual output from your chosen AI provider (OpenAI, Anthropic, etc.).\n\nResponse quality: Good\nToken usage: 150 tokens\nLatency: 1.2s`)
|
||||
} catch {
|
||||
setTestResult('Error: Failed to run prompt. Please try again.')
|
||||
} finally {
|
||||
setIsRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSavePrompt = async () => {
|
||||
setIsSaving(true)
|
||||
|
||||
try {
|
||||
// Simulate save operation
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// In real implementation, save to database via API
|
||||
console.log('Saving prompt:', { title: promptTitle, content: promptContent })
|
||||
|
||||
// Show success feedback (you could add a toast notification here)
|
||||
} catch (error) {
|
||||
console.error('Failed to save prompt:', error)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
// You could add a toast notification here
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header />
|
||||
|
||||
<div className="flex h-[calc(100vh-4rem)]">
|
||||
{/* Sidebar */}
|
||||
<div className="w-80 border-r bg-muted/30 p-4 overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-foreground">Prompt Studio</h2>
|
||||
<Button size="sm" variant="outline">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">{t('title')}</h1>
|
||||
<p className="text-muted-foreground mt-1">{t('myPrompts')}</p>
|
||||
</div>
|
||||
<Button onClick={handleCreatePrompt} className="flex items-center space-x-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>{t('createPrompt')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t('searchPrompts')}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Button size="sm" variant="outline">
|
||||
<Filter className="h-4 w-4 mr-1" />
|
||||
{t('filter')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
<Folder className="h-4 w-4 mr-1" />
|
||||
All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Prompt List */}
|
||||
<div className="space-y-2">
|
||||
<div className="p-3 rounded-lg bg-accent border cursor-pointer hover:bg-accent/80">
|
||||
<h3 className="font-medium text-sm text-foreground truncate">Welcome Message Generator</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">Generate personalized welcome messages...</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="text-xs text-muted-foreground">2 hours ago</span>
|
||||
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded">v1.2</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg border cursor-pointer hover:bg-accent/50">
|
||||
<h3 className="font-medium text-sm text-foreground truncate">Code Review Assistant</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">Help review code and suggest improvements...</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="text-xs text-muted-foreground">1 day ago</span>
|
||||
<span className="text-xs bg-secondary text-secondary-foreground px-2 py-0.5 rounded">v2.1</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg border cursor-pointer hover:bg-accent/50">
|
||||
<h3 className="font-medium text-sm text-foreground truncate">Email Marketing Copy</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">Create compelling email subject lines...</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="text-xs text-muted-foreground">3 days ago</span>
|
||||
<span className="text-xs bg-secondary text-secondary-foreground px-2 py-0.5 rounded">v1.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Toolbar */}
|
||||
<div className="border-b p-4 flex items-center justify-between bg-background">
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
{/* Search */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={promptTitle}
|
||||
onChange={(e) => setPromptTitle(e.target.value)}
|
||||
className="text-lg font-semibold border-none p-0 h-auto bg-transparent focus-visible:ring-0"
|
||||
placeholder="Prompt title..."
|
||||
placeholder={t('searchPrompts')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => copyToClipboard(promptContent)}
|
||||
disabled={!promptContent}
|
||||
|
||||
{/* Tag Filter */}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedTag}
|
||||
onChange={(e) => setSelectedTag(e.target.value)}
|
||||
className="appearance-none bg-background border border-input rounded-md px-3 py-2 pr-8 min-w-[120px] focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleSavePrompt}
|
||||
disabled={!promptContent || isSaving}
|
||||
<option value="">{t('allTags')}</option>
|
||||
{allTags.map(tag => (
|
||||
<option key={tag} value={tag}>{tag}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={`${sortField}-${sortOrder}`}
|
||||
onChange={(e) => {
|
||||
const [field, order] = e.target.value.split('-')
|
||||
setSortField(field as SortField)
|
||||
setSortOrder(order as SortOrder)
|
||||
}}
|
||||
className="appearance-none bg-background border border-input rounded-md px-3 py-2 pr-8 min-w-[140px] focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{isSaving ? (
|
||||
<LoadingSpinner size="sm" className="mr-2" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{tCommon('save')}
|
||||
</Button>
|
||||
|
||||
<option value="updatedAt-desc">{t('sortByUpdated')} ({t('descending')})</option>
|
||||
<option value="updatedAt-asc">{t('sortByUpdated')} ({t('ascending')})</option>
|
||||
<option value="createdAt-desc">{t('sortByDate')} ({t('descending')})</option>
|
||||
<option value="createdAt-asc">{t('sortByDate')} ({t('ascending')})</option>
|
||||
<option value="name-asc">{t('sortByName')} ({t('ascending')})</option>
|
||||
<option value="name-desc">{t('sortByName')} ({t('descending')})</option>
|
||||
<option value="lastUsed-desc">{t('lastUsed')} ({t('descending')})</option>
|
||||
<option value="lastUsed-asc">{t('lastUsed')} ({t('ascending')})</option>
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
</div>
|
||||
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex items-center border border-input rounded-md">
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={handleRunPrompt}
|
||||
disabled={!promptContent || isRunning}
|
||||
onClick={() => setViewMode('grid')}
|
||||
className="rounded-r-none"
|
||||
>
|
||||
{isRunning ? (
|
||||
<LoadingSpinner size="sm" className="mr-2" />
|
||||
) : (
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{t('runTest')}
|
||||
<Grid className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button size="sm" variant="outline">
|
||||
<Settings className="h-4 w-4" />
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('list')}
|
||||
className="rounded-l-none"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="flex-1 flex">
|
||||
{/* Prompt Editor */}
|
||||
<div className="flex-1 p-6">
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Label htmlFor="prompt-editor" className="text-base font-medium">
|
||||
Prompt Content
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost">
|
||||
<History className="h-4 w-4 mr-1" />
|
||||
History
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
id="prompt-editor"
|
||||
value={promptContent}
|
||||
onChange={(e) => setPromptContent(e.target.value)}
|
||||
placeholder="Enter your prompt here...
|
||||
|
||||
For example:
|
||||
You are a helpful assistant that helps users write professional emails.
|
||||
|
||||
Given the following context:
|
||||
- Recipient: {{recipient}}
|
||||
- Purpose: {{purpose}}
|
||||
- Tone: {{tone}}
|
||||
|
||||
Please write a professional email that..."
|
||||
className="flex-1 min-h-[400px] resize-none font-mono text-sm"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between mt-4 text-sm text-muted-foreground">
|
||||
<span>{promptContent.length} characters</span>
|
||||
<span>~{Math.ceil(promptContent.length / 4)} tokens</span>
|
||||
</div>
|
||||
{/* Bulk Actions */}
|
||||
{selectedPrompts.length > 0 && (
|
||||
<div className="flex items-center justify-between mb-4 p-3 bg-muted rounded-lg">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selectedPrompts.length} {t('selectedItems')}
|
||||
</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBulkDelete}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
{t('bulkDelete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Panel */}
|
||||
<div className="w-1/2 border-l p-6">
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Label className="text-base font-medium">Test Results</Label>
|
||||
{testResult && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => copyToClipboard(testResult)}
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
Copy
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-muted/30 rounded-lg p-4 overflow-y-auto">
|
||||
{isRunning ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
<p className="mt-4 text-muted-foreground">Running your prompt...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : testResult ? (
|
||||
<pre className="whitespace-pre-wrap text-sm text-foreground font-mono">
|
||||
{testResult}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-center">
|
||||
<div>
|
||||
<Zap className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
{t('clickRunTestToSee')}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{filteredPrompts.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">{t('noPrompts')}</h3>
|
||||
<p className="text-muted-foreground mb-4">{t('createFirstPrompt')}</p>
|
||||
<Button onClick={handleCreatePrompt}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t('createPrompt')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Prompts Grid/List */}
|
||||
{viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 mb-8">
|
||||
{currentPrompts.map((prompt) => (
|
||||
<div
|
||||
key={prompt.id}
|
||||
className={`bg-card rounded-lg border border-border p-4 hover:shadow-md transition-shadow cursor-pointer ${
|
||||
selectedPrompts.includes(prompt.id) ? 'ring-2 ring-primary' : ''
|
||||
}`}
|
||||
onClick={() => handleSelectPrompt(prompt.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-foreground truncate mb-1">
|
||||
{prompt.name}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{prompt.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative ml-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleQuickEdit(prompt)
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{prompt.tags.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center px-2 py-1 text-xs font-medium bg-muted text-muted-foreground rounded-full"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{prompt.tags.length > 3 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{prompt.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="h-3 w-3 mr-1" />
|
||||
<span className="hidden sm:inline">{t('updatedAt')}: </span>
|
||||
{formatDate(prompt.updatedAt)}
|
||||
</div>
|
||||
{prompt.lastUsed && (
|
||||
<div>
|
||||
<span className="hidden sm:inline">{t('lastUsed')}: </span>
|
||||
<span className="sm:hidden">Used: </span>
|
||||
{formatDate(prompt.lastUsed)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEditPrompt(prompt.id)
|
||||
}}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
<span className="text-xs">{tCommon('edit')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEditPrompt(prompt.id)
|
||||
}}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
<Play className="h-3 w-3" />
|
||||
<span className="text-xs">{t('debugPrompt')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 mb-8">
|
||||
{/* List Header */}
|
||||
<div className="flex items-center p-3 bg-muted rounded-lg text-sm font-medium text-muted-foreground">
|
||||
<div className="w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPrompts.length === currentPrompts.length && currentPrompts.length > 0}
|
||||
onChange={handleSelectAll}
|
||||
className="rounded"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">{t('promptName')}</div>
|
||||
<div className="w-32">{t('updatedAt')}</div>
|
||||
<div className="w-32">{t('lastUsed')}</div>
|
||||
<div className="w-20">{t('tags')}</div>
|
||||
<div className="w-16"></div>
|
||||
</div>
|
||||
|
||||
{/* List Items */}
|
||||
{currentPrompts.map((prompt) => (
|
||||
<div
|
||||
key={prompt.id}
|
||||
className={`flex items-center p-3 bg-card rounded-lg border border-border hover:shadow-sm transition-shadow ${
|
||||
selectedPrompts.includes(prompt.id) ? 'ring-2 ring-primary' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPrompts.includes(prompt.id)}
|
||||
onChange={() => handleSelectPrompt(prompt.id)}
|
||||
className="rounded"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-foreground">{prompt.name}</div>
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
{prompt.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-32 text-sm text-muted-foreground">
|
||||
{formatDate(prompt.updatedAt)}
|
||||
</div>
|
||||
<div className="w-32 text-sm text-muted-foreground">
|
||||
{prompt.lastUsed ? formatDate(prompt.lastUsed) : t('never')}
|
||||
</div>
|
||||
<div className="w-20">
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-muted text-muted-foreground rounded-full">
|
||||
{prompt.tags.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-16 flex items-center justify-end space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleQuickEdit(prompt)}
|
||||
className="h-8 w-8 p-0"
|
||||
title="Quick Edit"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditPrompt(prompt.id)}
|
||||
className="h-8 w-8 p-0"
|
||||
title="Full Edit"
|
||||
>
|
||||
<MoreHorizontal className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t('page')} {pagination.page} {t('of')} {pagination.totalPages} {t('total')} {pagination.total}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page === 1}
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
{Array.from({ length: Math.min(5, pagination.totalPages) }, (_, i) => {
|
||||
const page = i + 1
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
variant={pagination.page === page ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(page)}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page === pagination.totalPages}
|
||||
onClick={() => setCurrentPage(prev => Math.min(pagination.totalPages, prev + 1))}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editingPrompt && (
|
||||
<EditPromptModal
|
||||
prompt={editingPrompt}
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={handleEditModalClose}
|
||||
onSave={handleEditModalSave}
|
||||
userId={user?.id || ''}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
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