import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Loader2, Zap, Brain, MessageSquareText, TrendingUp, Presentation, RefreshCw, Eye, BookOpen, Shuffle, X, Send, CornerDownLeft, Divide, Minus, Plus, Equal, Hash, Percent } from 'lucide-react'; // --- Global Configuration --- const API_KEY = ""; // APIキーは空文字列のままにしておきます const API_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=${API_KEY}`; // --- Special Arithmetic Problems Data (特殊算24種類) --- const JUKEN_PROBLEMS = [ // 1. 面積図で解く (6種類) { id: 1, category: "面積図で解く", name: "鶴亀算(つるかめざん)", example: "鶴と亀が合わせて15匹います。足の総数は44本です。鶴は何匹ですか?" }, { id: 2, category: "面積図で解く", name: "3段鶴亀算", example: "1個50円、80円、100円の3種類のアメを合計12個買いました。合計金額は810円で、100円のアメは50円のアメより1個多く買いました。80円のアメは何個買いましたか?" }, { id: 3, category: "面積図で解く", name: "いもづる算", example: "1個80円のりんごと1個120円のナシを合わせて1000円分買います。買い方は何通りありますか?" }, { id: 4, category: "面積図で解く", name: "弁償算", example: "ある仕事で、成功すると200円もらえ、失敗すると100円払わなければなりません。20回挑戦して合計1400円もらえました。失敗は何回しましたか?" }, { id: 5, category: "面積図で解く", name: "濃度算", example: "5%の食塩水200gと、10%の食塩水300gを混ぜると、何%の食塩水ができますか?" }, { id: 6, category: "面積図で解く", name: "平均算", example: "男子10人の平均点が70点、女子15人の平均点が80点でした。クラス全体の平均点は何点ですか?" }, // 2. 線分図で解く (6種類) { id: 7, category: "線分図で解く", name: "和差算", example: "大小2つの数があり、その和は50、差は12です。大きい方の数はいくつですか?" }, { id: 8, category: "線分図で解く", name: "年齢算", example: "現在、父は40歳、娘は10歳です。父の年齢が娘の年齢の3倍になるのは何年後ですか?" }, { id: 9, category: "線分図で解く", name: "分配算", example: "1200円を兄と弟で3:2の割合で分けます。兄はいくらもらえますか?" }, { id: 10, category: "線分図で解く", name: "相当算", example: "ある本を読み終えるのに、まず全体の1/3を読み、次に残りの1/4を読んだら、残りページが60ページになりました。この本は全部で何ページありますか?" }, { id: 11, category: "線分図で解く", name: "倍数算", example: "現在、兄と弟の持っているお金の比は5:3ですが、2人とも300円ずつ使ったので、比は4:2になりました。現在、兄はいくら持っていますか?" }, { id: 12, category: "線分図で解く", name: "損益算", example: "仕入れ値1000円の商品に3割の利益を見込んで定価をつけましたが、売れないので定価の1割引で売りました。利益はいくらですか?" }, // 3. 図を描く (3種類) { id: 13, category: "図を描く", name: "方陣算", example: "1辺に9個ずつ碁石を並べて正方形の形(中空でない)を作ります。碁石は全部で何個必要ですか?" }, { id: 14, category: "図を描く", name: "時計算", example: "午後4時ちょうどの時、時計の長針と短針が作る角のうち、小さい方の角度は何°ですか?" }, { id: 15, category: "図を描く", name: "植木算", example: "100mの道の片側に、最初と最後に木を植えるとき、2m間隔で植えると木は何本必要ですか?" }, // 4. 速さの公式 (3種類) { id: 16, category: "速さの公式", name: "旅人算", example: "A地点からB地点まで1200mあります。Aさんが分速80m、Bさんが分速60mで向かい合って同時に出発すると、何分後にすれ違いますか?" }, { id: 17, category: "速さの公式", name: "流水算", example: "静水での速さが時速10kmの船が、時速2kmの流れの川を上り、4時間かかりました。船が移動した距離は何kmですか?" }, { id: 18, category: "速さの公式", name: "通過算", example: "長さ150m、秒速20mの列車が、長さ450mのトンネルを完全に通過するのに何秒かかりますか?" }, // 5. 仕事の公式 (3種類) { id: 19, category: "仕事の公式", name: "仕事算", example: "ある仕事をするのに、Aさんだけでは15日、Bさんだけでは10日かかります。この仕事を2人一緒に行うと何日で終わりますか?" }, { id: 20, category: "仕事の公式", name: "のべ算", example: "ある作業を終えるのに、大人5人で10日かかります。同じ作業を大人2人と子ども6人で行うと12日かかります。子ども1人で作業を終えるのに何日かかりますか?" }, { id: 21, category: "仕事の公式", name: "ニュートン算", example: "牧場に20頭の牛を放牧すると60日で草を食べ尽くし、15頭の牛を放牧すると120日で草を食べ尽くします。8頭の牛を放牧すると何日で草を食べ尽くしますか?" }, // 6. その他 (3種類) { id: 22, category: "その他", name: "消去算", example: "りんご3個とみかん5個の代金が850円、りんご3個とみかん8個の代金が1150円でした。りんご1個の値段はいくらですか?" }, { id: 23, category: "その他", name: "集合算", example: "あるクラス25人のうち、犬を飼っている人が15人、猫を飼っている人が10人、両方飼っている人が3人いました。犬も猫も飼っていない人は何人ですか?" }, { id: 24, category: "その他", name: "差集め算", example: "子どもたちにアメを配ります。1人に5個ずつ配ると10個余り、1人に7個ずつ配ると8個足りません。子どもの人数は何人ですか?" }, ]; // 図解ボタンを表示する特殊算のIDリスト(面積図、線分図、差集め算) const PROBLEMS_WITH_DIAGRAM_SUPPORT = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 24]; // データをカテゴリーごとにグルーピング const PROBLEM_CATEGORIES = JUKEN_PROBLEMS.reduce((acc, problem) => { if (!acc[problem.category]) { acc[problem.category] = []; } acc[problem.category].push(problem); return acc; }, {}); // --- 修正版: API Helper Function (For Problem/Solution Generation) --- const SYSTEM_PROMPT = `あなたは中学受験算数の専門チューターです。簡潔で明確な解説を作成してください。 【出力形式】 - 日本語のMarkdown形式 - 見出しは ## と ### のみ - LaTeX記号($, \\, {, })は使用禁止 - 数式は普通の日本語と数字で表現(例: x + y = 50) 【必須構成】 ### 判別のポイント この特殊算を見抜くヒントを2-3行で説明 ## 解き方 ### 図を用いる際の視点と工夫 よく使われる図(線分図、面積図など)の書き方を簡潔に説明 ステップ1: [計算手順] ステップ2: [計算手順] 答え: [最終答え] 【重要】 - 冗長な説明は避けながらも、丁寧な言葉で、要点のみ記載 - 各ステップは見出しで表現し、1-2行で簡潔に - 不要な挨拶や補足説明は省略`; // --- 修正版: 指数バックオフ付きAPI呼び出し (問題生成用) --- const callGeminiApi = async (userQuery, retries = 3) => { for (let i = 0; i < retries; i++) { try { const payload = { contents: [{ parts: [{ text: userQuery }] }], systemInstruction: { parts: [{ text: SYSTEM_PROMPT }] }, generationConfig: { temperature: 0.7, topK: 40, topP: 0.95, maxOutputTokens: 2048, // 🔧 1024 → 2048に変更 candidateCount: 1, }, // 🆕 安全性設定を追加 safetySettings: [ { category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_MEDIUM_AND_ABOVE" }, { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_MEDIUM_AND_ABOVE" }, { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_MEDIUM_AND_ABOVE" }, { category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_MEDIUM_AND_ABOVE" } ] }; const response = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!response.ok) { const errorBody = await response.text(); console.error(`Attempt ${i + 1} failed. HTTP status: ${response.status}. Body: ${errorBody}`); throw new Error(`HTTP error! status: ${response.status}`); } const result = await response.json(); // 🆕 詳細なエラーログを追加 console.log('API Response:', JSON.stringify(result, null, 2)); const candidate = result.candidates?.[0]; const text = candidate?.content?.parts?.[0]?.text; if (!text) { // 🆕 より詳細なエラー情報を出力 const blockReason = result.promptFeedback?.blockReason || 'NONE'; const finishReason = candidate?.finishReason || 'UNKNOWN'; const safetyRatings = candidate?.safetyRatings || []; console.error('=== API Response Debug Info ==='); console.error('Block Reason:', blockReason); console.error('Finish Reason:', finishReason); console.error('Safety Ratings:', JSON.stringify(safetyRatings, null, 2)); console.error('Full Result:', JSON.stringify(result, null, 2)); let errorMessage = `API応答が空です。`; if (blockReason !== 'NONE') { errorMessage += ` プロンプトがブロックされました(理由: ${blockReason})。`; } if (finishReason === 'SAFETY') { errorMessage += ` 安全性フィルターにより生成が停止されました。`; } else if (finishReason === 'MAX_TOKENS') { errorMessage += ` 最大トークン数に達しました。`; } else if (finishReason === 'RECITATION') { errorMessage += ` 著作権コンテンツの可能性により停止されました。`; } throw new Error(errorMessage); } const cleanedText = postProcessAIResponse(text); return cleanedText; } catch (err) { console.error(`Attempt ${i + 1} error:`, err.message); if (i < retries - 1) { const delay = Math.pow(2, i) * 1000 + Math.random() * 500; console.log(`Retrying in ${Math.round(delay)}ms...`); await new Promise(resolve => setTimeout(resolve, delay)); } else { throw new Error(err.message); } } } }; // --- 🆕 新規追加: AI応答の後処理関数 --- const postProcessAIResponse = (text) => { let cleaned = text; // 1. LaTeX形式の数式を削除・変換 // display math $$...$$ cleaned = cleaned.replace(/\$\$([^$]+)\$\$/g, (match, content) => { return cleanupLatexMath(content); }); // inline math $...$ cleaned = cleaned.replace(/\$([^$]+)\$/g, (match, content) => { return cleanupLatexMath(content); }); // 2. LaTeXコマンドの残骸を削除 cleaned = cleaned.replace(/\\[a-zA-Z]+\{([^}]*)\}/g, '$1'); // \text{...}, \frac{...} など cleaned = cleaned.replace(/\\[a-zA-Z]+/g, ''); // \times, \div など cleaned = cleaned.replace(/[{}]/g, ''); // 残った中括弧 // 3. Markdown記号の正規化 cleaned = cleaned.replace(/#{4,}/g, '###'); // #### 以上を ### に統一 // 4. コードブロック記号の削除(```の誤使用を防ぐ) cleaned = cleaned.replace(/```[a-z]*\n?/g, ''); // 5. 過剰な空行を削減 cleaned = cleaned.replace(/\n{4,}/g, '\n\n\n'); // 6. 特殊文字のエスケープ解除 cleaned = cleaned.replace(/</g, '<'); cleaned = cleaned.replace(/>/g, '>'); cleaned = cleaned.replace(/&/g, '&'); return cleaned.trim(); }; // --- 修正版: チャット用API呼び出し --- const callChatApi = async (context, history, retries = 3) => { const CHAT_SYSTEM_PROMPT = `中学受験算数チューターとして、簡潔に答えてください。 【ルール】 - LaTeX記号禁止($, \\, {, }) - 数式は普通の日本語表記(例: x + y = 50) - 質問に対して直接的に回答(挨拶不要) - 1-3文で完結させる ${context}`; const contents = history.map(msg => ({ role: msg.role === 'model' ? 'model' : 'user', parts: [{ text: msg.text }] })); for (let i = 0; i < retries; i++) { try { const payload = { contents: contents, systemInstruction: { parts: [{ text: CHAT_SYSTEM_PROMPT }] }, generationConfig: { temperature: 0.8, topK: 40, topP: 0.95, maxOutputTokens: 1024, candidateCount: 1, }, // 🆕 安全性設定を追加 safetySettings: [ { category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_MEDIUM_AND_ABOVE" }, { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_MEDIUM_AND_ABOVE" }, { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_MEDIUM_AND_ABOVE" }, { category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_MEDIUM_AND_ABOVE" } ] }; const response = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!response.ok) { const errorBody = await response.text(); console.error(`Chat API Attempt ${i + 1} failed. HTTP status: ${response.status}. Body: ${errorBody}`); throw new Error(`Chat HTTP error! status: ${response.status}`); } const result = await response.json(); // 🆕 詳細なログ console.log('Chat API Response:', JSON.stringify(result, null, 2)); const candidate = result.candidates?.[0]; const text = candidate?.content?.parts?.[0]?.text; if (!text) { const blockReason = result.promptFeedback?.blockReason || 'NONE'; const finishReason = candidate?.finishReason || 'UNKNOWN'; console.error('=== Chat API Debug Info ==='); console.error('Block Reason:', blockReason); console.error('Finish Reason:', finishReason); console.error('Full Result:', JSON.stringify(result, null, 2)); throw new Error(`チャットAPI応答が空です(理由: ${finishReason})`); } const cleanedText = postProcessAIResponse(text); return cleanedText; } catch (err) { console.error(`Chat API Attempt ${i + 1} error:`, err.message); if (i < retries - 1) { const delay = Math.pow(2, i) * 1000 + Math.random() * 500; await new Promise(resolve => setTimeout(resolve, delay)); } else { throw new Error(err.message); } } } }; // --- 修正版: LaTeXクリーンアップ関数(より厳密に) --- const cleanupLatexMath = (latexString) => { let cleaned = latexString; // 1. 一般的なLaTeXコマンドを置換 const replacements = { '\\times': '×', '\\div': '÷', '\\cdot': '・', '\\approx': '≒', '\\geq': '≧', '\\leq': '≦', '\\ge': '≧', '\\le': '≦', '\\neq': '≠', '\\cdots': '...', '\\ldots': '...', '\\quad': ' ', '\\qquad': ' ', '\\,': ' ', '\\:': ' ', '\\;': ' ', '\\!': '', }; for (const [latex, symbol] of Object.entries(replacements)) { cleaned = cleaned.replace(new RegExp(latex.replace(/\\/g, '\\\\'), 'g'), symbol); } // 2. \text{...} の中身を抽出 cleaned = cleaned.replace(/\\text\{([^}]*)\}/g, '$1'); // 3. \frac{分子}{分母} を (分子/分母) に変換 cleaned = cleaned.replace(/\\frac\{([^}]+)\}\{([^}]+)\}/g, '($1/$2)'); // 4. 上付き・下付き文字の処理 cleaned = cleaned.replace(/\^(\{[^}]+\}|\w)/g, (match, p1) => { const content = p1.replace(/[{}]/g, ''); return `^${content}`; }); cleaned = cleaned.replace(/_(\{[^}]+\}|\w)/g, (match, p1) => { const content = p1.replace(/[{}]/g, ''); return `_${content}`; }); // 5. 残ったバックスラッシュコマンドを削除 cleaned = cleaned.replace(/\\[a-zA-Z]+/g, ''); // 6. 中括弧とバックスラッシュを削除 cleaned = cleaned.replace(/[{}\\]/g, ''); // 7. 連続スペースを1つに cleaned = cleaned.replace(/\s+/g, ' '); return cleaned.trim(); }; // --- 修正版: Markdown→HTML変換関数(処理順序を最適化) --- const convertMarkdownToHtml = (markdown) => { let html = markdown; // 0. 前処理: すでに残っているLaTeX記号をクリーンアップ(念のため) html = html.replace(/\$\$([^$]+)\$\$/g, (match, content) => { return `
${cleanupLatexMath(content)}
`; }); html = html.replace(/\$([^$]+)\$/g, (match, content) => { const cleaned = cleanupLatexMath(content); // 演算子のみの場合は装飾なし if (/^[×÷+\-=・×÷≒≧≦]+$/.test(cleaned)) { return cleaned; } return `${cleaned}`; }); // 1. 見出しの変換(##と###のみ) html = html.replace(/^##\s*(.*)$/gm, '

$1

'); html = html.replace(/^###\s*(.*)$/gm, '

💡$1

'); // 2. 太字の変換 html = html.replace(/\*\*(.*?)\*\*/g, '$1'); // 3. リストの変換 const listItems = []; html = html.replace(/^[\*\-]\s+(.+)$/gm, (match, content) => { listItems.push(content); return `__LIST_ITEM__${listItems.length - 1}__`; }); // リストアイテムをulでグループ化 let listHtml = ''; listItems.forEach((item, index) => { if (index === 0 || !html.includes(`__LIST_ITEM__${index - 1}__`)) { listHtml += ''; } html = html.replace(`__LIST_ITEM__${index}__`, index === 0 || !html.includes(`__LIST_ITEM__${index - 1}__`) ? listHtml : ''); if (index === listItems.length - 1 || !html.includes(`__LIST_ITEM__${index + 1}__`)) { listHtml = ''; } }); // 4. 段落の変換 html = html.replace(/\n\n+/g, '

'); html = html.replace(/\n/g, '
'); // 5. 最初のタグ処理 if (!html.match(/^<[hpud]/)) { html = `

${html}

`; } // 6. 不要な連続
を削除 html = html.replace(/\s*/g, '
'); // 7. 閉じタグの修正 html = html.replace(/<\/h2>

{ const turtleCount = (actualTotal - (totalUnits * assumedRate)) / differenceRate; const craneCount = totalUnits - turtleCount; const totalWidth = 300; const totalHeight = 200; const padding = 20; const assumedAreaHeight = 80; const differenceAreaHeight = 60; const unitScale = totalWidth / totalUnits; const turtleWidth = turtleCount * unitScale; const craneWidth = craneCount * unitScale; const actualDifference = actualTotal - (totalUnits * assumedRate); return (

鶴亀算 面積図(仮定法)

「全てを鶴(足2本)と仮定」して計算する様子を図で表現しています。

仮定の面積 = {totalUnits}匹 × {assumedRate}本 = {totalUnits * assumedRate}本 差分 ({actualDifference}) {actualDifference}本 高さ ({differenceRate}本) 鶴 2本 亀 4本 合計 {totalUnits}匹 鶴 {craneCount}匹 亀 {turtleCount}匹 実際の足の総数:44本
); }; const DiagramPlaceholder = ({ problemName, category }) => { let diagramType = "線分図"; if (category.includes("面積図") || problemName.includes("鶴亀算") || problemName.includes("差集め算")) { diagramType = "面積図"; } return (

{problemName} の図解 ({diagramType})

**現在、この特殊算のインタラクティブな図解は準備中です。**

解説内の「図を用いる際の視点と工夫」を参考に、ご自身で{diagramType}を書いてみてください!図解の練習は計算力以上に重要です。

); }; // --- Floating Calculator Component (MODIFIED to Modal) --- const FloatingCalculator = ({ isOpen, onClose }) => { const [display, setDisplay] = useState('0'); const [currentValue, setCurrentValue] = useState(null); const [operator, setOperator] = useState(null); const [waitingForNewValue, setWaitingForNewValue] = useState(false); // 数字/小数点入力 const inputDigit = (digit) => { if (waitingForNewValue) { setDisplay(String(digit)); setWaitingForNewValue(false); } else { if (digit === '.') { if (!display.includes('.')) { setDisplay(display + '.'); } } else if (display === '0') { setDisplay(String(digit)); } else { setDisplay(display + String(digit)); } } }; // 演算子処理 const handleOperator = (nextOperator) => { const inputVal = parseFloat(display); if (currentValue === null) { setCurrentValue(inputVal); } else if (operator) { const result = calculate(currentValue, inputVal, operator); setDisplay(String(parseFloat(result.toFixed(10)))); // 精度を保持しつつ表示 setCurrentValue(result); } setWaitingForNewValue(true); setOperator(nextOperator); }; // 計算実行 const calculate = (prev, next, op) => { switch (op) { case '+': return prev + next; case '-': return prev - next; case '*': return prev * next; case '/': if (next === 0) return NaN; // ゼロ除算 return prev / next; default: return next; } }; // イコール(=) const handleEquals = () => { const inputVal = parseFloat(display); if (currentValue === null || operator === null) return; const result = calculate(currentValue, inputVal, operator); setDisplay(String(parseFloat(result.toFixed(10)))); setCurrentValue(null); setOperator(null); setWaitingForNewValue(true); }; // クリア(AC) const clear = () => { setDisplay('0'); setCurrentValue(null); setOperator(null); setWaitingForNewValue(false); }; // プラスマイナス(±) const toggleSign = () => { setDisplay(prev => String(parseFloat(prev) * -1)); }; // パーセンテージ(%) const inputPercent = () => { setDisplay(prev => String(parseFloat(prev) / 100)); setWaitingForNewValue(true); }; // ボタンコンポーネント const Button = ({ value, className, onClick, icon: Icon, children }) => ( ); return ( // モーダルコンテナ (画面全体を覆う)
{/* Backdrop */}
{/* Calculator Content */}
e.stopPropagation()} // モーダル外クリックで閉じないように >

計算機

{/* Display */}
{display.length > 12 ? parseFloat(display).toExponential(5) : display}
{/* Keypad */}
{/* Row 1 */} {/* Row 3 */}
); }; // 🆕 ここにFloatingChatを追加(Appの外側) const FloatingChat = ({ isOpen, onClose, selectedProblem, chatHistory, isChatLoading, newQuestion, setNewQuestion, handleChatSubmit, handleKeyPress, chatMessagesEndRef }) => ( <> {/* 2. Chat Modal/Panel (MODIFIED) */}
{/* Backdrop */}
{/* Chat Content */}
e.stopPropagation()} > {/* Header */}

特殊算AIチューター

{/* Messages Body */}
{chatHistory.length === 0 ? (

「{selectedProblem.name}」について、わからないことを質問してみましょう!
(例:「判別のポイントは何ですか?」「この数字の意味は?」)

) : ( chatHistory.map((msg, index) => (
)) )} {isChatLoading && (
チューターが考えています...
)}
{/* Input Area */}