├── .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 |
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 |
43 |
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 |
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 |
86 | {messages.map((message, index) => (
87 |
88 |
93 | {message.type === "user" ? (
94 | message.content
95 | ) : (
96 |
,
101 | }}
102 | >
103 | {message.content}
104 |
105 | )}
106 |
107 |
108 | ))}
109 | {isLoading && (
110 |
111 |
Thinking... | 思考中...
112 |
113 | )}
114 | {error && (
115 |
116 |
Error: | 错误: {error}
117 |
118 | )}
119 |
120 | )}
121 | 0 ? "border-t" : ""} mt-auto`}>
122 |
123 |
142 |
143 |
144 | )
145 | }
146 |
147 |
--------------------------------------------------------------------------------
/components/ChordFinder.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useState, useMemo } from "react"
4 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
5 | import { CardWrapper } from "@/components/ui/card-wrapper"
6 |
7 | const rootNotes = ["C", "Db", "D", "Eb", "E", "F", "F#", "G", "Ab", "A", "Bb", "B"]
8 |
9 | const chordTypes = [
10 | { value: "major", label: "Major | 大三和弦" },
11 | { value: "minor", label: "Minor | 小三和弦" },
12 | { value: "dim", label: "Dim | 减三和弦" },
13 | { value: "aug", label: "Aug | 增三和弦" },
14 | ]
15 |
16 | // Note sequence (including repeated sharp/flat forms, for calculation)
17 | const noteSequence = {
18 | sharp: [
19 | "C",
20 | "C#",
21 | "D",
22 | "D#",
23 | "E",
24 | "F",
25 | "F#",
26 | "G",
27 | "G#",
28 | "A",
29 | "A#",
30 | "B",
31 | "C",
32 | "C#",
33 | "D",
34 | "D#",
35 | "E",
36 | "F",
37 | "F#",
38 | "G",
39 | "G#",
40 | "A",
41 | "A#",
42 | "B",
43 | ],
44 | flat: [
45 | "C",
46 | "Db",
47 | "D",
48 | "Eb",
49 | "E",
50 | "F",
51 | "Gb",
52 | "G",
53 | "Ab",
54 | "A",
55 | "Bb",
56 | "B",
57 | "C",
58 | "Db",
59 | "D",
60 | "Eb",
61 | "E",
62 | "F",
63 | "Gb",
64 | "G",
65 | "Ab",
66 | "A",
67 | "Bb",
68 | "B",
69 | ],
70 | }
71 |
72 | // Special note mappings with comments
73 | const specialNoteMap = {
74 | "E aug 3": "B#(C)", // E augmented: E - G# - B# -> C
75 | "F# aug 3": "C##(D)", // F# augmented: F# - A# - C## -> D
76 | "A aug 3": "E#(F)", // A augmented: A - C# - E# -> F
77 | "Bb dim 3": "Fb(E)", // Bb diminished: Bb - Db - Fb -> E
78 | "B aug 3": "Fx(G)", // B augmented: B - D# - Fx -> G
79 | }
80 |
81 | function getChordNotes(rootNote: string, chordType: string): string[] {
82 | const useFlats = ["F", "Bb", "Eb", "Ab", "Db", "Gb"].includes(rootNote)
83 | const sequence = useFlats ? noteSequence.flat : noteSequence.sharp
84 | const rootIndex = sequence.indexOf(rootNote)
85 |
86 | let intervals: number[]
87 | switch (chordType) {
88 | case "major":
89 | intervals = [0, 4, 7] // Major third (4) and perfect fifth (7)
90 | break
91 | case "minor":
92 | intervals = [0, 3, 7] // Minor third (3) and perfect fifth (7)
93 | break
94 | case "dim":
95 | intervals = [0, 3, 6] // Minor third (3) and diminished fifth (6)
96 | break
97 | case "aug":
98 | intervals = [0, 4, 8] // Major third (4) and augmented fifth (8)
99 | break
100 | default:
101 | return []
102 | }
103 |
104 | return intervals.map((interval, index) => {
105 | // Check if there's a special case to handle
106 | const specialKey = `${rootNote} ${chordType} ${index + 1}`
107 | if (specialNoteMap[specialKey as keyof typeof specialNoteMap]) {
108 | return specialNoteMap[specialKey as keyof typeof specialNoteMap]
109 | }
110 | return sequence[rootIndex + interval]
111 | })
112 | }
113 |
114 | export function ChordFinder() {
115 | const [rootNote, setRootNote] = useState("C")
116 | const [chordType, setChordType] = useState("major")
117 |
118 | const chordNotes = useMemo(() => {
119 | return getChordNotes(rootNote, chordType)
120 | }, [rootNote, chordType])
121 |
122 | return (
123 |
124 |
125 |
126 |
138 |
150 |
151 |
152 |
153 | Notes | 组成音:{chordNotes.join(" - ")}
154 |
155 |
156 |
157 |
158 | )
159 | }
160 |
161 |
--------------------------------------------------------------------------------
/components/Header.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | export default function Header() {
4 | return (
5 |
6 |
7 |
zMusic-Pal
8 |
音乐伙伴
9 |
10 |
11 | )
12 | }
13 |
14 |
--------------------------------------------------------------------------------
/components/KeyFinder.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useState, useMemo, Fragment } from "react"
4 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
5 | import { CardWrapper } from "@/components/ui/card-wrapper"
6 |
7 | const rootNotes = ["C", "Db", "D", "Eb", "E", "F", "F#", "G", "Ab", "A", "Bb", "B"]
8 | const sharpNotes = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
9 | const flatNotes = ["C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B"]
10 | const scales = ["Major | 大调", "Natural Minor | 自然小调", "Harmonic Minor | 和声小调", "Melodic Minor | 旋律小调"]
11 |
12 | const scalePatterns = {
13 | Major: [0, 2, 4, 5, 7, 9, 11],
14 | "Natural Minor": [0, 2, 3, 5, 7, 8, 10],
15 | "Harmonic Minor": [0, 2, 3, 5, 7, 8, 11],
16 | "Melodic Minor": {
17 | ascending: [0, 2, 3, 5, 7, 9, 11],
18 | descending: [0, 10, 8, 7, 5, 3, 2],
19 | },
20 | }
21 |
22 | function getProperNoteName(index: number, rootNote: string): string {
23 | const useFlats = ["F", "Bb", "Eb", "Ab", "Db", "Gb"].includes(rootNote)
24 | const noteArray = useFlats ? flatNotes : sharpNotes
25 | return noteArray[index % 12]
26 | }
27 |
28 | function generateScale(rootNote: string, scaleType: string): string[] | { ascending: string[]; descending: string[] } {
29 | const pattern = scalePatterns[scaleType.split(" | ")[0] as keyof typeof scalePatterns]
30 | let rootIndex = flatNotes.indexOf(rootNote)
31 | if (rootIndex === -1) rootIndex = sharpNotes.indexOf(rootNote)
32 |
33 | if (scaleType.includes("Melodic Minor")) {
34 | const p = pattern as { ascending: number[]; descending: number[] }
35 | const ascending = p.ascending.map((interval) => getProperNoteName((rootIndex + interval) % 12, rootNote))
36 | const descending = p.descending.map((interval) => getProperNoteName((rootIndex + interval) % 12, rootNote))
37 | return { ascending, descending }
38 | } else {
39 | return (pattern as number[]).map((interval) => getProperNoteName((rootIndex + interval) % 12, rootNote))
40 | }
41 | }
42 |
43 | function generateTriads(
44 | scale: string[] | { ascending: string[]; descending: string[] },
45 | scaleType: string,
46 | ): { degree: string; chord: string; notes: string }[] {
47 | let triadTypes
48 | let scaleNotes: string[]
49 |
50 | if (scaleType.includes("Major")) {
51 | triadTypes = ["", "m", "m", "", "", "m", "dim"]
52 | scaleNotes = scale as string[]
53 | } else if (scaleType.includes("Natural Minor")) {
54 | triadTypes = ["m", "dim", "", "m", "m", "", ""]
55 | scaleNotes = scale as string[]
56 | } else if (scaleType.includes("Harmonic Minor")) {
57 | triadTypes = ["m", "dim", "aug", "m", "", "", "dim"]
58 | scaleNotes = scale as string[]
59 | } else {
60 | // Melodic Minor
61 | triadTypes = ["m", "m", "aug", "", "", "dim", "dim"]
62 | scaleNotes = (scale as { ascending: string[] }).ascending
63 | }
64 |
65 | return scaleNotes.slice(0, 6).map((note, index) => {
66 | const triadType = triadTypes[index]
67 | const chordNotes = [scaleNotes[index], scaleNotes[(index + 2) % 7], scaleNotes[(index + 4) % 7]]
68 | return {
69 | degree: ["I", "ii", "iii", "IV", "V", "vi"][index],
70 | chord: `${note}${triadType}`,
71 | notes: chordNotes.join(" - "),
72 | }
73 | })
74 | }
75 |
76 | export function KeyFinder() {
77 | const [rootNote, setRootNote] = useState("C")
78 | const [scale, setScale] = useState("Major | 大调")
79 |
80 | const scaleNotes = useMemo(() => generateScale(rootNote, scale), [rootNote, scale])
81 | const commonTriads = useMemo(() => generateTriads(scaleNotes, scale), [scaleNotes, scale])
82 |
83 | return (
84 |
85 |
86 |
87 |
99 |
111 |
112 |
113 |
Scale | 音阶
114 | {scale.includes("Melodic Minor") ? (
115 | <>
116 |
Ascending | 上行: {(scaleNotes as { ascending: string[] }).ascending.join(" ")}
117 |
Descending | 下行: {(scaleNotes as { descending: string[] }).descending.join(" ")}
118 | >
119 | ) : (
120 |
{(scaleNotes as string[]).join(" ")}
121 | )}
122 |
123 |
124 |
Common Chords | 常用和弦
125 |
126 | {commonTriads.map((triad, index) => (
127 |
128 | {triad.degree}
129 | {triad.chord}
130 | {triad.notes}
131 |
132 | ))}
133 |
134 |
135 |
136 |
137 | )
138 | }
139 |
140 |
--------------------------------------------------------------------------------
/components/ThemeToggle.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState, useEffect } from 'react'
4 | import { Moon, Sun } from 'lucide-react'
5 | import { Button } from '@/components/ui/button'
6 |
7 | interface ThemeToggleProps {
8 | className?: string;
9 | iconSize?: number;
10 | }
11 |
12 | export function ThemeToggle({ className, iconSize = 20 }: ThemeToggleProps) {
13 | const [theme, setTheme] = useState('light')
14 |
15 | useEffect(() => {
16 | const savedTheme = localStorage.getItem('theme') || 'light'
17 | setTheme(savedTheme)
18 | document.documentElement.classList.toggle('dark', savedTheme === 'dark')
19 | }, [])
20 |
21 | const toggleTheme = () => {
22 | const newTheme = theme === 'light' ? 'dark' : 'light'
23 | setTheme(newTheme)
24 | localStorage.setItem('theme', newTheme)
25 | document.documentElement.classList.toggle('dark', newTheme === 'dark')
26 | }
27 |
28 | return (
29 |
33 | )
34 | }
35 |
36 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from "react"
4 | import { Slot } from "@radix-ui/react-slot"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 | import { cn } from "@/lib/utils"
7 |
8 | const buttonVariants = cva(
9 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
10 | {
11 | variants: {
12 | variant: {
13 | default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | )
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button"
46 | return (
47 |
52 | )
53 | }
54 | )
55 | Button.displayName = "Button"
56 |
57 | export { Button, buttonVariants }
58 |
59 |
--------------------------------------------------------------------------------
/components/ui/card-wrapper.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from "react"
2 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
3 | import { cn } from "@/lib/utils"
4 |
5 | interface CardWrapperProps {
6 | title: string | ReactNode
7 | children: ReactNode
8 | className?: string
9 | }
10 |
11 | export function CardWrapper({ title, children, className }: CardWrapperProps) {
12 | return (
13 |
14 |
15 | {title}
16 |
17 | {children}
18 |
19 | )
20 | }
21 |
22 |
--------------------------------------------------------------------------------
/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import type { HTMLAttributes } from "react"
2 | import { cn } from "@/lib/utils"
3 |
4 | export interface CardProps extends HTMLAttributes {}
5 |
6 | export function Card({ className, ...props }: CardProps) {
7 | return (
8 |
12 | )
13 | }
14 |
15 | export function CardHeader({ className, ...props }: CardProps) {
16 | return
17 | }
18 |
19 | export function CardTitle({ className, ...props }: CardProps) {
20 | return (
21 |
25 | )
26 | }
27 |
28 | export function CardContent({ className, ...props }: CardProps) {
29 | return
30 | }
31 |
32 |
--------------------------------------------------------------------------------
/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { cn } from "@/lib/utils"
6 | import { X } from "lucide-react"
7 |
8 | const Dialog = DialogPrimitive.Root
9 |
10 | const DialogTrigger = DialogPrimitive.Trigger
11 |
12 | const DialogPortal = DialogPrimitive.Portal
13 |
14 | const DialogClose = DialogPrimitive.Close
15 |
16 | const DialogOverlay = React.forwardRef<
17 | React.ElementRef,
18 | React.ComponentPropsWithoutRef
19 | >(({ className, ...props }, ref) => (
20 |
28 | ))
29 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
30 |
31 | const DialogContent = React.forwardRef<
32 | React.ElementRef,
33 | React.ComponentPropsWithoutRef
34 | >(({ className, children, ...props }, ref) => (
35 |
36 |
37 |
45 | {children}
46 |
47 |
48 | Close
49 |
50 |
51 |
52 | ))
53 | DialogContent.displayName = DialogPrimitive.Content.displayName
54 |
55 | const DialogHeader = ({
56 | className,
57 | ...props
58 | }: React.HTMLAttributes) => (
59 |
66 | )
67 | DialogHeader.displayName = "DialogHeader"
68 |
69 | const DialogFooter = ({
70 | className,
71 | ...props
72 | }: React.HTMLAttributes) => (
73 |
80 | )
81 | DialogFooter.displayName = "DialogFooter"
82 |
83 | const DialogTitle = React.forwardRef<
84 | React.ElementRef,
85 | React.ComponentPropsWithoutRef
86 | >(({ className, ...props }, ref) => (
87 |
95 | ))
96 | DialogTitle.displayName = DialogPrimitive.Title.displayName
97 |
98 | const DialogDescription = React.forwardRef<
99 | React.ElementRef,
100 | React.ComponentPropsWithoutRef
101 | >(({ className, ...props }, ref) => (
102 |
107 | ))
108 | DialogDescription.displayName = DialogPrimitive.Description.displayName
109 |
110 | export {
111 | Dialog,
112 | DialogPortal,
113 | DialogOverlay,
114 | DialogClose,
115 | DialogTrigger,
116 | DialogContent,
117 | DialogHeader,
118 | DialogFooter,
119 | DialogTitle,
120 | DialogDescription,
121 | }
122 |
123 |
--------------------------------------------------------------------------------
/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from "react"
4 | import * as SelectPrimitive from "@radix-ui/react-select"
5 | import { Check, ChevronDown } from "lucide-react"
6 | import { cn } from "@/lib/utils"
7 |
8 | const Select = SelectPrimitive.Root
9 |
10 | const SelectGroup = SelectPrimitive.Group
11 |
12 | const SelectValue = SelectPrimitive.Value
13 |
14 | const SelectTrigger = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, children, ...props }, ref) => (
18 |
26 | {children}
27 |
28 |
29 |
30 |
31 | ))
32 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
33 |
34 | const SelectContent = React.forwardRef<
35 | React.ElementRef,
36 | React.ComponentPropsWithoutRef
37 | >(({ className, children, position = "popper", ...props }, ref) => (
38 |
39 |
50 |
57 | {children}
58 |
59 |
60 |
61 | ))
62 | SelectContent.displayName = SelectPrimitive.Content.displayName
63 |
64 | const SelectItem = React.forwardRef<
65 | React.ElementRef,
66 | React.ComponentPropsWithoutRef
67 | >(({ className, children, ...props }, ref) => (
68 |
76 |
77 |
78 |
79 |
80 |
81 | {children}
82 |
83 | ))
84 | SelectItem.displayName = SelectPrimitive.Item.displayName
85 |
86 | export {
87 | Select,
88 | SelectGroup,
89 | SelectValue,
90 | SelectTrigger,
91 | SelectContent,
92 | SelectItem,
93 | }
94 |
95 |
--------------------------------------------------------------------------------
/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from "react"
4 | import { cn } from "@/lib/utils"
5 |
6 | export interface TextareaProps
7 | extends React.TextareaHTMLAttributes {}
8 |
9 | const Textarea = React.forwardRef(
10 | ({ className, ...props }, ref) => {
11 | return (
12 |
20 | )
21 | }
22 | )
23 | Textarea.displayName = "Textarea"
24 |
25 | export { Textarea }
26 |
27 |
--------------------------------------------------------------------------------
/lib/api.ts:
--------------------------------------------------------------------------------
1 | export async function sendMessage(content: string): Promise {
2 | const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'https://zmusic-pal-back.zeabur.app';
3 |
4 | try {
5 | const response = await fetch(`${apiUrl}/api/chat`, {
6 | method: 'POST',
7 | headers: {
8 | 'Content-Type': 'application/json',
9 | 'Accept': 'application/json',
10 | },
11 | body: JSON.stringify({ content }),
12 | });
13 |
14 | if (!response.ok) {
15 | const errorText = await response.text();
16 | throw new Error(`HTTP error! status: ${response.status}, statusText: ${response.statusText}, body: ${errorText}`);
17 | }
18 |
19 | const data = await response.json();
20 | if (typeof data.response !== 'string') {
21 | throw new Error(`Invalid response format from API: ${JSON.stringify(data)}`);
22 | }
23 |
24 | return data.response;
25 | } catch (error) {
26 | console.error('Error in sendMessage:', error);
27 | if (error instanceof TypeError && error.message === 'Failed to fetch') {
28 | throw new Error(`Unable to connect to the server (${apiUrl}). Please check your network connection or server status.`);
29 | }
30 | if (error instanceof Error) {
31 | throw error;
32 | }
33 | throw new Error(`Unknown error occurred: ${JSON.stringify(error)}`);
34 | }
35 | }
36 |
37 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
8 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "zmusic-pal",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@radix-ui/react-dialog": "^1.1.4",
13 | "@radix-ui/react-select": "^2.1.4",
14 | "@radix-ui/react-slot": "^1.1.1",
15 | "class-variance-authority": "^0.7.1",
16 | "clsx": "^2.1.1",
17 | "lucide-react": "^0.323.0",
18 | "next": "14.1.0",
19 | "react": "^18.2.0",
20 | "react-dom": "^18.2.0",
21 | "react-markdown": "^9.0.3",
22 | "tailwind-merge": "^2.6.0",
23 | "tailwindcss-animate": "^1.0.7"
24 | },
25 | "devDependencies": {
26 | "@tailwindcss/typography": "^0.5.16",
27 | "@types/node": "^20",
28 | "@types/react": "^18",
29 | "@types/react-dom": "^18",
30 | "autoprefixer": "^10.4.20",
31 | "eslint": "^8",
32 | "eslint-config-next": "14.1.0",
33 | "postcss": "^8.5.1",
34 | "tailwindcss": "^3.4.17",
35 | "typescript": "^5"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
--------------------------------------------------------------------------------
/requirement.md:
--------------------------------------------------------------------------------
1 | # 需求文档|Requirements Document
2 |
3 | ## 项目名称
4 | zMusic-Pal: 小巧优雅的音乐工具
5 |
6 | ## 一、目标
7 |
8 | ### 项目定位
9 | - 一页式轻量级 Web 应用,支持音乐基础功能查询和 AI 辅助创作。
10 |
11 | ### 核心功能
12 | 1. 查调 / Key Finder
13 | 2. 查和弦 / Chord Finder
14 | 3. AI 伙伴 / AI Pal
15 | 4. 联系 / Contact(右上角按钮)
16 | 5. GitHub(右上角按钮)
17 |
18 | ### 设计要求
19 | - 页面布局清晰,所有功能模块在一页中按顺序排列。
20 | - 响应式设计,适配移动端和桌面端,提升用户体验。
21 | - 右上角按钮逻辑:
22 | - 初始页面加载时显示,随页面滚动后隐藏,保持主要功能区的整洁性。
23 |
24 | ## 二、功能需求
25 |
26 | ### 1. 查调 / Key Finder
27 | #### 描述
28 | 用户通过下拉菜单选择一个根音和调式,实时显示对应的音阶和常用三和弦。
29 |
30 | #### 输入
31 | - 根音 / Root Note:
32 | C, Db, D, Eb, E, F, F#, G, Ab, A, Bb, B
33 | - 调式 / Scale:
34 | 大调 / Major,自然小调 / Natural Minor,和声小调 / Harmonic Minor,旋律小调 / Melodic Minor
35 |
36 | #### 输出示例
37 | 音阶 / Scale:
38 | F G A Bb C D E
39 |
40 | 常用三和弦 / Common Triads:
41 | I F F - A - C
42 | ii Gm G - Bb - D
43 | iii Am A - C - E
44 | IV Bb Bb - D - F
45 | V C C - E - G
46 | vi Dm D - F - A
47 |
48 | ### 2. 查和弦 / Chord Finder
49 | #### 描述
50 | 用户选择一个根音和和弦类型,显示和弦的组成音。
51 |
52 | #### 输入
53 | - 根音 / Root Note:与查调相同
54 | - 和弦类型 / Chord Type:
55 | 大三和弦 / Major,小三和弦 / Minor,减三和弦 / Dim,增三和弦 / Aug
56 |
57 | #### 输出示例
58 | 和弦 / Chord: C Major
59 | 组成音:C - E - G
60 |
61 | ### 3. AI 伙伴 / AI Pal
62 | #### 描述
63 | 用户输入自由文本问题,AI 返回动态答案。
64 |
65 | #### 输入
66 | - 自由文本输入框
67 |
68 | #### 输出示例
69 | 用户:C Major 的常用和弦是什么?
70 | AI:C Major 的常用和弦是:I (C), ii (Dm), iii (Em), IV (F), V (G), vi (Am)
71 |
72 | ### 4. 联系 / Contact(右上角按钮)
73 | #### 描述
74 | 用户点击按钮,可以提交建议、问题反馈或联系开发者。
75 |
76 | #### 实现方式
77 | - 按钮位于右上角,初始页面加载时显示。
78 | - 随用户滚动页面,按钮隐藏,避免干扰主要功能区的体验。
79 | - 点击跳转到表单页面或邮件链接。
80 |
81 | #### 表单内容
82 | - 用户名(可选)
83 | - 联系方式(可选)
84 | - 反馈内容(必填)
85 |
86 | ### 5. GitHub(右上角按钮)
87 | #### 描述
88 | 用户点击按钮,可以跳转到 GitHub 项目主页,了解项目详情、提交问题或贡献代码。
89 |
90 | #### 实现方式
91 | - 按钮位于右上角,初始页面加载时显示。
92 | - 随用户滚动页面,按钮隐藏,保持界面整洁。
93 |
94 | ## 三、界面设计
95 |
96 | ### 1. 页面布局
97 |
98 | ```
99 | ┌─────────────────────────────────────────────────────┐
100 | │ zMusic-Pal │
101 | │ 一个小巧优雅的音乐工具 [联系] [GitHub] │
102 | └─────────────────────────────────────────────────────┘
103 |
104 | [ 查调 / Key Finder ]
105 | ┌────────────────────────────────────────────────┐
106 | │ 根音 / Root Note: [C] │
107 | │ 调式 / Scale: [Major] │
108 | ├────────────────────────────────────────────────┤
109 | │ 音阶 / Scale: │
110 | │ F G A Bb C D E │
111 | ├────────────────────────────────────────────────┤
112 | │ 常用三和弦 / Common Triads: │
113 | │ I F F - A - C │
114 | │ ii Gm G - Bb - D │
115 | │ iii Am A - C - E │
116 | │ IV Bb Bb - D - F │
117 | │ V C C - E - G │
118 | │ vi Dm D - F - A │
119 | └────────────────────────────────────────────────┘
120 |
121 | [ 查和弦 / Chord Finder ]
122 | ┌────────────────────────────────────────────────┐
123 | │ 根音 / Root Note: [C] │
124 | │ 和弦类型 / Chord Type: [Major] │
125 | ├────────────────────────────────────────────────┤
126 | │ 和弦 / Chord: C Major │
127 | │ 组成音: C - E - G │
128 | └────────────────────────────────────────────────┘
129 |
130 | [ AI 伙伴 / AI Pal ]
131 | ┌────────────────────────────────────────────────┐
132 | │ 输入问题 / Ask a question: │
133 | │ [ 请输入您的问题...... ] │
134 | ├────────────────────────────────────────────────┤
135 | │ 输出回答 / AI Response: │
136 | │ C Major 的常用和弦是: │
137 | │ I (C), ii (Dm), iii (Em), IV (F), V (G), │
138 | │ vi (Am) │
139 | └────────────────────────────────────────────────┘
140 | ```
141 |
142 | ### 模块特点
143 | - 各模块按顺序排列,动态更新结果在模块内显示。
144 | - 右上角按钮在页面加载时可见,滚动后隐藏。
145 |
146 | ### 2. 移动端设计
147 | - 页面宽度自适应,布局优先适配移动端。
148 | - 功能模块支持滚动显示,避免内容超出屏幕长度。
149 | - 右上角按钮在移动端以同样逻辑隐藏,避免影响有限屏幕空间。
150 |
151 | ## 四、技术要求
152 |
153 | ### 前端
154 | - React.js,支持响应式设计。
155 | - 动态显示逻辑:监听页面滚动事件控制右上角按钮显示/隐藏。
156 |
157 | ### 后端
158 | - FastAPI,用于处理 DeepSeek API 调用。
159 |
160 | ### 数据
161 | - 音阶和和弦逻辑初期硬编码在前端(如 JSON 文件)。
162 | - 可选扩展:后端提供静态 API 返回音阶和和弦数据。
163 |
164 | ## 五、测试需求
165 |
166 | ### 1. 功能测试
167 | - 验证各功能模块的输入输出是否符合预期:
168 | - 查调和查和弦:测试不同输入组合的结果正确性。
169 | - AI 伙伴:确认 AI 返回的答案是否准确。
170 | - 联系和 GitHub 按钮:测试跳转功能是否正常。
171 |
172 | ### 2. 浏览器兼容性
173 | - 测试以下浏览器的兼容性:
174 | - Google Chrome、Mozilla Firefox、Microsoft Edge、Safari、iOS Safari、Android Chrome。
175 |
176 | ### 3. 性能测试
177 | - 页面加载时间 < 2 秒。
178 | - 动态结果响应时间 < 500 毫秒。
179 |
180 | ### 4. 错误处理
181 | - 验证输入错误时是否有友好提示。
182 | - 确保 API 请求失败时有合理的错误信息。
183 |
184 | ## 六、特别说明
185 |
186 | ### 国际化支持
187 | - 中英文并列显示(如 "音阶 / Scale")。
188 |
189 | ### 后续扩展
190 | - 增加更多调式(如五声音阶)。
191 | - 支持更多和弦类型(如七和弦)。
192 |
193 | ### 开发优先级
194 | - 优先实现查调和查和弦功能。
195 | - AI 伙伴、联系和 GitHub 入口为辅助模块,但需保持基础可用性。
196 |
197 | ## Changelog
198 |
199 | - 20250121 zoejane fix markdown syntax
200 | - 20250117 zoejane init
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | './pages/**/*.{ts,tsx}',
6 | './components/**/*.{ts,tsx}',
7 | './app/**/*.{ts,tsx}',
8 | './src/**/*.{ts,tsx}',
9 | ],
10 | theme: {
11 | container: {
12 | center: true,
13 | padding: "2rem",
14 | screens: {
15 | "2xl": "1400px",
16 | },
17 | },
18 | extend: {
19 | colors: {
20 | border: "hsl(var(--border))",
21 | input: "hsl(var(--input))",
22 | ring: "hsl(var(--ring))",
23 | background: "hsl(var(--background))",
24 | foreground: "hsl(var(--foreground))",
25 | primary: {
26 | DEFAULT: "hsl(var(--primary))",
27 | foreground: "hsl(var(--primary-foreground))",
28 | },
29 | secondary: {
30 | DEFAULT: "hsl(var(--secondary))",
31 | foreground: "hsl(var(--secondary-foreground))",
32 | },
33 | destructive: {
34 | DEFAULT: "hsl(var(--destructive))",
35 | foreground: "hsl(var(--destructive-foreground))",
36 | },
37 | muted: {
38 | DEFAULT: "hsl(var(--muted))",
39 | foreground: "hsl(var(--muted-foreground))",
40 | },
41 | accent: {
42 | DEFAULT: "hsl(var(--accent))",
43 | foreground: "hsl(var(--accent-foreground))",
44 | },
45 | popover: {
46 | DEFAULT: "hsl(var(--popover))",
47 | foreground: "hsl(var(--popover-foreground))",
48 | },
49 | card: {
50 | DEFAULT: "hsl(var(--card))",
51 | foreground: "hsl(var(--card-foreground))",
52 | },
53 | },
54 | borderRadius: {
55 | lg: "var(--radius)",
56 | md: "calc(var(--radius) - 2px)",
57 | sm: "calc(var(--radius) - 4px)",
58 | },
59 | keyframes: {
60 | "accordion-down": {
61 | from: { height: 0 },
62 | to: { height: "var(--radix-accordion-content-height)" },
63 | },
64 | "accordion-up": {
65 | from: { height: "var(--radix-accordion-content-height)" },
66 | to: { height: 0 },
67 | },
68 | },
69 | animation: {
70 | "accordion-down": "accordion-down 0.2s ease-out",
71 | "accordion-up": "accordion-up 0.2s ease-out",
72 | },
73 | },
74 | },
75 | plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
76 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
--------------------------------------------------------------------------------