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:
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",