1_chat.tsx (7482B)
1 "use client"; 2 import React, { 3 KeyboardEvent, 4 useCallback, 5 useEffect, 6 useRef, 7 useState, 8 } from "react"; 9 import { StageProps } from "../ctrl/page.tsx"; 10 import init, { chat } from "../../rust-wasm/pkg/rust_wasm.js"; 11 const useAudioPlayback = ( 12 initialAudioId: number, // 1 13 audioSources: Record<number, string>, 14 onAudioEnd: () => void, 15 ) => { 16 const audioRef = useRef<HTMLAudioElement | null>(null); 17 const initialAudioUrl = audioSources[initialAudioId] || ""; 18 19 const stop = useCallback(() => { 20 if (audioRef.current) { 21 audioRef.current.pause(); 22 audioRef.current.currentTime = 0; 23 } 24 }, []); 25 26 useEffect(() => { 27 if (!initialAudioUrl) { 28 console.error("Initial audio URL is missing."); 29 return; 30 } 31 32 // 既存の音源を停止 33 stop(); 34 35 const audio = new Audio(initialAudioUrl); 36 audioRef.current = audio; 37 audio.loop = false; // ループはしない 38 39 // 再生終了時のハンドラ: 無条件で onAudioEnd を実行 40 const handleEnded = () => { 41 console.log("初期音源の再生が終了しました。onAudioEndを実行します。"); 42 stop(); 43 onAudioEnd(); // 完了コールバックを実行 44 }; 45 46 audio.addEventListener("ended", handleEnded); 47 48 audio.play().catch((e) => { 49 console.log( 50 "Audio playback failed initially. Waiting for user interaction.", 51 e, 52 ); 53 }); 54 55 // クリーンアップ関数 56 return () => { 57 audio.removeEventListener("ended", handleEnded); 58 audio.pause(); 59 }; 60 }, [initialAudioUrl, onAudioEnd, stop]); 61 62 return { currentAudioId: initialAudioId }; 63 }; 64 // ------------------------------------------------------------------ 65 // 以下、ChatPage およびその他のコンポーネント 66 // ------------------------------------------------------------------ 67 const FirstChat: React.FC<StageProps> = ({ onComplete }) => { 68 return <ChatPage onComplete={onComplete} />; 69 }; 70 export default FirstChat; 71 const AUDIO_SOURCES: Record<number, string> = { 72 1: "/audio/001.flac", // 再生される音源はこれのみ 73 }; 74 interface Message { 75 id: number; 76 text: string; 77 sender: "user" | "ai"; 78 } 79 const initialMessages: Message[] = [ 80 { id: 1, text: "そろそろどうするか決めないとだよ?", sender: "ai" }, 81 { id: 2, text: "はやく話して。", sender: "ai" }, 82 ]; 83 const MessageBubble: React.FC<{ message: Message }> = ({ message }) => { 84 const isUser = message.sender === "user"; 85 86 const bubbleStyle: React.CSSProperties = { 87 padding: "10px 15px", 88 borderRadius: "15px", 89 maxWidth: "70%", 90 wordBreak: "break-word", 91 fontSize: "16px", 92 backgroundColor: isUser ? "#3b82f6" : "#e5e7eb", // blue-500 or gray-200 93 color: isUser ? "white" : "#1f2937", // white or gray-800 94 marginLeft: isUser ? "auto" : "0", 95 marginRight: isUser ? "0" : "auto", 96 }; 97 98 const containerStyle: React.CSSProperties = { 99 display: "flex", 100 marginBottom: "10px", 101 justifyContent: isUser ? "flex-end" : "flex-start", 102 }; 103 104 return ( 105 <div style={containerStyle}> 106 <div style={bubbleStyle}>{message.text}</div> 107 </div> 108 ); 109 }; 110 const MessageInput: React.FC<{ onSend: (text: string) => void }> = ({ 111 onSend, 112 }) => { 113 const [input, setInput] = useState(""); 114 115 const handleSend = () => { 116 if (input.trim() === "") return; 117 onSend(input); 118 setInput(""); 119 }; 120 121 const handleKeyPress = (e: KeyboardEvent<HTMLInputElement>) => { 122 if (e.key === "Enter") { 123 e.preventDefault(); 124 handleSend(); 125 } 126 }; 127 128 const inputStyle: React.CSSProperties = { 129 flexGrow: 1, 130 padding: "12px", 131 border: "1px solid #d1d5db", // gray-300 132 borderRadius: "8px", 133 marginRight: "10px", 134 fontSize: "16px", 135 outline: "none", 136 }; 137 138 const buttonStyle: React.CSSProperties = { 139 backgroundColor: "#3b82f6", // blue-500 140 color: "white", 141 border: "none", 142 padding: "12px 20px", 143 borderRadius: "8px", 144 cursor: "pointer", 145 fontWeight: "bold", 146 }; 147 148 return ( 149 <div 150 style={{ 151 padding: "15px", 152 backgroundColor: "#f9fafb", 153 display: "flex", 154 alignItems: "center", 155 }} 156 > 157 <input 158 type="text" 159 style={inputStyle} 160 placeholder="メッセージを入力してください..." 161 value={input} 162 onChange={(e) => setInput(e.target.value)} 163 onKeyDown={handleKeyPress} 164 /> 165 <button 166 type="submit" 167 style={buttonStyle} 168 onClick={handleSend} 169 disabled={input.trim() === ""} 170 > 171 送信 172 </button> 173 </div> 174 ); 175 }; 176 function ChatPage({ onComplete }: StageProps) { 177 const [messages, setMessages] = useState<Message[]>(initialMessages); 178 const [dict, setDict] = useState<Uint8Array | undefined>(undefined); 179 const messagesEndRef = useRef<HTMLDivElement>(null); 180 const dictPath = "/system.dic.zst"; 181 182 // for audio: 最初の音源を再生し、終了したら onComplete を呼ぶ 183 const { currentAudioId } = useAudioPlayback( 184 1, // initialAudioId (最初の音源ID) 185 AUDIO_SOURCES, 186 onComplete, 187 ); 188 189 useEffect(() => { 190 messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); 191 }, [messages]); 192 193 useEffect(() => { 194 const loadData = async () => { 195 const loadedDict = await LoadDict(dictPath); 196 setDict(loadedDict); 197 }; 198 loadData(); 199 }, []); 200 201 useEffect(() => { 202 init(); 203 }, []); 204 205 const handleSendMessage = useCallback( 206 async (text: string) => { 207 if (text.trim() === "") return; 208 209 const newUserMessage: Message = { 210 id: Date.now(), 211 text, 212 sender: "user", 213 }; 214 setMessages((prev) => [...prev, newUserMessage]); 215 216 let ans = 217 dict == undefined ? "もっとマシなことを言いなさい。" : chat(dict, text); 218 if (messages[messages.length - 1].text === ans) { 219 ans = "は?"; 220 } 221 const aiResponse: Message = { 222 id: Date.now() + 1, 223 text: ans, 224 sender: "ai", 225 }; 226 setMessages((prev) => [...prev, aiResponse]); 227 }, 228 [dict, messages], 229 ); 230 231 const pageContainerStyle: React.CSSProperties = { 232 display: "flex", 233 flexDirection: "column", 234 height: "100vh", 235 maxWidth: "800px", 236 margin: "0 auto", 237 backgroundColor: "white", 238 boxShadow: 239 "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)", 240 }; 241 242 const headerStyle: React.CSSProperties = { 243 padding: "15px", 244 backgroundColor: "#3b82f6", 245 color: "white", 246 textAlign: "center", 247 fontWeight: "bold", 248 fontSize: "20px", 249 }; 250 251 const messageListStyle: React.CSSProperties = { 252 flexGrow: 1, 253 padding: "15px", 254 overflowY: "auto", 255 }; 256 257 return ( 258 <div style={pageContainerStyle}> 259 <div style={headerStyle}>チャット</div> 260 261 <div style={messageListStyle}> 262 {messages.map((msg) => ( 263 <MessageBubble key={msg.id} message={msg} /> 264 ))} 265 <div ref={messagesEndRef} /> 266 </div> 267 268 <MessageInput onSend={handleSendMessage} /> 269 </div> 270 ); 271 } 272 async function LoadDict(dictPath: string): Promise<Uint8Array> { 273 try { 274 const response = await fetch(dictPath); 275 if (!response.ok) { 276 throw new Error(`fail to fetch file: ${response.statusText}`); 277 } 278 const arrayBuffer = await response.arrayBuffer(); 279 const byte = new Uint8Array(arrayBuffer); 280 return byte; 281 } catch (error) { 282 console.log("error occur", error); 283 } 284 return new Uint8Array(0); 285 }