gemini-chat

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

commit b8509512d0d7515f552697a48429df2c422ab0e0
parent 30c787061dfaf174a7715b4cb31fb2579b3d3227
Author: Minerva_juppiter <94231606+minerva-jupiter@users.noreply.github.com>
Date:   Sat, 27 Sep 2025 17:57:08 +0900

Merge pull request #1 from minerva-jupiter/client-only

client-only equip
Diffstat:
Dapp/api/chat/route.ts | 57---------------------------------------------------------
Mapp/page.module.css | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/page.tsx | 289+++++++++++++++++++++++++++++++++++--------------------------------------------
Mbun.lock | 3+++
Mpackage.json | 1+
5 files changed, 284 insertions(+), 218 deletions(-)

diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts @@ -1,57 +0,0 @@ -import { GoogleGenAI } from "@google/genai"; -import { NextRequest, NextResponse } from "next/server"; - -// 環境変数からAPIキーを取得 -const apiKey = process.env.GEMINI_API_KEY; -if (!apiKey) { - throw new Error("GEMINI_API_KEY is not set"); -} -const ai = new GoogleGenAI({ apiKey: apiKey }); - -// システムプロンプトを定義 -const SYSTEM_INSTRUCTION = - "あなたは、ユーザーの質問に丁寧かつ簡潔に答えるフレンドリーなアシスタントです。回答は日本語で行い、語尾ににゃんをつけてください。"; - -export async function POST(req: NextRequest) { - try { - const { prompt } = await req.json(); - - // ユーザーのプロンプトをコンテンツとして設定 - const contents = [{ role: "user", parts: [{ text: prompt }] }]; - - // API呼び出し - const responseStream = await ai.models.generateContentStream({ - model: "gemini-2.5-flash", // 使用するモデル - contents: contents, - config: { - // システムプロンプトを 'systemInstruction' として設定 - systemInstruction: SYSTEM_INSTRUCTION, - }, - }); - - // レスポンスをストリーミングするためのカスタムレスポンス - const stream = new ReadableStream({ - async start(controller) { - for await (const chunk of responseStream) { - // テキストチャンクをエンコードしてクライアントに送信 - const text = chunk.text; - controller.enqueue(new TextEncoder().encode(text)); - } - controller.close(); - }, - }); - - return new NextResponse(stream, { - headers: { - "Content-Type": "text/plain; charset=utf-8", - "X-Content-Type-Options": "nosniff", - }, - }); - } catch (error) { - console.error("API Error:", error); - return NextResponse.json( - { error: "Failed to generate content" }, - { status: 500 }, - ); - } -} diff --git a/app/page.module.css b/app/page.module.css @@ -0,0 +1,152 @@ +/* page.module.css */ +.chatContainer { + display: flex; + flex-direction: column; + height: 100vh; + background: #fafafa; + font-family: system-ui, sans-serif; +} + +.chatHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + background: #ffffff; + border-bottom: 1px solid #ddd; +} + +.chatTitle { + font-size: 20px; + font-weight: 600; + margin: 0; +} + +.chatClear { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border: none; + border-radius: 6px; + background: transparent; + cursor: pointer; +} + +.chatClear:hover { + background: #f0f0f0; +} + +.chatMessages { + flex: 1; + overflow-y: auto; + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.chatEmpty { + color: #777; + text-align: center; + margin-top: 40px; +} + +.messageRow { + display: flex; +} + +.user { + justify-content: flex-end; +} + +.assistant { + justify-content: flex-start; +} + +.messageBubble { + max-width: 65%; + padding: 10px 14px; + border-radius: 18px; + white-space: pre-wrap; + word-break: break-word; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + line-height: 1.5; +} + +.messageBubble.user { + background: #2563eb; + color: #fff; + border-bottom-right-radius: 6px; +} + +.messageBubble.assistant { + background: #ffffff; + color: #111; + border-bottom-left-radius: 6px; +} + +.chatError { + background: #fde2e2; + color: #c53030; + text-align: center; + padding: 10px; + font-size: 14px; +} + +.chatFooter { + background: #ffffff; + border-top: 1px solid #ddd; + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.apiKeyBox { + width: 100%; +} + +.apiKeyInput { + width: 100%; + padding: 8px 10px; + font-size: 14px; + border: 1px solid #ccc; + border-radius: 6px; +} + +.inputBox { + display: flex; + gap: 10px; +} + +.messageInput { + flex: 1; + padding: 10px; + border: 1px solid #ccc; + border-radius: 6px; + font-size: 14px; + resize: none; + line-height: 1.5; +} + +.sendButton { + display: flex; + align-items: center; + justify-content: center; + padding: 0 18px; + background: #2563eb; + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; +} + +.sendButton:hover { + background: #1d4ed8; +} + +.sendButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/app/page.tsx b/app/page.tsx @@ -1,188 +1,155 @@ +// page.tsx "use client"; -import { useState } from "react"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; +import React, { useState, useEffect, useRef } from "react"; +import { Send, Trash2 } from "lucide-react"; +import styles from "./page.module.css"; + +const systemInstruction = + "あなたは相手のことを先生と呼び、語尾ににゃんを付けるかわいい生徒です。"; +const apiKey = process.env.NEXT_PUBLIC_GEMINI_API_KEY; +export default function Page() { + const [systemInstruction, setSystemInstruction] = useState( + () => sessionStorage.getItem("GEMINI_SYS_INST") || "", + ); + const [input, setInput] = useState(""); + const [messages, setMessages] = useState( + () => + JSON.parse(sessionStorage.getItem("gemini_chat_messages") || "[]") as { + role: string; + text: string; + }[], + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + const listRef = useRef<HTMLDivElement | null>(null); -interface Message { - role: "user" | "model"; - content: string; -} + useEffect(() => { + sessionStorage.setItem("GEMINI_SYS_INST", systemInstruction); + }, [systemInstruction]); -export default function GeminiSample() { - const [messages, setMessages] = useState<Message[]>([ - { role: "model", content: "こんにちは!お話ししましょう。" }, - ]); - const [inputMessage, setInputMessage] = useState(""); - const [isLoading, setIsLoading] = useState(false); + useEffect(() => { + sessionStorage.setItem("gemini_chat_messages", JSON.stringify(messages)); + listRef.current?.scrollTo({ + top: listRef.current.scrollHeight, + behavior: "smooth", + }); + }, [messages]); - const handleSend = async () => { - if (!inputMessage.trim() || isLoading) return; + async function sendMessage() { + if (!input.trim()) return; + setError(null); - const userMessage: Message = { role: "user", content: inputMessage.trim() }; + const userMsg = { role: "user", text: input }; + setMessages((m) => [...m, userMsg]); + setInput(""); - setMessages((prev) => [...prev, userMessage]); - setInputMessage(""); - setIsLoading(true); + setLoading(true); try { - const res = await fetch("/api/chat", { - method: "POST", - headers: { - "Content-Type": "application/json", + const payload: any = { + contents: [ + { + role: "user", + parts: [{ text: userMsg.text }], + }, + ], + }; + + if (systemInstruction.trim()) { + payload.systemInstruction = { + role: "system", + parts: [{ text: systemInstruction }], + }; + } + + const res = await fetch( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-goog-api-key": apiKey, + }, + body: JSON.stringify(payload), }, - body: JSON.stringify({ prompt: userMessage.content }), - }); + ); if (!res.ok) { - throw new Error(`APIエラー: ${res.status}`); + const text = await res.text(); + throw new Error(`HTTP ${res.status} - ${text}`); } - const reader = res.body?.getReader(); - if (!reader) return; + const data = await res.json(); + const assistantText = + data?.candidates?.[0]?.content?.parts?.[0]?.text ?? + JSON.stringify(data, null, 2); - const decoder = new TextDecoder(); - let modelResponseContent = ""; - - const tempModelMessage: Message = { role: "model", content: "" }; - setMessages((prev) => [...prev, tempModelMessage]); - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - const chunk = decoder.decode(value); - modelResponseContent += chunk; - - setMessages((prev) => { - const newMessages = [...prev]; - const lastMessageIndex = newMessages.length - 1; - if (newMessages[lastMessageIndex].role === "model") { - newMessages[lastMessageIndex].content = modelResponseContent; - } - return newMessages; - }); - } - } catch (error) { - console.error("チャットエラー:", error); - setMessages((prev) => [ - ...prev, - { - role: "model", - content: "エラーが発生しました。もう一度お試しください。", - }, - ]); + setMessages((m) => [...m, { role: "assistant", text: assistantText }]); + } catch (e: any) { + setError(`Error: ${e.message}`); } finally { - setIsLoading(false); + setLoading(false); } - }; + } - const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => { - if (e.key === "Enter") { - handleSend(); - } - }; + function clearConversation() { + setMessages([]); + sessionStorage.removeItem("gemini_chat_messages"); + } return ( - <article - style={{ - display: "flex", - flexDirection: "column", - height: "100vh", - padding: "20px", - maxWidth: "800px", - margin: "0 auto", - }} - > - <section - style={{ - flexGrow: 1, - overflowY: "auto", - marginBottom: "10px", - padding: "10px", - border: "1px solid #ccc", - borderRadius: "8px", - backgroundColor: "#f9f9f9", - }} - > - {messages.map((msg, index) => ( + <div className={styles.chatContainer}> + <header className={styles.chatHeader}> + <h1 className={styles.chatTitle}>Gemini Chat</h1> + <button onClick={clearConversation} className={styles.chatClear}> + <Trash2 size={16} /> クリア + </button> + </header> + + <main ref={listRef} className={styles.chatMessages}> + {messages.length === 0 && ( + <div className={styles.chatEmpty}>まだ会話がありません。</div> + )} + {messages.map((m, i) => ( <div - key={index} - style={{ - marginBottom: "10px", - textAlign: msg.role === "user" ? "right" : "left", - }} + key={i} + className={`${styles.messageRow} ${m.role === "user" ? styles.user : styles.assistant}`} > - <span - style={{ - display: "inline-block", - padding: "8px 12px", - borderRadius: "18px", - maxWidth: "75%", - backgroundColor: msg.role === "user" ? "#007bff" : "#e0e0e0", - color: msg.role === "user" ? "white" : "black", - }} + <div + className={`${styles.messageBubble} ${m.role === "user" ? styles.user : styles.assistant}`} > - <ReactMarkdown remarkPlugins={[remarkGfm]}> - {msg.content} - </ReactMarkdown> - </span> - <div style={{ fontSize: "12px", color: "#666", marginTop: "4px" }}> - {msg.role === "user" ? "あなた" : "Gemini"} + {m.text} </div> </div> ))} - {isLoading && ( - <div style={{ textAlign: "left", marginBottom: "10px" }}> - <span - style={{ - display: "inline-block", - padding: "8px 12px", - borderRadius: "18px", - backgroundColor: "#e0e0e0", - color: "black", - }} - > - ...... - </span> - </div> - )} - </section> - - <section style={{ display: "flex" }}> - <input - type="text" - value={inputMessage} - onChange={(e) => setInputMessage(e.target.value)} - onKeyPress={handleKeyPress} - placeholder="メッセージを入力..." - disabled={isLoading} - style={{ - flexGrow: 1, - padding: "10px", - border: "1px solid #ccc", - borderRadius: "4px", - marginRight: "10px", - fontSize: "16px", - }} - /> - <button - onClick={handleSend} - disabled={!inputMessage.trim() || isLoading} - style={{ - padding: "10px 20px", - border: "none", - borderRadius: "4px", - backgroundColor: - !inputMessage.trim() || isLoading ? "#b3d4ff" : "#007bff", - color: "white", - cursor: - !inputMessage.trim() || isLoading ? "not-allowed" : "pointer", - fontSize: "16px", - }} - > - 送信 - </button> - </section> - </article> + </main> + + {error && <div className={styles.chatError}>{error}</div>} + + <footer className={styles.chatFooter}> + <div className={styles.inputBox}> + <textarea + value={input} + onChange={(e) => setInput(e.target.value)} + className={styles.messageInput} + placeholder="メッセージを入力..." + rows={2} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }} + /> + <button + onClick={sendMessage} + disabled={loading} + className={styles.sendButton} + > + {loading ? "..." : <Send size={18} />} + </button> + </div> + </footer> + </div> ); } diff --git a/bun.lock b/bun.lock @@ -6,6 +6,7 @@ "dependencies": { "@google/genai": "^1.21.0", "ai": "^5.0.56", + "lucide-react": "^0.544.0", "next": "15.5.4", "react": "19.1.0", "react-dom": "19.1.0", @@ -234,6 +235,8 @@ "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + "lucide-react": ["lucide-react@0.544.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="], + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], diff --git a/package.json b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@google/genai": "^1.21.0", "ai": "^5.0.56", + "lucide-react": "^0.544.0", "next": "15.5.4", "react": "19.1.0", "react-dom": "19.1.0",