├── .env.production ├── .gitignore ├── LICENSE ├── README.md ├── app ├── globals.css ├── layout.client.tsx ├── layout.tsx └── page.tsx ├── backend ├── .env.example ├── Procfile ├── main.py └── requirements.txt ├── components ├── AIPal.tsx ├── ChordFinder.tsx ├── Header.tsx ├── KeyFinder.tsx ├── ThemeToggle.tsx └── ui │ ├── button.tsx │ ├── card-wrapper.tsx │ ├── card.tsx │ ├── dialog.tsx │ ├── select.tsx │ └── textarea.tsx ├── lib ├── api.ts └── utils.ts ├── package-lock.json ├── package.json ├── postcss.config.js ├── requirement.md ├── tailwind.config.js └── tsconfig.json /.env.production: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_API_URL=https://zmusic-pal-back.zeabur.app -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | # next.js 10 | /.next/ 11 | /out/ 12 | 13 | # production 14 | /build 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # local env files 26 | .env 27 | .env*.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | 39 | # Python 40 | __pycache__/ 41 | *.py[cod] 42 | *$py.class 43 | .env 44 | .env.* 45 | !.env.example 46 | .venv/ 47 | venv/ 48 | ENV/ 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 ZoeJane 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # zMusic-Pal | 音乐伙伴 2 | 3 | *English | [简体中文](#简体中文)* 4 | 5 | A lightweight and elegant web application for quick key and chord lookup, featuring an AI companion for deeper musical exploration. 6 | 7 | [zMusic-Pal](https://zmusic-pal.zoejane.net) 8 | 9 | ## Core Features 10 | 11 | ### 1. Key Finder 12 | Enter a key (e.g., F major) to view: 13 | 14 | **Scale:** 15 | F G A Bb C D E 16 | 17 | **Common Triads:** 18 | ``` 19 | I F F - A - C 20 | ii Gm G - Bb - D 21 | iii Am A - C - E 22 | IV Bb Bb - D - F 23 | V C C - E - G 24 | vi Dm D - F - A 25 | ``` 26 | 27 | ### 2. Chord Finder 28 | Select a chord (e.g., Am) to see its component notes (e.g., A - C - E). 29 | 30 | ### 3. AI Pal 31 | Ask any music-related questions, and AI Pal will provide creative support and practical advice. For example: 32 | - "What are common chord progressions in rock music?" 33 | - "How to reharmonize a pop song?" 34 | 35 | ### 4. Mobile-Friendly 36 | Access and use on mobile devices for music exploration anytime, anywhere. 37 | 38 | ## Quick Start 39 | 40 | ### Website 41 | Visit directly: [zMusic-Pal](https://zmusic-pal.zoejane.net) 42 | 43 | ### Local Development 44 | 45 | 1. Clone the repository: 46 | ```bash 47 | git clone https://github.com/zoejane/zmusic-pal.git 48 | cd zmusic-pal 49 | ``` 50 | 51 | 2. Install dependencies: 52 | ```bash 53 | npm install 54 | ``` 55 | 56 | 3. Start the development server: 57 | ```bash 58 | npm run dev 59 | ``` 60 | 61 | 4. Open in browser: http://localhost:3000 62 | 63 | ## Tech Stack 64 | - Frontend: Next.js + React + TypeScript + Tailwind CSS 65 | - Backend: FastAPI + Python 66 | - AI Integration: Deepseek API 67 | 68 | ## License 69 | MIT © 2025 ZoeJane 70 | 71 | ## About 72 | zMusic-Pal is a compact and efficient music tool designed for creators. 73 | 74 | Whether you're composing, practicing, or exploring music theory, it provides inspiration and support. 75 | 76 | Experience zMusic-Pal and embark on a wonderful musical journey together! 77 | 78 | --- 79 | 80 | # 简体中文 81 | 82 | *[English](#zmusic-pal--音乐伙伴) | 简体中文* 83 | 84 | 一个小巧优雅的 Web 应用,用于快速查找调性和和弦,同时配备 AI 伙伴,提供更深入的音乐陪伴。 85 | 86 | [zMusic-Pal](https://zmusic-pal.zoejane.net) 87 | 88 | ## 核心功能 89 | 90 | ### 1. 查调 91 | 输入调性(如 F 大调),查看: 92 | 93 | **音阶:** 94 | F G A Bb C D E 95 | 96 | **常用三和弦:** 97 | ``` 98 | I F F - A - C 99 | ii Gm G - Bb - D 100 | iii Am A - C - E 101 | IV Bb Bb - D - F 102 | V C C - E - G 103 | vi Dm D - F - A 104 | ``` 105 | 106 | ### 2. 查和弦 107 | 选择和弦(如 Am),查看组成音(如 A - C - E)。 108 | 109 | ### 3. AI 伙伴 110 | 提出任何音乐相关问题,AI 伙伴将提供创意支持和实用建议。例如: 111 | - "摇滚中常见的和弦进程是什么?" 112 | - "如何为一首流行歌曲重新配和弦?" 113 | 114 | ### 4. 移动端友好 115 | 支持移动设备访问和使用,随时随地进行音乐探索。 116 | 117 | ## 快速开始 118 | 119 | ### 在线体验 120 | 直接访问:[zMusic-Pal 在线体验](https://zmusic-pal.zoejane.net) 121 | 122 | ### 本地运行(可选) 123 | 124 | 1. 克隆项目: 125 | ```bash 126 | git clone https://github.com/zoejane/zmusic-pal.git 127 | cd zmusic-pal 128 | ``` 129 | 130 | 2. 安装依赖: 131 | ```bash 132 | npm install 133 | ``` 134 | 135 | 3. 启动项目: 136 | ```bash 137 | npm run dev 138 | ``` 139 | 140 | 4. 打开浏览器访问:http://localhost:3000 141 | 142 | ## 技术架构 143 | - 前端:Next.js + React + TypeScript + Tailwind CSS 144 | - 后端:FastAPI + Python 145 | - AI 集成:Deepseek API 146 | 147 | ## 许可证 148 | MIT © 2025 ZoeJane 149 | 150 | ## 关于 151 | zMusic-Pal 是一个小巧而高效的音乐工具,专为创作者设计。 152 | 153 | 无论是作曲、练习还是探索音乐理论,它都能为您提供灵感和支持。 154 | 155 | 体验 zMusic-Pal,一起开启音乐的美好旅程! 156 | 157 | ## 更新日志 158 | 159 | - 20250121 zoejane 添加英文版本 160 | - 20250117 zoejane 初始化项目 161 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 240 10% 3.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 240 10% 3.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 240 10% 3.9%; 13 | --primary: 240 5.9% 10%; 14 | --primary-foreground: 0 0% 98%; 15 | --secondary: 240 4.8% 95.9%; 16 | --secondary-foreground: 240 5.9% 10%; 17 | --muted: 240 4.8% 95.9%; 18 | --muted-foreground: 240 3.8% 46.1%; 19 | --accent: 240 4.8% 95.9%; 20 | --accent-foreground: 240 5.9% 10%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 0 0% 98%; 23 | --border: 240 5.9% 90%; 24 | --input: 240 5.9% 90%; 25 | --ring: 240 5% 64.9%; 26 | --radius: 0.5rem; 27 | } 28 | 29 | .dark { 30 | --background: 240 10% 3.9%; 31 | --foreground: 0 0% 98%; 32 | --card: 240 10% 3.9%; 33 | --card-foreground: 0 0% 98%; 34 | --popover: 240 10% 3.9%; 35 | --popover-foreground: 0 0% 98%; 36 | --primary: 0 0% 98%; 37 | --primary-foreground: 240 5.9% 10%; 38 | --secondary: 240 3.7% 15.9%; 39 | --secondary-foreground: 0 0% 98%; 40 | --muted: 240 3.7% 15.9%; 41 | --muted-foreground: 240 5% 64.9%; 42 | --accent: 240 3.7% 15.9%; 43 | --accent-foreground: 0 0% 98%; 44 | --destructive: 0 62.8% 30.6%; 45 | --destructive-foreground: 0 85.7% 97.3%; 46 | --border: 240 3.7% 15.9%; 47 | --input: 240 3.7% 15.9%; 48 | --ring: 240 3.7% 15.9%; 49 | } 50 | } 51 | 52 | @layer base { 53 | * { 54 | @apply border-border; 55 | } 56 | body { 57 | @apply bg-background text-foreground antialiased; 58 | } 59 | } 60 | 61 | body { 62 | font-family: var(--font-roboto), -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", 63 | "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; 64 | } 65 | 66 | h1, 67 | h2, 68 | h3, 69 | h4, 70 | h5, 71 | h6 { 72 | font-family: var(--font-roboto-slab), serif; 73 | } 74 | 75 | :lang(zh-CN) { 76 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", 77 | "Droid Sans", "Helvetica Neue", sans-serif; 78 | } 79 | 80 | /* Increase font size for desktop */ 81 | @media (min-width: 1024px) { 82 | html { 83 | font-size: 18px; 84 | } 85 | } 86 | 87 | -------------------------------------------------------------------------------- /app/layout.client.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { ThemeToggle } from "@/components/ThemeToggle" 4 | import { GithubIcon, User } from "lucide-react" 5 | import { Button } from "@/components/ui/button" 6 | import Header from "@/components/Header" 7 | 8 | export default function RootLayoutClient({ 9 | children, 10 | }: { 11 | children: React.ReactNode 12 | }) { 13 | return ( 14 |
15 |
16 |
17 |
18 |
19 | 28 | 37 | 38 |
39 |
40 |
41 |
{children}
42 |
43 | ) 44 | } 45 | 46 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import './globals.css' 2 | import type { Metadata } from 'next' 3 | import { Roboto, Roboto_Slab } from 'next/font/google' 4 | import RootLayoutClient from './layout.client' 5 | import Script from 'next/script' 6 | 7 | const roboto = Roboto({ 8 | weight: ['300', '400', '700'], 9 | subsets: ['latin'], 10 | variable: '--font-roboto', 11 | display: 'swap', 12 | }) 13 | 14 | const robotoSlab = Roboto_Slab({ 15 | weight: ['300', '400', '700'], 16 | subsets: ['latin'], 17 | variable: '--font-roboto-slab', 18 | display: 'swap', 19 | }) 20 | 21 | export const metadata: Metadata = { 22 | title: 'zMusic-Pal: 小巧优雅的音乐工具', 23 | description: '一个轻量级 Web 应用,支持音乐基础功能查询和 AI 辅助创作', 24 | metadataBase: new URL('https://zmusic-pal-web.zeabur.app'), 25 | other: { 26 | 'baidu-site-verification': 'codeva-zKIT9ACCCa', 27 | }, 28 | } 29 | 30 | export default function RootLayout({ 31 | children, 32 | }: { 33 | children: React.ReactNode 34 | }) { 35 | return ( 36 | 37 | 38 | {/* Google Analytics */} 39 | 51 | 52 | {/* Baidu Link Auto-Submit */} 53 | 69 | 70 | 71 | {children} 72 | 73 | 74 | ) 75 | } 76 | 77 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { KeyFinder } from "@/components/KeyFinder" 2 | import { ChordFinder } from "@/components/ChordFinder" 3 | import AIPal from "@/components/AIPal" 4 | 5 | export default function Home() { 6 | return ( 7 |
8 |
9 | 10 | 11 | 12 |
13 |
14 | ) 15 | } 16 | 17 | -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | # Deepseek API Key 2 | DEEPSEEK_API_KEY=your_deepseek_api_key_here 3 | 4 | # Zhipu API Key 5 | ZHIPU_API_KEY=your_zhipu_api_key_here -------------------------------------------------------------------------------- /backend/Procfile: -------------------------------------------------------------------------------- 1 | web: python -m uvicorn main:app --host 0.0.0.0 --port $PORT -------------------------------------------------------------------------------- /backend/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, HTTPException, Request 2 | from fastapi.middleware.cors import CORSMiddleware 3 | from fastapi.responses import JSONResponse 4 | from fastapi.routing import APIRoute 5 | from pydantic import BaseModel 6 | import httpx 7 | import os 8 | import logging 9 | import json 10 | from dotenv import load_dotenv 11 | import asyncio 12 | 13 | # ===================================================== 14 | # Configuration Area - Modify API Provider here 15 | # "deepseek" - Use Deepseek API 16 | # "zhipu" - Use Zhipu API 17 | API_PROVIDER = "deepseek" 18 | # ===================================================== 19 | 20 | # Configure logging 21 | logging.basicConfig(level=logging.INFO) 22 | logger = logging.getLogger(__name__) 23 | 24 | load_dotenv() 25 | 26 | app = FastAPI() 27 | 28 | @app.middleware("http") 29 | async def handle_options(request: Request, call_next): 30 | if request.method == "OPTIONS": 31 | return JSONResponse( 32 | status_code=200, 33 | content={"message": "OK"} 34 | ) 35 | response = await call_next(request) 36 | return response 37 | 38 | # Configure CORS 39 | app.add_middleware( 40 | CORSMiddleware, 41 | allow_origins=["*"], # Allow all origins 42 | allow_credentials=False, 43 | allow_methods=["*"], 44 | allow_headers=["*"], 45 | ) 46 | 47 | # Custom route class to handle OPTIONS requests 48 | class CustomAPIRoute(APIRoute): 49 | def get_route_handler(self): 50 | original_route_handler = super().get_route_handler() 51 | 52 | async def custom_route_handler(request: Request): 53 | if request.method == "OPTIONS": 54 | return JSONResponse( 55 | status_code=200, 56 | content={"message": "OK"} 57 | ) 58 | return await original_route_handler(request) 59 | 60 | return custom_route_handler 61 | 62 | # Use custom route class 63 | app.router.route_class = CustomAPIRoute 64 | 65 | class ChatMessage(BaseModel): 66 | content: str 67 | 68 | class ChatResponse(BaseModel): 69 | response: str 70 | 71 | @app.post("/api/chat") 72 | async def chat(message: ChatMessage): 73 | """Handle chat requests""" 74 | try: 75 | logger.info(f"Received chat request: {message.content}") 76 | # Use globally configured API provider 77 | api_provider = API_PROVIDER 78 | logger.info(f"Current API provider: {api_provider}") 79 | 80 | if api_provider == "zhipu": 81 | logger.info("Using Zhipu API") 82 | response_text = await get_zhipu_response(message.content) 83 | else: 84 | logger.info("Using Deepseek API") 85 | response_text = await get_deepseek_response(message.content) 86 | 87 | logger.info(f"Returning response: {response_text}") 88 | return ChatResponse(response=response_text) 89 | except Exception as e: 90 | logger.error(f"Error processing chat request: {str(e)}") 91 | raise HTTPException(status_code=500, detail=str(e)) 92 | 93 | async def get_deepseek_response(message: str) -> str: 94 | """Call Deepseek API to get response""" 95 | api_key = os.getenv("DEEPSEEK_API_KEY") 96 | if not api_key: 97 | logger.error("DEEPSEEK_API_KEY not set") 98 | raise ValueError("DEEPSEEK_API_KEY is not set") 99 | 100 | headers = { 101 | "Authorization": f"Bearer {api_key}", 102 | "Content-Type": "application/json" 103 | } 104 | 105 | data = { 106 | "model": "deepseek-chat", 107 | "messages": [ 108 | { 109 | "role": "system", 110 | "content": """You play the role of a music companion, proficient in music theory and composition. You will: 111 | 1. Answer questions in concise professional language, with responses under 150 characters 112 | 2. Provide specific suggestions and examples 113 | 3. When discussing chords, provide both chord symbols and specific notes 114 | 4. Respond in the same language as the user's query - if the user asks in Chinese, respond in Chinese; if the user asks in English, respond in English""" 115 | }, 116 | { 117 | "role": "user", 118 | "content": message 119 | } 120 | ], 121 | "temperature": 0.7, 122 | "max_tokens": 1000 123 | } 124 | 125 | max_retries = 3 # Maximum retry attempts 126 | retry_delay = 1 # Retry interval (seconds) 127 | 128 | for attempt in range(max_retries): 129 | try: 130 | logger.info(f"Calling Deepseek API (attempt {attempt + 1}/{max_retries})") 131 | async with httpx.AsyncClient() as client: 132 | response = await client.post( 133 | "https://api.deepseek.com/v1/chat/completions", 134 | json=data, 135 | headers=headers, 136 | timeout=30.0 137 | ) 138 | response.raise_for_status() 139 | response_data = response.json() 140 | logger.info("Successfully received Deepseek API response") 141 | logger.info(f"Response content: {response_data}") 142 | if "choices" not in response_data or not response_data["choices"]: 143 | raise ValueError("Invalid response format from Deepseek API") 144 | return response_data["choices"][0]["message"]["content"] 145 | except (httpx.HTTPError, Exception) as e: 146 | logger.error(f"Error calling Deepseek API (attempt {attempt + 1}/{max_retries}): {str(e)}") 147 | if isinstance(e, httpx.HTTPError): 148 | logger.error(f"Response status code: {e.response.status_code if hasattr(e, 'response') else 'Unknown'}") 149 | logger.error(f"Response content: {e.response.text if hasattr(e, 'response') else 'Unknown'}") 150 | if attempt < max_retries - 1: # If not the last attempt 151 | await asyncio.sleep(retry_delay) # Wait before retrying 152 | continue 153 | raise HTTPException(status_code=500, detail=str(e)) 154 | 155 | async def get_zhipu_response(message: str) -> str: 156 | """Call Zhipu API to get response""" 157 | api_key = os.getenv("ZHIPU_API_KEY") 158 | logger.info("Getting Zhipu API Key") 159 | 160 | if not api_key: 161 | logger.error("ZHIPU_API_KEY not set") 162 | raise ValueError("ZHIPU_API_KEY is not set") 163 | 164 | # Parse API Key 165 | try: 166 | key_id, secret = api_key.split(".") 167 | logger.info(f"Successfully parsed Zhipu API Key, key_id: {key_id[:4]}...") 168 | except ValueError: 169 | logger.error("Invalid Zhipu API Key format") 170 | raise ValueError("Invalid ZHIPU_API_KEY format") 171 | 172 | # Generate JWT token 173 | import jwt 174 | import time 175 | 176 | timestamp = int(time.time()) 177 | payload = { 178 | "api_key": key_id, 179 | "timestamp": timestamp, 180 | "exp": timestamp + 3600 # Expires in 1 hour 181 | } 182 | 183 | token = jwt.encode( 184 | payload, 185 | secret, 186 | algorithm="HS256", 187 | headers={"alg": "HS256", "sign_type": "SIGN"} # Add necessary header information 188 | ) 189 | 190 | headers = { 191 | "Authorization": token, # Use token directly, without Bearer prefix 192 | "Content-Type": "application/json" 193 | } 194 | 195 | data = { 196 | "model": "glm-4", # Use GLM-4 model 197 | "messages": [ 198 | { 199 | "role": "system", 200 | "content": """You play the role of a music companion, proficient in music theory and composition. You will: 201 | 1. Answer questions in concise professional language, with responses under 150 characters 202 | 2. Provide specific suggestions and examples 203 | 3. When discussing chords, provide both chord symbols and specific notes 204 | 4. Respond in the same language as the user's query - if the user asks in Chinese, respond in Chinese; if the user asks in English, respond in English""" 205 | }, 206 | { 207 | "role": "user", 208 | "content": message 209 | } 210 | ], 211 | "temperature": 0.7, 212 | "top_p": 0.7, 213 | "max_tokens": 1000, 214 | "stream": False 215 | } 216 | 217 | max_retries = 3 # Maximum retry attempts 218 | retry_delay = 1 # Retry interval (seconds) 219 | 220 | for attempt in range(max_retries): 221 | try: 222 | logger.info(f"Calling Zhipu API (attempt {attempt + 1}/{max_retries})") 223 | async with httpx.AsyncClient() as client: 224 | response = await client.post( 225 | "https://open.bigmodel.cn/api/paas/v4/chat/completions", 226 | json=data, 227 | headers=headers, 228 | timeout=30.0 # Set timeout 229 | ) 230 | response.raise_for_status() 231 | logger.info("Successfully received Zhipu API response") 232 | return response.json()["choices"][0]["message"]["content"] 233 | except Exception as e: 234 | logger.error(f"Error calling Zhipu API (attempt {attempt + 1}/{max_retries}): {str(e)}") 235 | if attempt < max_retries - 1: # If not the last attempt 236 | await asyncio.sleep(retry_delay) # Wait before retrying 237 | continue 238 | raise HTTPException(status_code=500, detail=str(e)) -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi==0.109.0 2 | uvicorn==0.27.0 3 | httpx==0.26.0 4 | python-dotenv==1.0.0 5 | pydantic==2.5.3 6 | PyJWT==2.8.0 -------------------------------------------------------------------------------- /components/AIPal.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState, useEffect, useRef, type KeyboardEvent } from "react" 4 | import { CardWrapper } from "./ui/card-wrapper" 5 | import { Button } from "./ui/button" 6 | import { Textarea } from "./ui/textarea" 7 | import { sendMessage } from "@/lib/api" 8 | import { Send } from "lucide-react" 9 | import ReactMarkdown from "react-markdown" 10 | 11 | interface Message { 12 | type: "user" | "ai" 13 | content: string 14 | } 15 | 16 | export default function AIPal() { 17 | const [input, setInput] = useState("") 18 | const [messages, setMessages] = useState([]) 19 | const [isLoading, setIsLoading] = useState(false) 20 | const [error, setError] = useState("") 21 | const inputRef = useRef(null) 22 | const chatContainerRef = useRef(null) 23 | const inputAreaRef = useRef(null) 24 | 25 | const handleSubmit = async () => { 26 | if (!input.trim() || isLoading) return 27 | setIsLoading(true) 28 | setError("") 29 | const newUserMessage = { type: "user" as const, content: input } 30 | setMessages((prev) => [...prev, newUserMessage]) 31 | setInput("") 32 | 33 | try { 34 | const aiResponse = await sendMessage(input) 35 | const newAiMessage = { type: "ai" as const, content: aiResponse } 36 | setMessages((prev) => [...prev, newAiMessage]) 37 | } catch (error) { 38 | console.error("Error in handleSubmit:", error) 39 | setError(error instanceof Error ? error.message : "An unexpected error occurred") 40 | } finally { 41 | setIsLoading(false) 42 | } 43 | } 44 | 45 | const handleKeyDown = (e: KeyboardEvent) => { 46 | if (e.key === "Enter" && !e.shiftKey) { 47 | e.preventDefault() 48 | handleSubmit() 49 | } 50 | } 51 | 52 | useEffect(() => { 53 | if (chatContainerRef.current) { 54 | chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight 55 | } 56 | }, [messages]) 57 | 58 | useEffect(() => { 59 | const resizeTextarea = () => { 60 | if (inputRef.current) { 61 | inputRef.current.style.height = "auto" 62 | inputRef.current.style.height = `${inputRef.current.scrollHeight}px` 63 | } 64 | } 65 | resizeTextarea() 66 | window.addEventListener("resize", resizeTextarea) 67 | return () => window.removeEventListener("resize", resizeTextarea) 68 | }, [input]) 69 | 70 | useEffect(() => { 71 | if (messages.length > 0 && inputAreaRef.current) { 72 | inputAreaRef.current.scrollIntoView({ behavior: "smooth" }) 73 | } 74 | }, [messages]) 75 | 76 | return ( 77 | 81 | {messages.length > 0 && ( 82 | 120 | )} 121 |
0 ? "border-t" : ""} mt-auto`}> 122 |
123 |