import React, { useState, useEffect, useRef } from 'react';
import { Send, Bot, User, Copy, Check, Sparkles, AlertCircle, Wand2, PenTool, Terminal, PanelRightClose, PanelRightOpen } from 'lucide-react';
const apiKey = ""; // APIキーは実行環境から提供されます
// Gemini API呼び出し関数(指数バックオフ付き)
const callGeminiAPI = async (history, systemInstruction) => {
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=${apiKey}`;
const payload = {
systemInstruction: {
parts: [{ text: systemInstruction }]
},
contents: history.map(msg => ({
role: msg.role === 'user' ? 'user' : 'model',
parts: [{ text: msg.text }]
}))
};
const retries = [1000, 2000, 4000, 8000, 16000];
for (let attempt = 0; attempt <= retries.length; attempt++) {
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`API Error: ${response.status}`);
}
const data = await response.json();
return data.candidates?.[0]?.content?.parts?.[0]?.text || "";
} catch (error) {
if (attempt === retries.length) {
throw new Error("通信に失敗しました。時間をおいて再度お試しください。");
}
await new Promise(resolve => setTimeout(resolve, retries[attempt]));
}
}
};
export default function App() {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [canvasContent, setCanvasContent] = useState('');
const [isCopied, setIsCopied] = useState(false);
const [error, setError] = useState('');
const [isCanvasOpen, setIsCanvasOpen] = useState(false);
const messagesEndRef = useRef(null);
// システムプロンプトの定義
const systemPrompt = `
# Role: 対話型メタプロンプト・コンシェルジュ
あなたはプロンプト設計の専門家です。ユーザーのスキルレベルに合わせた2つのモードを駆使し、対話を通じてAIのパフォーマンスを最大化し、かつ安全性の高い「構造化プロンプト」を作成することがあなたの任務です。専門用語を多用する場合は、適宜用語解説が必要かユーザーに確認してください。常に日本語で返答してください。
## 🛡️ システム・ガードレール(コンシェルジュ自身の動作ルール)
ユーザーとの対話およびプロンプト生成において、以下の安全基準を内部で厳守してください。
- **RiskFlag Detected(危険用途の検知)**: ユーザーの目的に倫理的懸念や危険な用途が含まれる場合、安全な方向への修正提案を行い、同意が得られない場合は処理を停止してください。
- **Policy Violation(ポリシー違反)**: 個人情報の不適切な扱いや非合法なタスクの要求は直ちに拒否してください。
- **One-line Output(一行出力の禁止)**: 生成する完成版プロンプトが、1行や極端に短いテキストになることを禁じます。必ず構造化して出力してください。
## ⚙️ 対話のルール(厳守事項)
- ユーザーに一度に複数の質問をしないでください。必ず以下のステップに沿って、1つずつ順番に対話を進行してください。
- ユーザーの回答を待ってから、次のステップやフィードバックに進んでください。
## 🗣️ 対話のステップ
### ステップ 0: モードの選択
(※このステップは初期挨拶として既に完了している前提で進めてください)
---
### 🟢 初心者モードが選ばれた場合
以下の順番で1つずつヒアリングを行います。回答が短い場合は優しく深掘りしてください。
1. **目的**: 「どのようなプロンプトを作成したいですか?」
2. **対象者と役割**: 「誰に向けた出力ですか?また、私(AI)にはどのような役割として振る舞ってほしいですか?」
3. **口調とトーン**: 「出力のトーン&マナーを教えてください。」
4. **出力形式と制約**: 「最後に出力形式や、文字数などのルールがあれば教えてください。」
5. **ガードレール(禁止・優先事項)**: 「AIに『絶対にやってほしくないこと(禁止事項)』や、指示が衝突した際の『最優先ルール(例:正確性より分かりやすさを優先するなど)』はありますか?」
※ステップ5まで完了したら、【📤 最終出力】に進んでください。
---
### 🟡 中級者モードが選ばれた場合
以下の順番で、初稿の分析とブラッシュアップを行います。
1. **初稿の入力**: 「ブラッシュアップしたいプロンプトの初稿を入力してください。」
2. **Analyze & Feedback(解析と改善案の提示)**:
初稿を分析し、以下の3点を提示してください。
- **良い点**: 初稿の優れている部分。
- **改善案とガードレールの提案**: 構造的な改善提案に加え、安全性や禁止事項(ネガティブ・コンストレイント)の不足があれば「〇〇という制限を追加すると安全です」と提案してください。
- **あやふやな部分の確認(質問)**: 1〜2個質問し、初稿の解像度を上げてください。
3. **再構築の確認**: ユーザーが回答したら、「いただいた情報をもとに、プロンプトを最適化して出力してよろしいでしょうか?」と尋ねてください。
※同意が得られたら、【📤 最終出力】に進んでください。
---
## 📤 最終出力(最重要ルール)
情報が出揃い、完成版プロンプトを出力する際は、**必ず以下の構成要素を網羅したテキストを作成し、専用のXMLタグ \`<canvas>\` と \`</canvas>\` で囲んで出力してください。**
システムがこのタグを検知してUI右側のキャンバスエリアに表示します。
【出力するプロンプトの構成】
1. AIの役割設定(Role)
2. コンテキスト・前提条件(Context)
3. 具体的なタスク指示と段階的な思考プロセス(Task & Step-by-Step)
4. 出力のトーンと対象読者(Tone & Audience)
5. 出力フォーマット(Format)
6. **🛡️ ガードレール(Guardrails & Constraints)**
- 禁止事項(ネガティブ・コンストレイント:〇〇は出力しない 等)
- 最優先ルール(矛盾が発生した場合は〇〇を優先する 等)
- 安全基準(必要に応じてプライバシー保護や倫理的配慮の指示)
【出力例】
<canvas>
# Role: ...
# Context: ...
# Task & Step-by-Step: ...
# Tone & Audience: ...
# Format: ...
# 🛡️ Guardrails & Constraints:
- 禁止事項: ...
- 最優先ルール: ...
- 安全基準: ...
</canvas>
`;
const initialGreeting = "こんにちは!プロンプト作成をサポートするコンシェルジュです。\nまずはご希望のモードを選択してください。";
// 初期化
useEffect(() => {
setMessages([
{ role: 'user', text: "プロンプトの作成を始めたいです。挨拶とモード選択の提示をお願いします。", isHidden: true },
{ role: 'model', text: initialGreeting, isHidden: false }
]);
}, []);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages, isLoading]);
const handleSend = async (textToSend = input) => {
const userMessage = textToSend.trim();
if (!userMessage || isLoading) return;
setInput('');
setError('');
const newMessages = [...messages, { role: 'user', text: userMessage, isHidden: false }];
setMessages(newMessages);
setIsLoading(true);
try {
const responseText = await callGeminiAPI(newMessages, systemPrompt);
const canvasRegex = /<canvas>([\s\S]*?)<\/canvas>/;
const match = responseText.match(canvasRegex);
let displayText = responseText;
let newCanvasContent = canvasContent;
if (match) {
newCanvasContent = match[1].trim();
setCanvasContent(newCanvasContent);
setIsCanvasOpen(true); // プロンプト生成時に自動でエディタを開く
displayText = responseText.replace(canvasRegex, "").trim();
if (displayText === "") {
displayText = "✨ プロンプトを作成し、右側のエディタに出力しました!内容をご確認ください。\nさらに調整したい箇所があればお知らせください。";
}
}
setMessages([...newMessages, { role: 'model', text: displayText, isHidden: false }]);
} catch (err) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
const handleModeSelect = (modeStr) => {
handleSend(modeStr);
};
const handleKeyDown = (e) => {
// IME入力中(変換中)は判定をスキップ
if (e.nativeEvent.isComposing) return;
// Shift + Enterで送信
if (e.key === 'Enter' && e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const copyToClipboard = () => {
if (canvasContent) {
document.execCommand('copy');
const textArea = document.createElement("textarea");
textArea.value = canvasContent;
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} catch (err) {
console.error('コピーに失敗しました', err);
}
document.body.removeChild(textArea);
}
};
const renderMessage = (text) => {
if (!text) return null;
const parts = text.split(/(\*\*.*?\*\*)/g);
return parts.map((part, i) => {
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={i} className="font-semibold text-indigo-600 dark:text-indigo-400">{part.slice(2, -2)}</strong>;
}
return <span key={i}>{part.split('\n').map((line, j) => <React.Fragment key={j}>{line}<br/></React.Fragment>)}</span>;
});
};
const showModeSelection = messages.length === 2 && !isLoading;
return (
<div className="flex flex-col md:flex-row h-screen w-full bg-zinc-50 dark:bg-zinc-950 font-sans text-zinc-800 dark:text-zinc-200 selection:bg-indigo-500/30">
{/* 左ペイン: チャットエリア */}
<div className={`flex flex-col border-zinc-200 dark:border-zinc-800 max-h-screen relative overflow-hidden bg-white/50 dark:bg-zinc-900/50 backdrop-blur-xl transition-all duration-300 ${isCanvasOpen ? 'hidden md:flex md:w-1/2 border-r' : 'w-full'}`}>
<header className="px-6 py-5 border-b border-zinc-200/80 dark:border-zinc-800/80 bg-white/80 dark:bg-zinc-900/80 backdrop-blur-md flex items-center justify-between z-10 sticky top-0">
<div className="flex items-center">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center shadow-lg shadow-indigo-500/20 mr-4">
<Sparkles className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="text-lg font-bold bg-clip-text text-transparent bg-gradient-to-r from-zinc-800 to-zinc-500 dark:from-white dark:to-zinc-400">
Prompt Concierge
</h1>
<p className="text-xs text-zinc-500 dark:text-zinc-400 font-medium tracking-wide">AI-POWERED ASSISTANT</p>
</div>
</div>
<button
onClick={() => setIsCanvasOpen(!isCanvasOpen)}
className="p-2 rounded-xl text-zinc-500 hover:bg-zinc-200 dark:hover:bg-zinc-800 hover:text-zinc-800 dark:hover:text-zinc-200 transition-colors flex items-center"
title={isCanvasOpen ? "エディタを閉じる" : "エディタを開く"}
>
{isCanvasOpen ? <PanelRightClose size={22} /> : <PanelRightOpen size={22} />}
{!isCanvasOpen && <span className="ml-2 text-sm font-semibold hidden sm:block">エディタを表示</span>}
</button>
</header>
<div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-6 scroll-smooth">
{messages.filter(m => !m.isHidden).map((msg, index) => (
<div key={index} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} animate-in fade-in slide-in-from-bottom-2 duration-300`}>
<div className={`flex max-w-[85%] ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'}`}>
<div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center mt-1 shadow-sm
${msg.role === 'user'
? 'bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 ml-3'
: 'bg-gradient-to-br from-indigo-500 to-purple-600 text-white mr-3'}`}>
{msg.role === 'user' ? <User size={16} /> : <Bot size={16} />}
</div>
<div className={`p-4 rounded-2xl shadow-sm border
${msg.role === 'user'
? 'bg-gradient-to-br from-zinc-800 to-zinc-700 dark:from-zinc-100 dark:to-zinc-300 text-white dark:text-zinc-900 border-transparent rounded-tr-sm'
: 'bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 text-zinc-700 dark:text-zinc-300 rounded-tl-sm'}`}>
<div className={`text-sm leading-relaxed whitespace-pre-wrap
${msg.role === 'user' ? 'text-zinc-100 dark:text-zinc-800' : ''}`}>
{msg.role === 'user' ? msg.text : renderMessage(msg.text)}
</div>
</div>
</div>
</div>
))}
{/* モード選択カード(初期状態のみ表示) */}
{showModeSelection && (
<div className="flex flex-col sm:flex-row gap-4 justify-center mt-8 animate-in fade-in zoom-in-95 duration-500 pl-11">
<button
onClick={() => handleModeSelect('1. 初心者モード')}
className="group relative flex-1 p-6 rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 hover:border-indigo-500 dark:hover:border-indigo-400 shadow-sm hover:shadow-md transition-all text-left overflow-hidden"
>
<div className="absolute inset-0 bg-gradient-to-br from-indigo-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="relative z-10">
<div className="w-10 h-10 rounded-full bg-indigo-100 dark:bg-indigo-500/20 text-indigo-600 dark:text-indigo-400 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
<Wand2 size={20} />
</div>
<h3 className="text-base font-bold text-zinc-800 dark:text-zinc-100 mb-1">1. 初心者モード</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400 leading-relaxed">
ゼロから一緒にプロンプトを作ります。対話形式でヒアリングを行います。
</p>
</div>
</button>
<button
onClick={() => handleModeSelect('2. 中級者モード')}
className="group relative flex-1 p-6 rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 hover:border-purple-500 dark:hover:border-purple-400 shadow-sm hover:shadow-md transition-all text-left overflow-hidden"
>
<div className="absolute inset-0 bg-gradient-to-br from-purple-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="relative z-10">
<div className="w-10 h-10 rounded-full bg-purple-100 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
<PenTool size={20} />
</div>
<h3 className="text-base font-bold text-zinc-800 dark:text-zinc-100 mb-1">2. 中級者モード</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400 leading-relaxed">
既存の初稿をベースに、AI視点での改善や安全性のチェックを行います。
</p>
</div>
</button>
</div>
)}
{isLoading && (
<div className="flex justify-start animate-in fade-in duration-300">
<div className="flex max-w-[85%] flex-row">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 text-white mr-3 flex items-center justify-center mt-1 shadow-sm">
<Bot size={16} />
</div>
<div className="p-4 rounded-2xl rounded-tl-sm bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 flex items-center space-x-2 shadow-sm">
<div className="w-2 h-2 bg-zinc-400 dark:bg-zinc-500 rounded-full animate-pulse"></div>
<div className="w-2 h-2 bg-zinc-400 dark:bg-zinc-500 rounded-full animate-pulse delay-150"></div>
<div className="w-2 h-2 bg-zinc-400 dark:bg-zinc-500 rounded-full animate-pulse delay-300"></div>
</div>
</div>
</div>
)}
{error && (
<div className="flex items-center text-red-600 dark:text-red-400 text-sm p-4 bg-red-50 dark:bg-red-900/20 border border-red-100 dark:border-red-900/50 rounded-2xl mx-11">
<AlertCircle size={18} className="mr-2 flex-shrink-0" />
<span>{error}</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="p-4 md:p-6 bg-white/80 dark:bg-zinc-900/80 backdrop-blur-md border-t border-zinc-200/80 dark:border-zinc-800/80 z-10">
<div className="relative flex items-end shadow-sm rounded-2xl bg-zinc-50 dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 focus-within:ring-2 focus-within:ring-indigo-500/50 focus-within:border-indigo-500 transition-all">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="メッセージを入力... (Shift+Enterで送信)"
className="w-full max-h-[160px] pl-4 pr-14 py-3.5 bg-transparent border-transparent rounded-2xl focus:ring-0 resize-none overflow-y-auto text-sm text-zinc-800 dark:text-zinc-200 placeholder-zinc-400 dark:placeholder-zinc-600 outline-none"
rows={1}
style={{ minHeight: '52px' }}
disabled={isLoading || showModeSelection}
/>
<button
onClick={() => handleSend()}
disabled={!input.trim() || isLoading || showModeSelection}
className="absolute right-2 bottom-2 p-2 text-white bg-indigo-600 rounded-xl hover:bg-indigo-700 disabled:bg-zinc-300 dark:disabled:bg-zinc-800 disabled:text-zinc-500 transition-colors shadow-sm"
>
<Send size={18} className={input.trim() && !isLoading ? "translate-x-0.5 -translate-y-0.5 transition-transform" : ""} />
</button>
</div>
</div>
</div>
{/* 右ペイン: Canvasエリア (エディタ風) */}
<div className={`flex-col max-h-screen bg-zinc-950 text-zinc-300 relative ${isCanvasOpen ? 'flex w-full md:w-1/2' : 'hidden'}`}>
<header className="px-6 py-5 border-b border-zinc-800/80 bg-zinc-950 flex justify-between items-center z-10">
<div className="flex items-center space-x-3">
{/* モバイル用の「戻る」ボタン */}
<button
onClick={() => setIsCanvasOpen(false)}
className="md:hidden p-1.5 text-zinc-400 hover:text-white bg-zinc-800/50 rounded-md transition-colors"
>
<PanelRightClose size={16} className="rotate-180" />
</button>
<Terminal size={18} className="text-zinc-500" />
<h2 className="text-sm font-mono tracking-wider text-zinc-400">output.prompt</h2>
</div>
<button
onClick={copyToClipboard}
disabled={!canvasContent}
className="flex items-center px-3 py-1.5 text-xs font-mono font-medium text-zinc-300 bg-zinc-800/50 border border-zinc-700/50 rounded-md hover:bg-zinc-800 hover:text-white transition-all disabled:opacity-50 disabled:hover:bg-zinc-800/50"
>
{isCopied ? <Check size={14} className="mr-2 text-emerald-400" /> : <Copy size={14} className="mr-2" />}
{isCopied ? 'COPIED' : 'COPY'}
</button>
</header>
<div className="flex-1 p-0 overflow-hidden flex flex-col relative bg-[#0d0d12]">
{/* 行番号風の装飾 (視覚的アクセント) */}
<div className="absolute left-0 top-0 bottom-0 w-12 bg-zinc-950/50 border-r border-zinc-800/50 pointer-events-none z-0 hidden md:block"></div>
{canvasContent ? (
<textarea
value={canvasContent}
onChange={(e) => setCanvasContent(e.target.value)}
className="w-full flex-1 p-6 md:pl-16 font-mono text-sm leading-relaxed bg-transparent resize-none outline-none text-zinc-200 z-10 custom-scrollbar focus:ring-0 border-none"
placeholder="生成されたプロンプトがここに表示されます..."
spellCheck="false"
/>
) : (
<div className="w-full flex-1 flex flex-col items-center justify-center z-10 p-6">
<div className="w-20 h-20 rounded-2xl border border-zinc-800/80 bg-zinc-900/30 flex items-center justify-center mb-6 shadow-inner">
<Terminal size={32} className="text-zinc-700" />
</div>
<p className="text-zinc-500 font-mono text-sm tracking-wide text-center">
// The generated prompt will appear here.
</p>
<p className="text-zinc-600 font-mono text-xs mt-3 text-center max-w-sm leading-relaxed">
Awaiting interaction in the chat panel...
</p>
</div>
)}
</div>
{/* CSS for custom scrollbar in editor */}
<style dangerouslySetInnerHTML={{__html: `
.custom-scrollbar::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 5px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
`}} />
</div>
</div>
);
}
Total Installments (before discounts)