sam prompt 모듈 개발
This commit is contained in:
24
prt/api/init_db.php
Normal file
24
prt/api/init_db.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
require_once(__DIR__ . "/../../lib/mydb.php");
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
try {
|
||||
$pdo = db_connect();
|
||||
|
||||
// Ensure sort_order exists if table already created
|
||||
$pdo->exec("ALTER TABLE prompt_items ADD COLUMN IF NOT EXISTS sort_order INT DEFAULT 0 AFTER current_version_id");
|
||||
|
||||
$sql = file_get_contents(__DIR__ . "/../schema.sql");
|
||||
|
||||
// Split combined SQL into individual queries for PDO if needed,
|
||||
// but exec() can often handle multiple statements depending on driver config.
|
||||
// For safer execution, we'll try to run it.
|
||||
$pdo->exec($sql);
|
||||
|
||||
echo json_encode(["success" => true, "message" => "Database schema initialized successfully."]);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(["success" => false, "error" => $e->getMessage()]);
|
||||
}
|
||||
?>
|
||||
105
prt/api/prompts.php
Normal file
105
prt/api/prompts.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
require_once(__DIR__ . "/../../lib/mydb.php");
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
try {
|
||||
$pdo = db_connect();
|
||||
|
||||
switch ($action) {
|
||||
case 'get_data':
|
||||
$categories = $pdo->query("SELECT * FROM prompt_categories ORDER BY COALESCE(parent_id, 0) ASC, sort_order ASC, name ASC")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$prompts = $pdo->query("SELECT * FROM prompt_items ORDER BY sort_order ASC, updated_at DESC")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$versions = $pdo->query("SELECT * FROM prompt_versions ORDER BY version_number DESC")->fetchAll(PDO::FETCH_ASSOC);
|
||||
echo json_encode(['success' => true, 'categories' => $categories, 'prompts' => $prompts, 'versions' => $versions]);
|
||||
break;
|
||||
|
||||
case 'move_category':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
// input: { id, parent_id, sort_order }
|
||||
$stmt = $pdo->prepare("UPDATE prompt_categories SET parent_id = ?, sort_order = ? WHERE id = ?");
|
||||
$stmt->execute([$input['parent_id'], $input['sort_order'], $input['id']]);
|
||||
echo json_encode(['success' => true]);
|
||||
break;
|
||||
|
||||
case 'move_prompt':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
// input: { id, category_id, sort_order }
|
||||
$stmt = $pdo->prepare("UPDATE prompt_items SET category_id = ?, sort_order = ? WHERE id = ?");
|
||||
$stmt->execute([$input['category_id'], $input['sort_order'], $input['id']]);
|
||||
echo json_encode(['success' => true]);
|
||||
break;
|
||||
|
||||
case 'save_category':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
if (isset($input['id']) && is_numeric($input['id'])) {
|
||||
$stmt = $pdo->prepare("UPDATE prompt_categories SET name = ?, parent_id = ? WHERE id = ?");
|
||||
$stmt->execute([$input['name'], $input['parent_id'], $input['id']]);
|
||||
$id = $input['id'];
|
||||
} else {
|
||||
$stmt = $pdo->prepare("INSERT INTO prompt_categories (name, parent_id) VALUES (?, ?)");
|
||||
$stmt->execute([$input['name'], $input['parent_id']]);
|
||||
$id = $pdo->lastInsertId();
|
||||
}
|
||||
echo json_encode(['success' => true, 'id' => $id]);
|
||||
break;
|
||||
|
||||
case 'delete_category':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$stmt = $pdo->prepare("DELETE FROM prompt_categories WHERE id = ?");
|
||||
$stmt->execute([$input['id']]);
|
||||
echo json_encode(['success' => true]);
|
||||
break;
|
||||
|
||||
case 'save_prompt':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
if (isset($input['id']) && is_numeric($input['id'])) {
|
||||
$stmt = $pdo->prepare("UPDATE prompt_items SET name = ?, category_id = ?, description = ? WHERE id = ?");
|
||||
$stmt->execute([$input['name'], $input['category_id'], $input['description'] ?? '', $input['id']]);
|
||||
$id = $input['id'];
|
||||
} else {
|
||||
$stmt = $pdo->prepare("INSERT INTO prompt_items (name, category_id, description) VALUES (?, ?, ?)");
|
||||
$stmt->execute([$input['name'], $input['category_id'], $input['description'] ?? '']);
|
||||
$id = $pdo->lastInsertId();
|
||||
}
|
||||
echo json_encode(['success' => true, 'id' => $id]);
|
||||
break;
|
||||
|
||||
case 'delete_prompt':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$stmt = $pdo->prepare("DELETE FROM prompt_items WHERE id = ?");
|
||||
$stmt->execute([$input['id']]);
|
||||
echo json_encode(['success' => true]);
|
||||
break;
|
||||
|
||||
case 'save_version':
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
// Get next version number
|
||||
$stmt = $pdo->prepare("SELECT MAX(version_number) as max_v FROM prompt_versions WHERE prompt_id = ?");
|
||||
$stmt->execute([$input['prompt_id']]);
|
||||
$row = $stmt->fetch();
|
||||
$nextV = ($row['max_v'] ?? 0) + 1;
|
||||
|
||||
$stmt = $pdo->prepare("INSERT INTO prompt_versions (prompt_id, content, version_number, change_summary) VALUES (?, ?, ?, ?)");
|
||||
$stmt->execute([$input['prompt_id'], $input['content'], $nextV, $input['change_summary']]);
|
||||
$versionId = $pdo->lastInsertId();
|
||||
|
||||
// Update current_version_id in prompt_items
|
||||
$stmt = $pdo->prepare("UPDATE prompt_items SET current_version_id = ?, updated_at = NOW() WHERE id = ?");
|
||||
$stmt->execute([$versionId, $input['prompt_id']]);
|
||||
|
||||
echo json_encode(['success' => true, 'id' => $versionId, 'version_number' => $nextV]);
|
||||
break;
|
||||
|
||||
default:
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid action']);
|
||||
break;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
?>
|
||||
867
prt/index.php
Normal file
867
prt/index.php
Normal file
@@ -0,0 +1,867 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SAM PRT | 프롬프트 연구 터미널</title>
|
||||
|
||||
<!-- Fonts -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;500;600;700;800;900&family=Fira+Code:wght@400;500&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Pretendard', 'sans-serif'],
|
||||
mono: ['Fira Code', 'monospace'],
|
||||
},
|
||||
colors: {
|
||||
indigo: {
|
||||
50: '#f5f7ff',
|
||||
100: '#ebf0ff',
|
||||
200: '#dce4ff',
|
||||
300: '#ccd8ff',
|
||||
400: '#adbdff',
|
||||
500: '#8e9eff',
|
||||
600: '#6366f1',
|
||||
700: '#4f46e5',
|
||||
800: '#3730a3',
|
||||
900: '#312e81',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Alpine.js -->
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- Lucide Icons -->
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #e2e8f0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.sidebar-border {
|
||||
border-right: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.research-card {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.research-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.drag-over {
|
||||
@apply border-2 border-dashed border-indigo-400 bg-indigo-50/50 !important;
|
||||
}
|
||||
|
||||
.dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@keyframes pulse-soft {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
.animate-pulse-soft {
|
||||
animation: pulse-soft 2s infinite;
|
||||
}
|
||||
|
||||
/* Modal backdrop animation */
|
||||
.modal-overlay {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-white text-slate-900 font-sans selection:bg-indigo-100 selection:text-indigo-700 h-screen overflow-hidden"
|
||||
x-data="prtApp()"
|
||||
x-init="initApp()"
|
||||
x-cloak>
|
||||
|
||||
<div class="flex h-screen w-full">
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-80 sidebar-border bg-white flex flex-col h-screen overflow-hidden z-20">
|
||||
<div class="px-8 py-10">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<div class="w-7 h-7 bg-indigo-600 rounded-lg flex items-center justify-center text-white font-black text-[10px] shadow-lg shadow-indigo-200">PRT</div>
|
||||
<h1 class="text-xl font-black text-slate-900 leading-tight tracking-tight">SAM 프롬프트 연구소</h1>
|
||||
</div>
|
||||
<a href="https://codebridge-x.com" target="_blank" class="text-[10px] font-black text-indigo-500 uppercase tracking-widest hover:underline transition-all">
|
||||
CODEBRIDGE-X.COM
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="openCategoryModal()"
|
||||
class="mt-10 w-full flex items-center justify-center space-x-2 py-3.5 bg-slate-900 text-white rounded-[20px] hover:bg-slate-800 transition-all shadow-xl shadow-slate-200 active:scale-95"
|
||||
>
|
||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||
<span class="text-sm font-black tracking-tight">새 연구 분야 추가</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-5 pb-10 space-y-1">
|
||||
<template x-if="categories.length === 0">
|
||||
<div class="text-center py-20 px-6 opacity-40">
|
||||
<div class="w-16 h-16 bg-slate-50 rounded-3xl flex items-center justify-center mx-auto mb-4 border border-slate-100">
|
||||
<i data-lucide="folder-open" class="w-6 h-6 text-slate-300"></i>
|
||||
</div>
|
||||
<p class="text-xs font-bold text-slate-400">등록된 연구 분야가 없습니다</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-for="category in categories.filter(c => !c.parent_id)" :key="category.id">
|
||||
<div x-html="renderTreeItem(category, 0)"></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto p-8 bg-slate-50/50 border-t border-slate-100">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="relative">
|
||||
<div class="w-12 h-12 rounded-[18px] bg-gradient-to-tr from-slate-900 to-slate-700 flex items-center justify-center text-white font-black text-xs shadow-xl">
|
||||
PRT
|
||||
</div>
|
||||
<div class="absolute -bottom-1 -right-1 w-4 h-4 bg-emerald-500 border-[3px] border-white rounded-full"></div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<p class="text-[13px] font-black text-slate-900 leading-tight mb-0.5">프롬프트 연구소</p>
|
||||
<p class="text-[10px] text-slate-400 font-bold tracking-widest uppercase">v3.1 DATABASE SYNC</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 flex flex-col h-screen bg-slate-50/20 overflow-hidden relative">
|
||||
<template x-if="selectedPrompt">
|
||||
<div class="flex flex-col h-full w-full">
|
||||
<!-- Editor Header -->
|
||||
<header class="px-10 py-8 bg-white/70 backdrop-blur-xl border-b border-slate-100 flex items-center justify-between sticky top-0 z-10 no-print">
|
||||
<div class="flex items-center space-x-5">
|
||||
<div class="w-14 h-14 rounded-3xl bg-slate-50 border border-slate-100 flex items-center justify-center shadow-inner">
|
||||
<i data-lucide="pen-tool" class="w-6 h-6 text-slate-400"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center space-x-3 text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1.5">
|
||||
<span class="text-indigo-600 bg-indigo-50 px-2.5 py-1 rounded-full" x-text="currentCategoryName"></span>
|
||||
<span class="text-slate-300">•</span>
|
||||
<span class="font-black text-slate-500" x-text="'진화 단계 v' + currentVersionNumber"></span>
|
||||
</div>
|
||||
<h2 class="text-3xl font-black text-slate-900 tracking-tight" x-text="selectedPrompt.name"></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<button
|
||||
@click="copyToClipboard()"
|
||||
class="flex items-center space-x-2.5 px-6 py-3.5 rounded-2xl text-sm font-black transition-all bg-white border border-slate-200 text-slate-600 hover:bg-slate-50 hover:border-slate-300 shadow-sm"
|
||||
>
|
||||
<i :data-lucide="copied ? 'check' : 'copy'" class="w-4 h-4" :class="copied ? 'text-emerald-500' : ''"></i>
|
||||
<span x-text="copied ? '복사 완료!' : '프롬프트 복사'"></span>
|
||||
</button>
|
||||
<button
|
||||
@click="showHistory = !showHistory"
|
||||
class="flex items-center space-x-2.5 px-6 py-3.5 rounded-2xl text-sm font-black transition-all"
|
||||
:class="showHistory ? 'bg-indigo-600 text-white shadow-2xl shadow-indigo-200' : 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-50 hover:border-slate-300 shadow-sm'"
|
||||
>
|
||||
<i data-lucide="history" class="w-4 h-4"></i>
|
||||
<span>버전 히스토리</span>
|
||||
</button>
|
||||
<div class="h-8 w-px bg-slate-100 mx-2"></div>
|
||||
<button
|
||||
@click="deletePrompt(selectedPrompt.id)"
|
||||
class="p-3 text-slate-300 hover:text-red-500 hover:bg-red-50 rounded-2xl transition-all"
|
||||
title="프롬프트 연구 삭제"
|
||||
>
|
||||
<i data-lucide="trash-2" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Workspace -->
|
||||
<div class="flex-1 flex overflow-hidden p-8 gap-8">
|
||||
<div class="flex-1 flex flex-col space-y-6 min-w-0">
|
||||
<!-- Editor Container -->
|
||||
<div class="bg-white rounded-[40px] shadow-2xl shadow-slate-200/40 border border-slate-100 flex-1 flex flex-col overflow-hidden research-card min-h-0">
|
||||
<div class="px-8 py-5 border-b border-slate-50 flex items-center justify-between bg-slate-50/30">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-2 h-2 rounded-full bg-indigo-500 animate-pulse-soft shadow-[0_0_8px_rgba(79,70,229,0.5)]"></div>
|
||||
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest">활성 연구 워크스페이스</span>
|
||||
</div>
|
||||
<button
|
||||
@click="optimizeWithAI()"
|
||||
:disabled="isOptimizing"
|
||||
class="flex items-center space-x-2.5 px-5 py-2.5 bg-gradient-to-br from-indigo-600 to-indigo-800 text-white rounded-2xl text-[11px] font-black hover:from-indigo-700 hover:to-indigo-900 disabled:opacity-50 transition-all shadow-xl shadow-indigo-100 active:scale-95 group"
|
||||
>
|
||||
<i data-lucide="sparkles" class="w-4 h-4 group-hover:rotate-12 transition-transform" :class="isOptimizing ? 'animate-spin' : ''"></i>
|
||||
<span x-text="isOptimizing ? 'AI 연구 및 진화 중...' : 'Gemini AI로 프롬프트 고도화'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 p-0 relative min-h-0">
|
||||
<textarea
|
||||
x-model="editorContent"
|
||||
placeholder="이곳에 프롬프트 원석을 입력하여 연구를 시작하세요..."
|
||||
class="w-full h-full p-10 text-slate-800 font-mono text-lg bg-white focus:outline-none resize-none leading-[1.8]"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="px-10 py-8 border-t border-slate-50 bg-slate-50/20">
|
||||
<div class="flex items-center space-x-5">
|
||||
<div class="flex-1 relative group">
|
||||
<div class="absolute inset-y-0 left-5 flex items-center pointer-events-none opacity-40">
|
||||
<i data-lucide="message-square" class="w-4 h-4 text-slate-400"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
x-model="changeSummary"
|
||||
placeholder="이번 연구 단계의 변경 핵심 내용을 요약으로 기록하세요..."
|
||||
class="w-full pl-14 pr-6 py-4.5 bg-white border border-slate-200/70 rounded-[22px] text-sm font-bold transition-all focus:ring-8 focus:ring-indigo-50/50 focus:border-indigo-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@click="saveVersion()"
|
||||
class="px-10 py-4.5 bg-slate-900 text-white rounded-[22px] font-black text-sm hover:bg-black transition-all shadow-[0_15px_30px_-5px_rgba(0,0,0,0.15)] active:scale-95 whitespace-nowrap"
|
||||
>
|
||||
연구 성과 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Version History Sidebar (Inside Content) -->
|
||||
<div x-show="showHistory"
|
||||
class="w-96 glass-panel rounded-[40px] border border-slate-200/50 overflow-hidden flex flex-col h-full shadow-2xl relative z-30"
|
||||
x-transition:enter="transition ease-out duration-500"
|
||||
x-transition:enter-start="translate-x-full opacity-0"
|
||||
x-transition:enter-end="translate-x-0 opacity-100"
|
||||
x-transition:leave="transition ease-in duration-300"
|
||||
x-transition:leave-start="translate-x-0 opacity-100"
|
||||
x-transition:leave-end="translate-x-full opacity-0">
|
||||
|
||||
<div class="p-8 border-b border-slate-100/50 flex items-center justify-between bg-white/50">
|
||||
<div>
|
||||
<h3 class="font-black text-slate-900 text-xl tracking-tight">프롬프트 진화 과정</h3>
|
||||
<p class="text-[10px] text-slate-400 font-black uppercase tracking-widest mt-1">Research History</p>
|
||||
</div>
|
||||
<button @click="showHistory = false" class="w-10 h-10 flex items-center justify-center rounded-2xl hover:bg-slate-100 text-slate-400 transition-all active:scale-90">
|
||||
<i data-lucide="x" class="w-6 h-6"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-8 space-y-8 bg-slate-50/10">
|
||||
<template x-for="(v, idx) in currentPromptVersions" :key="v.id">
|
||||
<div class="relative pl-10 group">
|
||||
<!-- Timeline line -->
|
||||
<template x-if="idx < currentPromptVersions.length - 1">
|
||||
<div class="absolute left-[13px] top-8 bottom-[-32px] w-0.5 bg-slate-200/60 rounded-full"></div>
|
||||
</template>
|
||||
|
||||
<!-- Timeline point -->
|
||||
<div class="absolute left-0 top-1 w-7 h-7 rounded-full border-[6px] border-white shadow-md z-10 transition-all duration-300"
|
||||
:class="v.id == selectedPrompt.current_version_id ? 'bg-indigo-600 scale-110 shadow-indigo-200' : 'bg-slate-300 group-hover:bg-slate-400'">
|
||||
</div>
|
||||
|
||||
<div class="p-6 rounded-[28px] border transition-all duration-300 research-card"
|
||||
:class="v.id == selectedPrompt.current_version_id ? 'bg-white border-indigo-100 shadow-xl' : 'bg-white/60 border-slate-100 hover:border-slate-200'">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="text-[10px] font-black px-3 py-1 rounded-full uppercase tracking-widest shadow-sm"
|
||||
:class="v.id == selectedPrompt.current_version_id ? 'bg-indigo-600 text-white' : 'bg-slate-100 text-slate-500'"
|
||||
x-text="'v' + v.version_number"></span>
|
||||
<span class="text-[10px] text-slate-300 font-bold" x-text="formatDate(v.created_at)"></span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-700 font-bold mb-4 leading-relaxed" x-text="'“' + v.change_summary + '”'"></p>
|
||||
<button
|
||||
@click="editorContent = v.content"
|
||||
class="text-[10px] text-indigo-600 font-black hover:text-indigo-800 transition-colors uppercase tracking-widest flex items-center gap-2 group/btn"
|
||||
>
|
||||
연구 데이터 복구
|
||||
<i data-lucide="corner-up-left" class="w-3.5 h-3.5 group-hover/btn:-translate-x-0.5 transition-transform"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty State -->
|
||||
<template x-if="!selectedPrompt">
|
||||
<div class="flex-1 flex flex-col items-center justify-center bg-slate-50/20 p-20 text-center">
|
||||
<div class="relative mb-12">
|
||||
<div class="w-40 h-40 bg-white rounded-[56px] border border-slate-100 shadow-2xl flex items-center justify-center animate-pulse-soft">
|
||||
<i data-lucide="lightbulb" class="w-20 h-20 text-indigo-500"></i>
|
||||
</div>
|
||||
<div class="absolute -top-4 -right-4 w-12 h-12 bg-indigo-600 rounded-2xl flex items-center justify-center text-white shadow-xl rotate-12">
|
||||
<i data-lucide="sparkles" class="w-6 h-6"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-4xl font-black text-slate-900 mb-6 tracking-tight">수행할 연구 프로젝트를 선택하세요</h3>
|
||||
<p class="max-w-md text-slate-400 text-lg leading-relaxed font-bold">
|
||||
사이드바에서 기존 프롬프트 과제를 선택하거나 <br />
|
||||
새로운 연구 분야를 추가하여 인공지능 연구를 시작하십시오.
|
||||
</p>
|
||||
|
||||
<div class="mt-20 flex items-center justify-center space-x-6 text-[10px] font-black uppercase tracking-[0.4em] text-slate-200">
|
||||
<span class="w-12 h-px bg-slate-100"></span>
|
||||
<span class="flex items-center gap-3">
|
||||
AI 혁신 파트너
|
||||
<span class="text-indigo-400">Codebridge-X</span>
|
||||
</span>
|
||||
<span class="w-12 h-px bg-slate-100"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Category Modal -->
|
||||
<div x-show="categoryModal.open"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-6"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100">
|
||||
<div class="absolute inset-0 modal-overlay" @click="categoryModal.open = false"></div>
|
||||
<div class="relative bg-white rounded-[32px] w-full max-w-md shadow-[0_30px_60px_-12px_rgba(0,0,0,0.25)] overflow-hidden scale-100 transform active:scale-100 transition-all"
|
||||
x-show="categoryModal.open"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="scale-90 opacity-0"
|
||||
x-transition:enter-end="scale-100 opacity-100">
|
||||
<div class="p-10">
|
||||
<div class="flex items-center space-x-4 mb-8">
|
||||
<div class="w-14 h-14 bg-indigo-50 rounded-2xl flex items-center justify-center">
|
||||
<i data-lucide="folder-plus" class="w-7 h-7 text-indigo-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-2xl font-black text-slate-900 tracking-tight" x-text="categoryModal.id ? '연구 분야 수정' : '새 연구 분야 추가'"></h3>
|
||||
<p class="text-xs font-bold text-slate-400">관련 프롬프트들을 그룹화합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2.5 ml-1">분야 이름</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="categoryModal.name"
|
||||
placeholder="예: 영업 자동화, 미드저니 연구"
|
||||
class="w-full px-6 py-4.5 bg-slate-50 border border-slate-100 rounded-2xl text-sm font-bold focus:ring-8 focus:ring-indigo-50 focus:border-indigo-200 outline-none transition-all"
|
||||
@keyup.enter="saveCategory()"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
@click="categoryModal.open = false"
|
||||
class="flex-1 py-4.5 bg-slate-100 text-slate-500 rounded-2xl font-black text-sm hover:bg-slate-200 transition-all active:scale-95"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
@click="saveCategory()"
|
||||
class="flex-2 py-4.5 bg-indigo-600 text-white rounded-2xl font-black text-sm hover:bg-indigo-700 transition-all shadow-xl shadow-indigo-100 active:scale-95 px-8"
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prompt Modal -->
|
||||
<div x-show="promptModal.open"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-6"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100">
|
||||
<div class="absolute inset-0 modal-overlay" @click="promptModal.open = false"></div>
|
||||
<div class="relative bg-white rounded-[32px] w-full max-w-md shadow-[0_30px_60px_-12px_rgba(0,0,0,0.25)] overflow-hidden transition-all"
|
||||
x-show="promptModal.open"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="scale-90 opacity-0"
|
||||
x-transition:enter-end="scale-100 opacity-100">
|
||||
<div class="p-10">
|
||||
<div class="flex items-center space-x-4 mb-8">
|
||||
<div class="w-14 h-14 bg-indigo-50 rounded-2xl flex items-center justify-center">
|
||||
<i data-lucide="file-plus" class="w-7 h-7 text-indigo-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-2xl font-black text-slate-900 tracking-tight" x-text="promptModal.id ? '연구 제목 수정' : '새 연구 추가'"></h3>
|
||||
<p class="text-xs font-bold text-slate-400">구체적인 프롬프트 과제명을 입력하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2.5 ml-1">연구 프로젝트 명</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="promptModal.name"
|
||||
placeholder="예: 고객 맞춤 콜드메일 V2"
|
||||
class="w-full px-6 py-4.5 bg-slate-50 border border-slate-100 rounded-2xl text-sm font-bold focus:ring-8 focus:ring-indigo-50 focus:border-indigo-200 outline-none transition-all"
|
||||
@keyup.enter="savePrompt()"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
@click="promptModal.open = false"
|
||||
class="flex-1 py-4.5 bg-slate-100 text-slate-500 rounded-2xl font-black text-sm hover:bg-slate-200 transition-all active:scale-95"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
@click="savePrompt()"
|
||||
class="flex-2 py-4.5 bg-indigo-600 text-white rounded-2xl font-black text-sm hover:bg-indigo-700 transition-all shadow-xl shadow-indigo-100 active:scale-95 px-8"
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Initialization & App Logic -->
|
||||
<script>
|
||||
function prtApp() {
|
||||
return {
|
||||
categories: [],
|
||||
prompts: [],
|
||||
versions: [],
|
||||
selectedPromptId: null,
|
||||
expandedCategories: {},
|
||||
editorContent: '',
|
||||
changeSummary: '',
|
||||
isOptimizing: false,
|
||||
showHistory: false,
|
||||
copied: false,
|
||||
|
||||
// Modals
|
||||
categoryModal: { open: false, id: null, name: '', parentId: null },
|
||||
promptModal: { open: false, id: null, categoryId: null, name: '' },
|
||||
|
||||
async initApp() {
|
||||
await this.fetchData();
|
||||
this.syncEditor();
|
||||
|
||||
this.$nextTick(() => {
|
||||
lucide.createIcons();
|
||||
});
|
||||
},
|
||||
|
||||
async fetchData() {
|
||||
try {
|
||||
const res = await fetch('api/prompts.php?action=get_data');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.categories = data.categories;
|
||||
this.prompts = data.prompts;
|
||||
this.versions = data.versions;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Data fetch failed", e);
|
||||
}
|
||||
},
|
||||
|
||||
// --- Category Actions ---
|
||||
openCategoryModal(category = null, parentId = null) {
|
||||
this.categoryModal = {
|
||||
open: true,
|
||||
id: category ? category.id : null,
|
||||
name: category ? category.name : '',
|
||||
parentId: parentId || (category ? category.parent_id : null)
|
||||
};
|
||||
this.$nextTick(() => lucide.createIcons());
|
||||
},
|
||||
|
||||
async saveCategory() {
|
||||
if (!this.categoryModal.name.trim()) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('api/prompts.php?action=save_category', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: this.categoryModal.id,
|
||||
name: this.categoryModal.name,
|
||||
parent_id: this.categoryModal.parentId
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
await this.fetchData();
|
||||
this.categoryModal.open = false;
|
||||
}
|
||||
} catch (e) {
|
||||
alert("저장 중 오류 발생");
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCategory(id) {
|
||||
if (!confirm('정말로 이 분야와 포함된 모든 연구 내용을 영구 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const res = await fetch('api/prompts.php?action=delete_category', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
await this.fetchData();
|
||||
if (this.selectedPrompt && this.selectedPrompt.category_id == id) {
|
||||
this.selectedPromptId = null;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
alert("삭제 중 오류 발생");
|
||||
}
|
||||
},
|
||||
|
||||
toggleExpand(id) {
|
||||
this.expandedCategories[id] = !this.expandedCategories[id];
|
||||
this.$nextTick(() => lucide.createIcons());
|
||||
},
|
||||
|
||||
isExpanded(id) {
|
||||
return !!this.expandedCategories[id];
|
||||
},
|
||||
|
||||
// --- Prompt Actions ---
|
||||
getPrompts(catId) {
|
||||
return this.prompts.filter(p => p.category_id == catId);
|
||||
},
|
||||
|
||||
openPromptModal(catId, prompt = null) {
|
||||
this.promptModal = {
|
||||
open: true,
|
||||
id: prompt ? prompt.id : null,
|
||||
categoryId: catId,
|
||||
name: prompt ? prompt.name : ''
|
||||
};
|
||||
this.$nextTick(() => lucide.createIcons());
|
||||
},
|
||||
|
||||
async savePrompt() {
|
||||
if (!this.promptModal.name.trim()) return;
|
||||
|
||||
try {
|
||||
const isNew = !this.promptModal.id;
|
||||
const res = await fetch('api/prompts.php?action=save_prompt', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: this.promptModal.id,
|
||||
category_id: this.promptModal.categoryId,
|
||||
name: this.promptModal.name
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
await this.fetchData();
|
||||
this.promptModal.open = false;
|
||||
|
||||
if (isNew) {
|
||||
this.selectedPromptId = data.id;
|
||||
this.expandedCategories[this.promptModal.categoryId] = true;
|
||||
// Create initial empty version
|
||||
await this.saveInitialVersion(data.id);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
alert("저장 중 오류 발생");
|
||||
}
|
||||
},
|
||||
|
||||
async saveInitialVersion(promptId) {
|
||||
const res = await fetch('api/prompts.php?action=save_version', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt_id: promptId,
|
||||
content: '',
|
||||
change_summary: '연구 인스턴스 초기화'
|
||||
})
|
||||
});
|
||||
await this.fetchData();
|
||||
this.syncEditor();
|
||||
},
|
||||
|
||||
async deletePrompt(id) {
|
||||
if (!confirm('이 프롬프트 연구 기록을 영구 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const res = await fetch('api/prompts.php?action=delete_prompt', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
await this.fetchData();
|
||||
this.selectedPromptId = null;
|
||||
}
|
||||
} catch (e) {
|
||||
alert("삭제 중 오류 발생");
|
||||
}
|
||||
},
|
||||
|
||||
selectPrompt(id) {
|
||||
this.selectedPromptId = id;
|
||||
this.showHistory = false;
|
||||
this.syncEditor();
|
||||
},
|
||||
|
||||
// --- Editor Actions ---
|
||||
syncEditor() {
|
||||
if (this.selectedPrompt) {
|
||||
const version = this.versions.find(v => v.id == this.selectedPrompt.current_version_id);
|
||||
this.editorContent = version ? version.content : '';
|
||||
this.changeSummary = '';
|
||||
} else {
|
||||
this.editorContent = '';
|
||||
this.changeSummary = '';
|
||||
}
|
||||
},
|
||||
|
||||
get selectedPrompt() {
|
||||
return this.prompts.find(p => p.id == this.selectedPromptId);
|
||||
},
|
||||
|
||||
get currentCategoryName() {
|
||||
if (!this.selectedPrompt) return '';
|
||||
const cat = this.categories.find(c => c.id == this.selectedPrompt.category_id);
|
||||
return cat ? cat.name : '분류 없음';
|
||||
},
|
||||
|
||||
get currentVersionNumber() {
|
||||
if (!this.selectedPrompt) return 1;
|
||||
const v = this.versions.find(v => v.id == this.selectedPrompt.current_version_id);
|
||||
return v ? v.version_number : 1;
|
||||
},
|
||||
|
||||
get currentPromptVersions() {
|
||||
return this.versions
|
||||
.filter(v => v.prompt_id == this.selectedPromptId)
|
||||
.sort((a, b) => b.version_number - a.version_number);
|
||||
},
|
||||
|
||||
async saveVersion() {
|
||||
if (!this.editorContent.trim()) {
|
||||
alert('연구 데이터를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('api/prompts.php?action=save_version', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt_id: this.selectedPromptId,
|
||||
content: this.editorContent,
|
||||
change_summary: this.changeSummary || `${this.currentVersionNumber + 1}차 진화 및 고도화`
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
await this.fetchData();
|
||||
this.changeSummary = '';
|
||||
alert('연구 성과가 성공적으로 저장되었습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
alert("저장 중 오류 발생");
|
||||
}
|
||||
},
|
||||
|
||||
optimizeWithAI() {
|
||||
if (!this.editorContent.trim()) return;
|
||||
|
||||
this.isOptimizing = true;
|
||||
setTimeout(() => {
|
||||
const original = this.editorContent;
|
||||
this.editorContent = `[Gemini Optimized Version]\n\n# Context & Persona\n당신은 ${this.currentCategoryName} 분야의 최고 고문입니다.\n\n# Objectives\n${original}\n\n# Constraints\n- 전문적인 톤앤매너 유지\n- 구체적인 수치 지표 기반 논리 전개\n- 가독성을 위한 구조화된 응답`;
|
||||
this.isOptimizing = false;
|
||||
this.changeSummary = "Gemini AI를 통한 페르소나 및 구조 최적화";
|
||||
lucide.createIcons();
|
||||
}, 1500);
|
||||
},
|
||||
|
||||
copyToClipboard() {
|
||||
if (!this.editorContent.trim()) return;
|
||||
navigator.clipboard.writeText(this.editorContent).then(() => {
|
||||
this.copied = true;
|
||||
setTimeout(() => {
|
||||
this.copied = false;
|
||||
this.$nextTick(() => lucide.createIcons());
|
||||
}, 2000);
|
||||
this.$nextTick(() => lucide.createIcons());
|
||||
});
|
||||
},
|
||||
|
||||
renderTreeItem(category, depth) {
|
||||
const subCategories = this.categories.filter(c => c.parent_id == category.id);
|
||||
const prompts = this.getPrompts(category.id);
|
||||
const expanded = this.isExpanded(category.id);
|
||||
const paddingLeft = depth * 12;
|
||||
|
||||
let html = `
|
||||
<div class="mb-1"
|
||||
draggable="true"
|
||||
ondragstart="window.prtInstance.handleDragStart(event, 'category', '${category.id}')"
|
||||
ondragover="window.prtInstance.handleDragOver(event)"
|
||||
ondragleave="window.prtInstance.handleDragLeave(event)"
|
||||
ondrop="window.prtInstance.handleDrop(event, 'category', '${category.id}')">
|
||||
<div class="group flex items-center justify-between px-4 py-2.5 rounded-2xl cursor-pointer transition-all duration-200 ${expanded ? 'bg-slate-50' : 'hover:bg-slate-50/80'}"
|
||||
onclick="window.prtInstance.toggleExpand('${category.id}')" style="padding-left: ${paddingLeft + 16}px">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="transform transition-transform duration-300 ${expanded ? 'rotate-90' : ''}">
|
||||
<i data-lucide="chevron-right" class="w-3.5 h-3.5 text-slate-300"></i>
|
||||
</span>
|
||||
<i data-lucide="folder" class="w-4 h-4 ${expanded ? 'text-indigo-500' : 'text-slate-400'}"></i>
|
||||
<span class="text-sm font-bold truncate ${expanded ? 'text-slate-900' : 'text-slate-500'}">${category.name}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-0.5 opacity-0 group-hover:opacity-100 transition-all">
|
||||
<button onclick="event.stopPropagation(); window.prtInstance.openCategoryModal(null, '${category.id}')" class="p-1.5 hover:bg-white hover:shadow-sm rounded-lg text-slate-400 hover:text-indigo-600" title="하위 분야 추가"><i data-lucide="folder-plus" class="w-3 h-3"></i></button>
|
||||
<button onclick="event.stopPropagation(); window.prtInstance.openPromptModal('${category.id}')" class="p-1.5 hover:bg-white hover:shadow-sm rounded-lg text-slate-400 hover:text-indigo-600" title="프롬프트 추가"><i data-lucide="plus-square" class="w-3.5 h-3.5"></i></button>
|
||||
<button onclick="event.stopPropagation(); window.prtInstance.openCategoryModal(${JSON.stringify(category).replace(/"/g, '"')})" class="p-1.5 hover:bg-white hover:shadow-sm rounded-lg text-slate-400 hover:text-amber-600" title="이름 수정"><i data-lucide="edit-3" class="w-3.5 h-3.5"></i></button>
|
||||
<button onclick="event.stopPropagation(); window.prtInstance.deleteCategory('${category.id}')" class="p-1.5 hover:bg-red-50 hover:text-red-500 rounded-lg text-slate-300" title="삭제"><i data-lucide="trash-2" class="w-3.5 h-3.5"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="isExpanded('${category.id}')" class="mt-1 ml-4 border-l border-slate-100 pl-2">
|
||||
${subCategories.map(sub => this.renderTreeItem(sub, depth + 1)).join('')}
|
||||
${prompts.map(p => `
|
||||
<div draggable="true"
|
||||
ondragstart="window.prtInstance.handleDragStart(event, 'prompt', '${p.id}')"
|
||||
onclick="window.prtInstance.selectPrompt('${p.id}')"
|
||||
class="flex items-center justify-between px-4 py-2.5 cursor-pointer rounded-xl text-xs transition-all duration-300 group ${this.selectedPromptId == p.id ? 'bg-indigo-600 text-white shadow-lg' : 'hover:bg-indigo-50 text-slate-500 hover:text-indigo-600 font-bold'}">
|
||||
<div class="flex items-center space-x-3 truncate">
|
||||
<i data-lucide="file-text" class="w-3.5 h-3.5 ${this.selectedPromptId == p.id ? 'text-white' : 'text-slate-300 group-hover:text-indigo-400'}"></i>
|
||||
<span class="truncate">${p.name}</span>
|
||||
</div>
|
||||
<button x-show="selectedPromptId == ${p.id}" onclick="event.stopPropagation(); window.prtInstance.openPromptModal('${category.id}', ${JSON.stringify(p).replace(/"/g, '"')})" class="p-1 hover:bg-indigo-500 rounded text-white/70 hover:text-white transition-all"><i data-lucide="edit-2" class="w-3 h-3"></i></button>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return html;
|
||||
},
|
||||
|
||||
// --- Drag and Drop Handlers ---
|
||||
handleDragStart(e, type, id) {
|
||||
e.dataTransfer.setData('type', type);
|
||||
e.dataTransfer.setData('id', id);
|
||||
e.target.classList.add('dragging');
|
||||
},
|
||||
|
||||
handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
if (e.target.closest('.mb-1')) {
|
||||
e.target.closest('.mb-1').classList.add('drag-over');
|
||||
}
|
||||
},
|
||||
|
||||
handleDragLeave(e) {
|
||||
if (e.target.closest('.mb-1')) {
|
||||
e.target.closest('.mb-1').classList.remove('drag-over');
|
||||
}
|
||||
},
|
||||
|
||||
async handleDrop(e, targetType, targetId) {
|
||||
e.preventDefault();
|
||||
const draggedType = e.dataTransfer.getData('type');
|
||||
const draggedId = e.dataTransfer.getData('id');
|
||||
|
||||
document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||
document.querySelectorAll('.dragging').forEach(el => el.classList.remove('dragging'));
|
||||
|
||||
if (draggedId === targetId && draggedType === targetType) return;
|
||||
|
||||
try {
|
||||
let action = draggedType === 'category' ? 'move_category' : 'move_prompt';
|
||||
let body = {};
|
||||
|
||||
if (draggedType === 'category') {
|
||||
body = {
|
||||
id: draggedId,
|
||||
parent_id: targetType === 'category' ? targetId : null,
|
||||
sort_order: 0 // Simplification: set to top/first
|
||||
};
|
||||
} else {
|
||||
body = {
|
||||
id: draggedId,
|
||||
category_id: targetType === 'category' ? targetId : this.prompts.find(p => p.id == targetId)?.category_id,
|
||||
sort_order: 0
|
||||
};
|
||||
}
|
||||
|
||||
const res = await fetch(`api/prompts.php?action=${action}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
await this.fetchData();
|
||||
this.$nextTick(() => lucide.createIcons());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Drop failed", err);
|
||||
}
|
||||
},
|
||||
|
||||
async initApp() {
|
||||
window.prtInstance = this;
|
||||
await this.fetchData();
|
||||
this.syncEditor();
|
||||
this.$nextTick(() => { lucide.createIcons(); });
|
||||
},
|
||||
|
||||
formatDate(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return `${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${d.getMinutes().toString().padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global watcher for icon updates
|
||||
document.addEventListener('alpine:initialized', () => {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
24
prt/ref/.gitignore
vendored
Normal file
24
prt/ref/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
204
prt/ref/App.tsx
Normal file
204
prt/ref/App.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import PromptEditor from './components/PromptEditor';
|
||||
import { AppState, Category, Prompt, PromptVersion } from './types';
|
||||
import { generatePromptTitle } from './services/geminiService';
|
||||
|
||||
const INITIAL_DATA: AppState = {
|
||||
categories: [
|
||||
{ id: 'cat-1', name: '웹 프론트엔드', parentId: null },
|
||||
{ id: 'cat-2', name: 'AI 엔지니어링', parentId: null },
|
||||
{ id: 'cat-3', name: '클라우드 아키텍처', parentId: null },
|
||||
],
|
||||
prompts: [
|
||||
{
|
||||
id: 'p-1',
|
||||
categoryId: 'cat-2',
|
||||
name: 'Gemini 최적화 로직',
|
||||
description: '프롬프트 최적화를 위한 핵심 시스템 프롬프트 연구',
|
||||
tags: ['ai', 'optimization'],
|
||||
currentVersionId: 'v-1',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
],
|
||||
versions: [
|
||||
{
|
||||
id: 'v-1',
|
||||
promptId: 'p-1',
|
||||
content: '당신은 세계적인 프롬프트 엔지니어입니다. 입력받은 프롬프트를 분석하여 논리적 구조, 제약 조건, 그리고 창의적인 페르소나를 강화하여 재작성하세요.',
|
||||
versionNumber: 1,
|
||||
changeSummary: '초기 연구 가설 설정',
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
],
|
||||
selectedCategoryId: null,
|
||||
selectedPromptId: 'p-1',
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [state, setState] = useState<AppState>(() => {
|
||||
const saved = localStorage.getItem('sam_prompt_state');
|
||||
return saved ? JSON.parse(saved) : INITIAL_DATA;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sam_prompt_state', JSON.stringify(state));
|
||||
}, [state]);
|
||||
|
||||
const handleSelectPrompt = (id: string) => {
|
||||
setState(prev => ({ ...prev, selectedPromptId: id }));
|
||||
};
|
||||
|
||||
const handleAddCategory = (parentId: string | null) => {
|
||||
const name = prompt('새로운 연구 분야의 이름을 입력하세요 (예: DevOps, Mobile, Security):');
|
||||
if (!name) return;
|
||||
|
||||
const newCategory: Category = {
|
||||
id: `cat-${Date.now()}`,
|
||||
name,
|
||||
parentId
|
||||
};
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
categories: [...prev.categories, newCategory]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDeleteCategory = (id: string) => {
|
||||
if (!confirm('정말로 이 분야의 모든 연구 내용을 삭제하시겠습니까?')) return;
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
categories: prev.categories.filter(c => c.id !== id),
|
||||
prompts: prev.prompts.filter(p => p.categoryId !== id),
|
||||
selectedPromptId: prev.prompts.find(p => p.categoryId === id)?.id === prev.selectedPromptId ? null : prev.selectedPromptId
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddPrompt = async (categoryId: string) => {
|
||||
const id = `p-${Date.now()}`;
|
||||
const versionId = `v-${Date.now()}`;
|
||||
|
||||
const newPrompt: Prompt = {
|
||||
id,
|
||||
categoryId,
|
||||
name: '제목 없는 연구 프로젝트',
|
||||
description: '',
|
||||
tags: [],
|
||||
currentVersionId: versionId,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const newVersion: PromptVersion = {
|
||||
id: versionId,
|
||||
promptId: id,
|
||||
content: '',
|
||||
versionNumber: 1,
|
||||
changeSummary: '신규 연구 프로젝트 시작',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
prompts: [...prev.prompts, newPrompt],
|
||||
versions: [...prev.versions, newVersion],
|
||||
selectedPromptId: id
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSaveVersion = async (content: string, summary: string) => {
|
||||
if (!state.selectedPromptId) return;
|
||||
|
||||
const currentPrompt = state.prompts.find(p => p.id === state.selectedPromptId);
|
||||
if (!currentPrompt) return;
|
||||
|
||||
const currentVersions = state.versions.filter(v => v.promptId === state.selectedPromptId);
|
||||
const nextVersionNumber = Math.max(0, ...currentVersions.map(v => v.versionNumber)) + 1;
|
||||
const newVersionId = `v-${Date.now()}`;
|
||||
|
||||
// Auto-update name if it's still default
|
||||
let updatedName = currentPrompt.name;
|
||||
if (updatedName === '제목 없는 연구 프로젝트' && content.length > 10) {
|
||||
updatedName = await generatePromptTitle(content);
|
||||
}
|
||||
|
||||
const newVersion: PromptVersion = {
|
||||
id: newVersionId,
|
||||
promptId: state.selectedPromptId,
|
||||
content,
|
||||
versionNumber: nextVersionNumber,
|
||||
changeSummary: summary,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
prompts: prev.prompts.map(p => p.id === state.selectedPromptId ? {
|
||||
...p,
|
||||
name: updatedName,
|
||||
currentVersionId: newVersionId,
|
||||
updatedAt: new Date().toISOString()
|
||||
} : p),
|
||||
versions: [...prev.versions, newVersion]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDeletePrompt = (id: string) => {
|
||||
if (!confirm('이 프롬프트 연구 기록을 삭제하시겠습니까?')) return;
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
prompts: prev.prompts.filter(p => p.id !== id),
|
||||
versions: prev.versions.filter(v => v.promptId !== id),
|
||||
selectedPromptId: prev.selectedPromptId === id ? null : prev.selectedPromptId
|
||||
}));
|
||||
};
|
||||
|
||||
const selectedPrompt = state.prompts.find(p => p.id === state.selectedPromptId);
|
||||
const selectedPromptVersions = state.versions.filter(v => v.promptId === state.selectedPromptId);
|
||||
const selectedCategory = state.categories.find(c => c.id === selectedPrompt?.categoryId);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-white text-slate-900 antialiased overflow-hidden">
|
||||
<Sidebar
|
||||
categories={state.categories}
|
||||
prompts={state.prompts}
|
||||
onSelectPrompt={handleSelectPrompt}
|
||||
onAddCategory={handleAddCategory}
|
||||
onAddPrompt={handleAddPrompt}
|
||||
onDeleteCategory={handleDeleteCategory}
|
||||
selectedPromptId={state.selectedPromptId}
|
||||
/>
|
||||
|
||||
{selectedPrompt ? (
|
||||
<PromptEditor
|
||||
prompt={selectedPrompt}
|
||||
versions={selectedPromptVersions}
|
||||
categoryName={selectedCategory?.name || '분류 없음'}
|
||||
onSave={handleSaveVersion}
|
||||
onDelete={handleDeletePrompt}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center bg-slate-50/50 p-10 text-center">
|
||||
<div className="w-32 h-32 bg-white rounded-[40px] border border-slate-100 shadow-2xl shadow-slate-200/50 flex items-center justify-center mb-10">
|
||||
<svg className="w-14 h-14 text-indigo-500 animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-3xl font-black text-slate-900 mb-4 tracking-tight">연구를 시작할 프롬프트를 선택하세요</h3>
|
||||
<p className="max-w-md text-slate-500 leading-relaxed font-bold">
|
||||
사이드바에서 기존 연구 과제를 선택하거나 새로운 분야를 추가하여 프롬프트의 진화 과정을 기록하십시오.
|
||||
</p>
|
||||
<div className="mt-12 flex items-center space-x-2 text-[10px] font-black uppercase tracking-widest text-slate-300">
|
||||
<span>혁신 파트너</span>
|
||||
<span className="text-indigo-400">Codebridge-X</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
20
prt/ref/README.md
Normal file
20
prt/ref/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/drive/1Z2kAlFwh0UXFVckjV9KPZLXtpLoCfhoV
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
38
prt/ref/components/Icons.tsx
Normal file
38
prt/ref/components/Icons.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export const FolderIcon = ({ className = "w-5 h-5" }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const FileIcon = ({ className = "w-5 h-5" }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const PlusIcon = ({ className = "w-5 h-5" }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const HistoryIcon = ({ className = "w-5 h-5" }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SparklesIcon = ({ className = "w-5 h-5" }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const TrashIcon = ({ className = "w-5 h-5" }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
);
|
||||
176
prt/ref/components/PromptEditor.tsx
Normal file
176
prt/ref/components/PromptEditor.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Prompt, PromptVersion, Category } from '../types';
|
||||
import { SparklesIcon, HistoryIcon, TrashIcon } from './Icons';
|
||||
import { optimizePrompt } from '../services/geminiService';
|
||||
|
||||
interface PromptEditorProps {
|
||||
prompt: Prompt;
|
||||
versions: PromptVersion[];
|
||||
categoryName: string;
|
||||
onSave: (content: string, summary: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
const PromptEditor: React.FC<PromptEditorProps> = ({ prompt, versions, categoryName, onSave, onDelete }) => {
|
||||
const currentVersion = versions.find(v => v.id === prompt.currentVersionId);
|
||||
const [content, setContent] = useState(currentVersion?.content || '');
|
||||
const [summary, setSummary] = useState('');
|
||||
const [isOptimizing, setIsOptimizing] = useState(false);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setContent(currentVersion?.content || '');
|
||||
setSummary('');
|
||||
}, [prompt.id, prompt.currentVersionId, currentVersion]);
|
||||
|
||||
const handleOptimize = async () => {
|
||||
if (!content.trim()) return;
|
||||
setIsOptimizing(true);
|
||||
const optimized = await optimizePrompt(content, categoryName);
|
||||
setContent(optimized);
|
||||
setIsOptimizing(false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!content.trim()) return;
|
||||
onSave(content, summary || `${versions.length + 1}차 고도화 진행`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-screen bg-slate-50/30">
|
||||
{/* Header */}
|
||||
<header className="px-8 py-6 bg-white border-b border-slate-100 flex items-center justify-between sticky top-0 z-10">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-slate-50 border border-slate-100 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 text-[10px] font-black uppercase tracking-widest text-slate-400 mb-0.5">
|
||||
<span className="text-indigo-500">{categoryName}</span>
|
||||
<span className="opacity-30">/</span>
|
||||
<span className="bg-indigo-50 text-indigo-600 px-1.5 py-0.5 rounded uppercase font-black">진화 단계 v{currentVersion?.versionNumber || 1}</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-black text-slate-900">{prompt.name}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
className={`flex items-center space-x-2.5 px-4 py-2.5 rounded-xl text-sm font-bold transition-all ${showHistory ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-200' : 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-50 hover:border-slate-300 shadow-sm'}`}
|
||||
>
|
||||
<HistoryIcon className="w-4 h-4" />
|
||||
<span>버전 히스토리</span>
|
||||
</button>
|
||||
<div className="h-6 w-px bg-slate-200 mx-1"></div>
|
||||
<button
|
||||
onClick={() => onDelete(prompt.id)}
|
||||
className="p-2.5 text-slate-300 hover:text-red-500 hover:bg-red-50 rounded-xl transition-all"
|
||||
title="프롬프트 연구 삭제"
|
||||
>
|
||||
<TrashIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 flex overflow-hidden p-6 gap-6">
|
||||
{/* Editor Area */}
|
||||
<div className="flex-1 flex flex-col space-y-4 max-w-5xl mx-auto w-full">
|
||||
<div className="bg-white rounded-3xl shadow-xl shadow-slate-200/50 border border-slate-100 flex-1 flex flex-col overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-50 flex items-center justify-between bg-slate-50/30">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-2 h-2 rounded-full bg-indigo-500 animate-pulse"></div>
|
||||
<span className="text-xs font-black text-slate-500 uppercase tracking-widest">활성 연구 워크스페이스</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleOptimize}
|
||||
disabled={isOptimizing}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-gradient-to-r from-indigo-600 to-violet-600 text-white rounded-xl text-xs font-black hover:from-indigo-700 hover:to-violet-700 disabled:opacity-50 transition-all shadow-lg shadow-indigo-200 active:scale-95"
|
||||
>
|
||||
<SparklesIcon className={`w-3.5 h-3.5 ${isOptimizing ? 'animate-spin' : ''}`} />
|
||||
<span>{isOptimizing ? 'AI 연구 및 최적화 중...' : 'Gemini AI로 프롬프트 고도화'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-0">
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="여기에 프롬프트를 작성하여 연구를 시작하세요..."
|
||||
className="w-full h-full p-8 text-slate-800 font-mono text-base bg-white focus:outline-none resize-none leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-6 border-t border-slate-50 bg-slate-50/20">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-1 relative group">
|
||||
<div className="absolute inset-y-0 left-4 flex items-center pointer-events-none">
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
placeholder="이번 연구 단계의 핵심 개선 내용을 기록하세요 (변경 요약)..."
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-white border border-slate-200 rounded-2xl text-sm font-bold transition-all focus:ring-4 focus:ring-indigo-50/50 focus:border-indigo-300 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-8 py-3.5 bg-slate-900 text-white rounded-2xl font-black text-sm hover:bg-black transition-all shadow-xl shadow-slate-200 active:scale-95 whitespace-nowrap"
|
||||
>
|
||||
연구 성과 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version History Sidebar */}
|
||||
{showHistory && (
|
||||
<div className="w-96 glass-panel rounded-3xl border border-slate-200 overflow-hidden flex flex-col animate-in slide-in-from-right duration-500 shadow-2xl">
|
||||
<div className="p-6 border-b border-slate-100 flex items-center justify-between bg-white">
|
||||
<h3 className="font-black text-slate-900 tracking-tight">프롬프트 진화 히스토리</h3>
|
||||
<button onClick={() => setShowHistory(false)} className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-slate-100 text-slate-400 transition-all">✕</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6 bg-slate-50/30">
|
||||
{[...versions].sort((a, b) => b.versionNumber - a.versionNumber).map((v, idx) => (
|
||||
<div key={v.id} className="relative pl-8 group">
|
||||
{/* Connection line */}
|
||||
{idx < versions.length - 1 && (
|
||||
<div className="absolute left-[11px] top-6 bottom-[-24px] w-0.5 bg-slate-200"></div>
|
||||
)}
|
||||
{/* Circle */}
|
||||
<div className={`absolute left-0 top-1 w-6 h-6 rounded-full border-4 border-white shadow-md z-10 ${v.id === prompt.currentVersionId ? 'bg-indigo-600' : 'bg-slate-300 group-hover:bg-slate-400'}`}></div>
|
||||
|
||||
<div className={`p-4 rounded-2xl border transition-all duration-200 ${v.id === prompt.currentVersionId ? 'bg-white border-indigo-100 shadow-lg' : 'bg-white/60 border-slate-100 hover:border-slate-200'}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className={`text-[10px] font-black px-2 py-0.5 rounded-full ${v.id === prompt.currentVersionId ? 'bg-indigo-600 text-white' : 'bg-slate-100 text-slate-500'}`}>버전 {v.versionNumber}</span>
|
||||
<span className="text-[10px] text-slate-400 font-bold">{new Date(v.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-700 font-bold mb-3 leading-relaxed">"{v.changeSummary}"</p>
|
||||
<button
|
||||
onClick={() => setContent(v.content)}
|
||||
className="text-[10px] text-indigo-600 font-black hover:text-indigo-800 transition-colors uppercase tracking-widest flex items-center"
|
||||
>
|
||||
에디터로 복구하여 연구 재개
|
||||
<svg className="w-2.5 h-2.5 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M14 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PromptEditor;
|
||||
142
prt/ref/components/Sidebar.tsx
Normal file
142
prt/ref/components/Sidebar.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Category, Prompt } from '../types';
|
||||
import { FolderIcon, FileIcon, PlusIcon, TrashIcon } from './Icons';
|
||||
|
||||
interface SidebarProps {
|
||||
categories: Category[];
|
||||
prompts: Prompt[];
|
||||
onSelectPrompt: (id: string) => void;
|
||||
onAddCategory: (parentId: string | null) => void;
|
||||
onAddPrompt: (categoryId: string) => void;
|
||||
onDeleteCategory: (id: string) => void;
|
||||
selectedPromptId: string | null;
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({
|
||||
categories,
|
||||
prompts,
|
||||
onSelectPrompt,
|
||||
onAddCategory,
|
||||
onAddPrompt,
|
||||
onDeleteCategory,
|
||||
selectedPromptId
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
setExpanded(prev => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
const renderCategory = (category: Category, depth: number = 0) => {
|
||||
const categoryPrompts = prompts.filter(p => p.categoryId === category.id);
|
||||
const isExpanded = expanded[category.id];
|
||||
|
||||
return (
|
||||
<div key={category.id} className="mb-0.5">
|
||||
<div
|
||||
className={`group flex items-center justify-between px-3 py-2 rounded-lg cursor-pointer transition-all duration-200 ${isExpanded ? 'bg-slate-50' : 'hover:bg-slate-50'}`}
|
||||
onClick={() => toggleExpand(category.id)}
|
||||
>
|
||||
<div className="flex items-center space-x-2.5" style={{ paddingLeft: `${depth * 12}px` }}>
|
||||
<span className={`transform transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}>
|
||||
<svg className="w-3 h-3 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
<FolderIcon className={`w-4 h-4 ${isExpanded ? 'text-indigo-500' : 'text-slate-400'}`} />
|
||||
<span className={`text-sm font-semibold truncate ${isExpanded ? 'text-slate-900' : 'text-slate-600'}`}>{category.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onAddPrompt(category.id); }}
|
||||
className="p-1 hover:bg-white hover:shadow-sm rounded text-slate-500 transition-all"
|
||||
title="프롬프트 추가"
|
||||
>
|
||||
<PlusIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDeleteCategory(category.id); }}
|
||||
className="p-1 hover:bg-red-50 hover:text-red-500 rounded text-slate-400 transition-all"
|
||||
title="분야 삭제"
|
||||
>
|
||||
<TrashIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-0.5 border-l border-slate-100 ml-4.5 pl-0">
|
||||
{categoryPrompts.map(prompt => (
|
||||
<div
|
||||
key={prompt.id}
|
||||
onClick={() => onSelectPrompt(prompt.id)}
|
||||
className={`flex items-center space-x-3 px-4 py-2 my-0.5 cursor-pointer rounded-lg text-sm transition-all duration-200
|
||||
${selectedPromptId === prompt.id
|
||||
? 'bg-indigo-600 text-white shadow-md shadow-indigo-100'
|
||||
: 'hover:bg-indigo-50 text-slate-500 hover:text-indigo-600'}
|
||||
`}
|
||||
style={{ marginLeft: `${depth * 4}px` }}
|
||||
>
|
||||
<FileIcon className={`w-4 h-4 ${selectedPromptId === prompt.id ? 'text-white' : 'text-slate-300'}`} />
|
||||
<span className="truncate font-medium">{prompt.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="w-80 sidebar-border bg-white flex flex-col h-screen overflow-hidden">
|
||||
<div className="px-6 py-8">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-xl font-bold text-slate-900 leading-tight tracking-tight">SAM 프롬프트 관리</h1>
|
||||
<a href="https://codebridge-x.com" target="_blank" className="text-[10px] font-black text-indigo-500 uppercase tracking-widest hover:underline transition-all">
|
||||
codebridge-x.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onAddCategory(null)}
|
||||
className="mt-6 w-full flex items-center justify-center space-x-2 py-2.5 bg-slate-900 text-white rounded-xl hover:bg-slate-800 transition-all shadow-lg active:scale-95"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<span className="text-sm font-bold">새 연구 분야 추가</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-10 space-y-1">
|
||||
{categories.length === 0 ? (
|
||||
<div className="text-center py-20 px-6">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<FolderIcon className="w-6 h-6 text-slate-300" />
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 font-medium">등록된 연구 분야가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
categories.map(cat => renderCategory(cat))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto p-6 bg-slate-50/50 border-t border-slate-100">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="relative">
|
||||
<div className="w-10 h-10 rounded-2xl bg-indigo-600 flex items-center justify-center text-white font-black text-sm shadow-inner">
|
||||
CB
|
||||
</div>
|
||||
<div className="absolute -bottom-1 -right-1 w-4 h-4 bg-emerald-500 border-2 border-white rounded-full"></div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<p className="text-sm font-black text-slate-900 leading-none mb-1">프롬프트 연구소</p>
|
||||
<p className="text-[11px] text-slate-500 font-bold">v2.1 안정화 버전</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
61
prt/ref/index.html
Normal file
61
prt/ref/index.html
Normal file
@@ -0,0 +1,61 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SAM 프롬프트 관리 | Codebridge-X</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;500;600;700&family=Fira+Code:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
background-color: #ffffff;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.font-mono {
|
||||
font-family: 'Fira Code', monospace;
|
||||
}
|
||||
/* Custom scrollbar for modern feel */
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #e2e8f0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
.glass-panel {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.sidebar-border {
|
||||
border-right: 1px solid #f1f5f9;
|
||||
}
|
||||
.editor-border {
|
||||
border: 1px solid #f1f5f9;
|
||||
}
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react": "https://esm.sh/react@^19.2.3",
|
||||
"react-dom/": "https://esm.sh/react-dom@^19.2.3/",
|
||||
"react/": "https://esm.sh/react@^19.2.3/",
|
||||
"@google/genai": "https://esm.sh/@google/genai@^1.34.0"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
16
prt/ref/index.tsx
Normal file
16
prt/ref/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
6
prt/ref/metadata.json
Normal file
6
prt/ref/metadata.json
Normal file
@@ -0,0 +1,6 @@
|
||||
|
||||
{
|
||||
"name": "SAM 프롬프트 관리",
|
||||
"description": "Codebridge-X의 전문 프롬프트 진화 및 버전 관리 시스템. 코딩 각 분야의 프롬프트를 체계적으로 연구하고 기록합니다.",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
22
prt/ref/package.json
Normal file
22
prt/ref/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "sam-프롬프트-관리",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"@google/genai": "^1.34.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
50
prt/ref/services/geminiService.ts
Normal file
50
prt/ref/services/geminiService.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
|
||||
|
||||
export const optimizePrompt = async (originalPrompt: string, field: string): Promise<string> => {
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-3-flash-preview',
|
||||
contents: `You are an expert Prompt Engineer specializing in the ${field} field.
|
||||
Refine the following AI prompt to be more effective, professional, and clear.
|
||||
Maintain the original intent but improve structure, context, and constraints.
|
||||
Please provide the optimized prompt in the same language as the original.
|
||||
|
||||
Original Prompt:
|
||||
"${originalPrompt}"
|
||||
|
||||
Optimized Prompt (Provide only the text of the prompt):`,
|
||||
config: {
|
||||
temperature: 0.7,
|
||||
topP: 0.95,
|
||||
}
|
||||
});
|
||||
|
||||
return response.text || originalPrompt;
|
||||
} catch (error) {
|
||||
console.error("Gemini optimization failed:", error);
|
||||
return originalPrompt;
|
||||
}
|
||||
};
|
||||
|
||||
export const generatePromptTitle = async (content: string): Promise<string> => {
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-3-flash-preview',
|
||||
contents: `다음 AI 프롬프트를 요약하여 3-5단어 내외의 짧고 명확한 한국어 제목으로 만들어주세요:
|
||||
|
||||
"${content}"
|
||||
|
||||
제목:`,
|
||||
config: {
|
||||
maxOutputTokens: 20
|
||||
}
|
||||
});
|
||||
|
||||
return response.text?.trim() || "새 프롬프트";
|
||||
} catch (error) {
|
||||
return "새 프롬프트";
|
||||
}
|
||||
};
|
||||
29
prt/ref/tsconfig.json
Normal file
29
prt/ref/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
35
prt/ref/types.ts
Normal file
35
prt/ref/types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
|
||||
export interface PromptVersion {
|
||||
id: string;
|
||||
promptId: string;
|
||||
content: string;
|
||||
versionNumber: number;
|
||||
changeSummary: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Prompt {
|
||||
id: string;
|
||||
categoryId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
currentVersionId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
children?: Category[];
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
categories: Category[];
|
||||
prompts: Prompt[];
|
||||
versions: PromptVersion[];
|
||||
selectedCategoryId: string | null;
|
||||
selectedPromptId: string | null;
|
||||
}
|
||||
Reference in New Issue
Block a user