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 += '';
}
listHtml += `- ${item}
`;
if (index === listItems.length - 1 || !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本)と仮定」して計算する様子を図で表現しています。
);
};
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 2 */}
{/* Row 3 */}
{/* Row 4 */}
{/* Row 5 */}
);
};
// 🆕 ここに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 */}
>
);
const App = () => {
// 通常モード用のState
const [selectedProblemId, setSelectedProblemId] = useState(JUKEN_PROBLEMS[0].id);
const [loading, setLoading] = useState(false);
const [generatedContent, setGeneratedContent] = useState('');
const [error, setError] = useState(null);
const [showDiagram, setShowDiagram] = useState(false);
// ランダム挑戦モード用のState
const [randomChallengeActive, setRandomChallengeActive] = useState(false);
const [currentChallenge, setCurrentChallenge] = useState(null);
const [isPreloading, setIsPreloading] = useState(false);
const [showChallengeSolution, setShowChallengeSolution] = useState(false);
// チャット機能用のState (MODIFIED for Modal)
const [isChatOpen, setIsChatOpen] = useState(false);
const [chatHistory, setChatHistory] = useState([]);
const [newQuestion, setNewQuestion] = useState('');
const [isChatLoading, setIsChatLoading] = useState(false);
const chatMessagesEndRef = useRef(null);
// 計算機機能用のState (MODIFIED for Modal)
const [isCalculatorOpen, setIsCalculatorOpen] = useState(false);
const selectedProblem = useMemo(() =>
JUKEN_PROBLEMS.find(p => p.id === selectedProblemId) || JUKEN_PROBLEMS[0]
, [selectedProblemId]);
const hasDiagramSupport = PROBLEMS_WITH_DIAGRAM_SUPPORT.includes(selectedProblem.id);
const isTsurukameZan = selectedProblem.id === 1;
// チャットエリアを最下部にスクロール
useEffect(() => {
if (chatMessagesEndRef.current) {
chatMessagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [chatHistory, isChatOpen]);
// --- Core Logic: Solution Generation ---
const generateSolution = useCallback(async (problem, difficulty = 'normal') => {
let promptInstruction;
if (difficulty === 'advanced') {
promptInstruction = `の応用問題を1つ作成し、簡潔な解き方を提供してください。難易度を上げ、複数ステップの問題にしてください。各ステップは見出し形式で表現してください。`;
} else {
promptInstruction = `の類似問題を1つ作成し、簡潔な解き方を提供してください。例題とは数字とシチュエーションを変えてください。`;
}
const initialQuery = `特殊算「${problem.name}」${promptInstruction}\n\n参考例題: ${problem.example}\n\n【注意】簡潔に要点のみ記載してください。`;
try {
const markdown = await callGeminiApi(initialQuery);
return markdown;
} catch (err) {
throw new Error(err.message);
}
}, []);
// --- Regular Mode Handlers ---
const generateInitialSolution = useCallback(async (problem) => {
if (!problem || randomChallengeActive) return;
setLoading(true);
setError(null);
setGeneratedContent('');
setShowDiagram(false);
try {
const promptForExample = `特殊算「${problem.name}」の解き方を簡潔に説明してください。\n\n例題: ${problem.example}\n\n【注意】要点のみを丁寧な言葉で、ステップバイステップで記載してください。各ステップは見出し形式で表現してください。`;
const markdown = await callGeminiApi(promptForExample);
const content = `## 例題\n\n${problem.example}\n\n` + markdown;
setGeneratedContent(content);
setChatHistory([]);
} catch (err) {
const detail = err.message || "原因不明のエラー";
setError(`解法の生成中にエラーが発生しました。詳細: ${detail}`);
} finally {
setLoading(false);
}
}, [randomChallengeActive]);
const generateSimilarProblem = async () => {
if (!selectedProblem) return;
setLoading(true);
setError(null);
setShowDiagram(false);
try {
const markdown = await generateSolution(selectedProblem, 'normal');
const content = "## 類似問題(特訓用)\n\n" + markdown;
setGeneratedContent(content);
setChatHistory([]); // 問題が変わったらチャット履歴をリセット
} catch (err) {
const detail = err.message || "原因不明のエラー";
setError(`類似問題の生成中にエラーが発生しました。詳細: ${detail}`);
} finally {
setLoading(false);
}
};
const generateAdvancedProblem = async () => {
if (!selectedProblem) return;
setLoading(true);
setError(null);
setShowDiagram(false);
try {
const markdown = await generateSolution(selectedProblem, 'advanced');
const content = "## 難問・応用問題(特訓用)\n\n" + markdown;
setGeneratedContent(content);
setChatHistory([]); // 問題が変わったらチャット履歴をリセット
} catch (err) {
const detail = err.message || "原因不明のエラー";
setError(`応用問題の生成中にエラーが発生しました。詳細: ${detail}`);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!randomChallengeActive) {
generateInitialSolution(selectedProblem);
}
}, [selectedProblem, generateInitialSolution, randomChallengeActive]);
// --- Challenge Mode Handlers ---
const startRandomChallenge = useCallback(async () => {
const randomIndex = Math.floor(Math.random() * JUKEN_PROBLEMS.length);
const problem = JUKEN_PROBLEMS[randomIndex];
setRandomChallengeActive(true);
setShowChallengeSolution(false);
setIsPreloading(true);
setError(null);
setCurrentChallenge({ problem, solution: null });
setChatHistory([]); // 問題が変わったらチャット履歴をリセット
try {
const markdown = await generateSolution(problem, 'normal');
// 問題文を抽出
// 例: ## 挑戦問題\n\n...問題文...\n\n### 判別のポイント... の形式から、問題文部分を抽出したい
const problemTextMatch = markdown.match(/^##\s*(?:例題|類似問題(特訓用)|難問・応用問題(特訓用)|挑戦問題)\n\n([\s\S]*?)\n\n/i);
let problemText = problemTextMatch && problemTextMatch[1] ? problemTextMatch[1].trim() : `**問題文の抽出に失敗しました。特殊算:${problem.name}**`;
// 解き方セクションの前に "## 挑戦問題" の見出しを追加
let fullContent = markdown;
if (problemTextMatch) {
// 見出し部分 (例: ## 類似問題(特訓用)) を "## 挑戦問題" に置換し、問題文を前に出す
fullContent = `## 挑戦問題\n\n${problemText}\n\n` + markdown.replace(problemTextMatch[0], "").trim();
} else {
// マッチしなかった場合、全体を挑戦問題とする
fullContent = `## 挑戦問題\n\n` + markdown;
}
setCurrentChallenge({ problem, solution: fullContent });
} catch (err) {
const detail = err.message || "原因不明のエラー";
setError(`問題の生成中にエラーが発生しました。詳細: ${detail}`);
} finally {
setIsPreloading(false);
}
}, [generateSolution]);
const showSolutionHandler = () => {
if (currentChallenge && currentChallenge.solution && !isPreloading) {
setShowChallengeSolution(true);
}
};
const switchToRegularMode = (id) => {
setRandomChallengeActive(false);
setShowChallengeSolution(false);
setCurrentChallenge(null);
setSelectedProblemId(id);
setError(null);
};
// --- Chat Logic ---
const handleChatSubmit = useCallback(async () => {
if (!newQuestion.trim() || isChatLoading) return;
const userMessage = newQuestion.trim();
setIsChatLoading(true);
setNewQuestion('');
// 1. 履歴に追加
const newHistory = [...chatHistory, { role: 'user', text: userMessage }];
setChatHistory(newHistory);
// 2. コンテキストの決定
const currentProblem = randomChallengeActive ? currentChallenge?.problem : selectedProblem;
const problemName = currentProblem?.name || "特殊算";
// 現在表示されている問題文(生成されたコンテンツから抽出、コンテキストとして利用)
let currentProblemText = "";
let contentToProcess = "";
if (!randomChallengeActive && generatedContent) {
contentToProcess = generatedContent;
} else if (randomChallengeActive && currentChallenge && currentChallenge.solution) {
contentToProcess = currentChallenge.solution;
}
if (contentToProcess) {
// 例: ## 例題/類似問題/挑戦問題\n\n[問題文] の部分を抽出
const match = contentToProcess.match(/^##\s*(?:例題|類似問題|難問・応用問題|挑戦問題)[\s\S]*?\n\n([\s\S]*?)(?=\n\n(##|###)|\n*$)/i);
if (match && match[1]) {
// 問題文の最初の500文字程度をコンテキストとして利用
currentProblemText = match[1].trim().substring(0, 500) + (match[1].length > 500 ? "..." : "");
} else {
currentProblemText = contentToProcess.trim().substring(0, 500) + (contentToProcess.length > 500 ? "..." : "");
}
}
const context = `現在の問題は「${problemName}」です。あなたが参考にできる問題の内容は「${currentProblemText}」です。`;
try {
// 3. API呼び出し
const modelResponse = await callChatApi(context, newHistory);
// 4. モデルの応答を履歴に追加
setChatHistory(prev => [
...prev,
{ role: 'model', text: modelResponse }
]);
} catch (err) {
setChatHistory(prev => [
...prev,
{ role: 'model', text: `ごめんなさい、AIチューターとの通信中にエラーが発生しました。${err.message}` }
]);
} finally {
setIsChatLoading(false);
}
}, [newQuestion, isChatLoading, chatHistory, randomChallengeActive, currentChallenge, selectedProblem, generatedContent]);
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); // 改行を抑制
handleChatSubmit();
}
};
// --- Sub Component: Navigation Sidebar ---
const Navigation = () => (
{/* Header/Mode Selector */}
特殊算マスタリー
{/* Problem List */}
{Object.entries(PROBLEM_CATEGORIES).map(([category, problems]) => (
{category}
{problems.map(p => (
-
))}
))}
);
// --- Main Content Render (Unchanged) ---
const RegularModeContent = () => (
<>
{selectedProblem.name} の特訓
{hasDiagramSupport && (
)}
{showDiagram && (
isTsurukameZan ? (
) : (
)
)}
{loading && (
)}
{error && (
)}
{!loading && !error && generatedContent && (
)}
>
);
const ChallengeModeContent = () => {
if (!currentChallenge) {
return
ランダム挑戦モードを開始してください。
;
}
const problem = currentChallenge.problem;
const isReady = currentChallenge.solution !== null && !isPreloading;
let problemTextOnly = "";
let solutionContent = "";
if (isReady && currentChallenge.solution) {
// 解答と問題文を分離
const solutionParts = currentChallenge.solution.split(/^##\s*解き方/m);
problemTextOnly = solutionParts[0].replace(/^##\s*挑戦問題\n\n/i, '').trim();
solutionContent = solutionParts[1] || "";
}
return (
<>
今日のランダム挑戦: {problem.name}
挑戦問題
{isPreloading && (
)}
{isReady && problemTextOnly && (
)}
{showChallengeSolution && currentChallenge.solution && (
)}
>
);
};
const MainContent = () => (
{error && (
)}
{randomChallengeActive ?
:
}
);
// 電卓ボタンのクリックハンドラ。チャットと電卓の同時起動を防止。
const toggleCalculator = () => {
setIsCalculatorOpen(!isCalculatorOpen);
if (isChatOpen) setIsChatOpen(false);
}
// チャットボタンのクリックハンドラ。チャットと電卓の同時起動を防止。
const toggleChat = () => {
setIsChatOpen(!isChatOpen);
if (isCalculatorOpen) setIsCalculatorOpen(false);
}
// --- Main Layout ---
return (
{/* 1. Navigation Sidebar */}
{/* 2. Main Content Area */}
{/* 3. Floating Action Buttons (右下にまとめて配置) */}
{/* 電卓ボタン */}
{/* AIチューターボタン */}
{/* 4. Floating Calculator Panel (Modal) */}
setIsCalculatorOpen(false)} />
{/* 5. Floating Chat UI (Modal) */}
setIsChatOpen(false)}
selectedProblem={selectedProblem}
chatHistory={chatHistory}
isChatLoading={isChatLoading}
newQuestion={newQuestion}
setNewQuestion={setNewQuestion}
handleChatSubmit={handleChatSubmit}
handleKeyPress={handleKeyPress}
chatMessagesEndRef={chatMessagesEndRef}
/>
{/* Mobile Selector (簡略化) */}
);
};
export default App;