sam prompt 모듈 개발

This commit is contained in:
2025-12-22 21:35:17 +09:00
parent af7e373afa
commit f1738953b0
16 changed files with 1819 additions and 0 deletions

24
prt/api/init_db.php Normal file
View 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
View 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
View 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="'&ldquo;' + v.change_summary + '&rdquo;'"></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, '&quot;')})" 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, '&quot;')})" 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
View 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
View 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
View 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`

View 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>
);

View 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;

View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
{
"name": "SAM 프롬프트 관리",
"description": "Codebridge-X의 전문 프롬프트 진화 및 버전 관리 시스템. 코딩 각 분야의 프롬프트를 체계적으로 연구하고 기록합니다.",
"requestFramePermissions": []
}

22
prt/ref/package.json Normal file
View 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"
}
}

View 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
View 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
View 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;
}