大致框架有了

This commit is contained in:
adrian 2025-04-29 18:15:16 +08:00
parent be4b28d6f6
commit fd8a4a15f5
33 changed files with 784 additions and 140 deletions

0
backend/app/__init__.py Normal file
View File

View File

View File

15
backend/app/api/v1/api.py Normal file
View File

@ -0,0 +1,15 @@
# File: backend/app/api/v1/api.py
# Description: 聚合 v1 版本的所有 API 路由
from fastapi import APIRouter
from app.api.v1.endpoints import chat # 导入聊天路由
# 创建 v1 版本的总路由
api_router = APIRouter()
# 将聊天路由包含到 v1 总路由中,并添加前缀
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
# --- 如果有其他路由,也在这里 include ---
# from app.api.v1.endpoints import workflow
# api_router.include_router(workflow.router, prefix="/workflow", tags=["workflow"])

View File

View File

@ -0,0 +1,41 @@
# File: backend/app/api/v1/endpoints/chat.py (更新)
# Description: 聊天功能的 API 路由 (使用 ChatService)
from fastapi import APIRouter, HTTPException, Depends
from app.models.pydantic_models import ChatRequest, ChatResponse
# 导入 ChatService 实例
from app.services.chat_service import chat_service_instance, ChatService
router = APIRouter()
# --- (可选) 使用 FastAPI 的依赖注入来获取 ChatService 实例 ---
# 这样更符合 FastAPI 的风格,方便测试和替换实现
# async def get_chat_service() -> ChatService:
# return chat_service_instance
@router.post("/", response_model=ChatResponse)
async def handle_chat_message(
request: ChatRequest,
# chat_service: ChatService = Depends(get_chat_service) # 使用依赖注入
):
"""
处理用户发送的聊天消息并使用 LangChain 获取 AI 回复
"""
user_message = request.message
# session_id = request.session_id # 如果 ChatRequest 中包含 session_id
print(f"接收到用户消息: {user_message}")
try:
# --- 调用 ChatService 获取 AI 回复 ---
# 使用全局实例 (简单方式)
ai_reply = await chat_service_instance.get_ai_reply(user_message)
# 或者使用依赖注入获取的实例
# ai_reply = await chat_service.get_ai_reply(user_message, session_id)
print(f"发送 AI 回复: {ai_reply}")
return ChatResponse(reply=ai_reply)
except Exception as e:
# 如果 ChatService 抛出异常,捕获并返回 HTTP 500 错误
print(f"处理聊天消息时发生错误: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

View File

View File

View File

@ -0,0 +1,15 @@
# File: backend/app/core/config.py (新建或修改)
# Description: 读取环境变量配置
import os
from dotenv import load_dotenv
# 加载 .env 文件中的环境变量
# 这会在应用启动时查找 .env 文件并加载其内容
load_dotenv()
# 从环境变量获取 OpenAI API Key
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY") # 如果使用 Google
# 可以在这里添加其他配置项

View File

View File

32
backend/app/main.py Normal file
View File

@ -0,0 +1,32 @@
# File: backend/app/main.py (确认 load_dotenv 调用位置)
# Description: FastAPI 应用入口
from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware
from app.api.v1.api import api_router as api_router_v1
# 确保在创建 FastAPI 实例之前加载环境变量
from app.core.config import OPENAI_API_KEY # 导入会触发 load_dotenv
# 创建 FastAPI 应用实例
app = FastAPI(title="CherryAI Backend", version="0.1.0")
# --- 配置 CORS ---
origins = [
"http://localhost:3000",
"http://127.0.0.1:3000",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- 挂载 API 路由 ---
app.include_router(api_router_v1, prefix="/api/v1")
# --- 根路径 ---
@app.get("/", tags=["Root"])
async def read_root():
return {"message": "欢迎来到 CherryAI 后端!"}

View File

View File

View File

@ -0,0 +1,16 @@
# File: backend/app/models/pydantic_models.py
# Description: Pydantic 模型定义 API 数据结构
from pydantic import BaseModel
class ChatRequest(BaseModel):
"""聊天请求模型"""
message: str
# 可以添加更多字段,如 user_id, session_id 等
class ChatResponse(BaseModel):
"""聊天响应模型"""
reply: str
# 可以添加更多字段,如 message_id, status 等
# --- 你可以在这里添加其他功能的模型 ---

View File

View File

@ -0,0 +1,92 @@
# File: backend/app/services/chat_service.py (新建)
# Description: 封装 LangChain 聊天逻辑
from langchain_openai import ChatOpenAI
from langchain_google_genai import ChatGoogleGenerativeAI # 如果使用 Google
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.output_parsers import StrOutputParser
from langchain_core.messages import HumanMessage, AIMessage
# --- 可选:添加内存管理 ---
# 简单的内存实现 (可以替换为更复杂的 LangChain Memory 类)
chat_history = {} # 使用字典存储不同会话的内存,需要 session_id
class ChatService:
"""处理 AI 聊天交互的服务"""
def __init__(self, api_key: str):
"""
初始化 ChatService
Args:
api_key (str): 用于 LLM API 密钥
"""
# --- 选择并初始化 LLM ---
# 使用 OpenAI GPT-3.5 Turbo (推荐) 或 GPT-4
# self.llm = ChatOpenAI(model="gpt-3.5-turbo", api_key=api_key, temperature=0.7)
# self.llm = ChatOpenAI(model="gpt-4", api_key=api_key, temperature=0.7)
# --- 如果使用 Google Gemini ---
self.llm = ChatGoogleGenerativeAI(model="gemini-2.0-flash", google_api_key=api_key, convert_system_message_to_human=True)
# --- 定义 Prompt 模板 ---
# 包含系统消息、历史记录占位符和当前用户输入
self.prompt = ChatPromptTemplate.from_messages([
("system", "你是一个乐于助人的 AI 助手,请用简洁明了的语言回答问题。你的名字叫 CherryAI。"),
MessagesPlaceholder(variable_name="chat_history"), # 用于插入历史消息
("human", "{input}"), # 用户当前输入
])
# --- 定义输出解析器 ---
# 将 LLM 的输出解析为字符串
self.output_parser = StrOutputParser()
# --- 构建 LangChain Expression Language (LCEL) 链 ---
self.chain = self.prompt | self.llm | self.output_parser
async def get_ai_reply(self, user_message: str, session_id: str = "default_session") -> str:
"""
获取 AI 对用户消息的回复 (异步)
Args:
user_message (str): 用户发送的消息
session_id (str): (可选) 用于区分不同对话的会话 ID以支持内存
Returns:
str: AI 的回复文本
Raises:
Exception: 如果调用 AI 服务时发生错误
"""
try:
# --- 获取当前会话的历史记录 (如果需要内存) ---
current_chat_history = chat_history.get(session_id, [])
# --- 使用 ainvoke 进行异步调用 ---
ai_response = await self.chain.ainvoke({
"input": user_message,
"chat_history": current_chat_history, # 传入历史记录
})
# --- 更新会话历史记录 (如果需要内存) ---
# 只保留最近 N 轮对话,防止历史过长
max_history_length = 10 # 保留最近 5 轮对话 (10条消息)
current_chat_history.append(HumanMessage(content=user_message))
current_chat_history.append(AIMessage(content=ai_response))
# 如果历史记录超过长度,移除最早的消息
if len(current_chat_history) > max_history_length:
chat_history[session_id] = current_chat_history[-max_history_length:]
else:
chat_history[session_id] = current_chat_history
return ai_response
except Exception as e:
print(f"调用 LangChain 时出错: {e}")
# 可以进行更细致的错误处理,例如区分 API 错误和内部错误
raise Exception(f"AI 服务暂时不可用: {e}")
# --- 在文件末尾,创建 ChatService 的实例 ---
# 从配置中获取 API Key
from app.core.config import GOOGLE_API_KEY
if not GOOGLE_API_KEY:
raise ValueError("请在 .env 文件中设置 GOOGLE_API_KEY")
chat_service_instance = ChatService(api_key=GOOGLE_API_KEY)

View File

View File

View File

8
frontend/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,8 @@
{
"css.customData": [
".vscode/tailwindcss.json"
],
"files.associations": {
"*.css": "tailwindcss"
}
}

291
frontend/app/chat/page.tsx Normal file
View File

@ -0,0 +1,291 @@
// File: frontend/app/chat/page.tsx (重构布局)
// Description: AI 聊天界面组件,包含左侧助手面板、中间聊天区、右侧会话面板
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { SendHorizontal, Loader2, PanelRightOpen, PanelRightClose, UserPlus, Settings2 } from 'lucide-react'; // 添加图标
import { sendChatMessage } from '@/lib/api';
// --- 数据接口定义 ---
interface Message {
id: string;
text: string;
sender: 'user' | 'ai';
isError?: boolean;
}
interface ChatSession {
id: string;
title: string;
createdAt: Date;
}
// 定义助手接口 (示例)
interface Assistant {
id: string;
name: string;
description: string;
avatar?: string; // 可选头像 URL 或 emoji
systemPrompt: string; // 核心:系统提示
model: string; // 例如 'gpt-3.5-turbo', 'gemini-pro'
temperature: number;
// 可以添加 top_p, max_tokens 等其他参数
}
export default function ChatPage() {
// --- State Variables ---
const [inputMessage, setInputMessage] = useState('');
const [messages, setMessages] = useState<Message[]>([
{ id: 'init-1', text: '你好!我是默认助手,有什么可以帮你的吗?', sender: 'ai' },
]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSessionPanelOpen, setIsSessionPanelOpen] = useState(true); // 右侧会话面板状态
// --- Mock Data ---
// 助手列表 Mock 数据
const [assistants, setAssistants] = useState<Assistant[]>([
{ id: 'asst-default', name: '默认助手', description: '通用聊天助手', avatar: '🤖', systemPrompt: '你是一个乐于助人的 AI 助手。', model: 'gpt-3.5-turbo', temperature: 0.7 },
{ id: 'asst-coder', name: '代码助手', description: '帮助编写和解释代码', avatar: '💻', systemPrompt: '你是一个专业的代码助手,精通多种编程语言。请提供清晰、准确的代码示例和解释。', model: 'gpt-4', temperature: 0.5 },
{ id: 'asst-writer', name: '写作助手', description: '协助进行创意写作', avatar: '✍️', systemPrompt: '你是一位富有创意的写作伙伴,擅长构思情节、润色文字。', model: 'gemini-pro', temperature: 0.9 },
]);
const [currentAssistantId, setCurrentAssistantId] = useState<string>('asst-default'); // 当前选中的助手
// 会话列表 Mock 数据
const [sessions, setSessions] = useState<ChatSession[]>([
{id: 'session-1', title: '关于 RAG 技术的讨论', createdAt: new Date(Date.now() - 3600000)},
{id: 'session-2', title: 'Python FastAPI 项目构思', createdAt: new Date(Date.now() - 7200000)},
{id: 'session-3', title: '如何学习 LangChain', createdAt: new Date()},
]);
const [currentSessionId, setCurrentSessionId] = useState<string>('session-3'); // 当前选中的会话
// --- Refs ---
const messagesEndRef = useRef<null | HTMLDivElement>(null);
// --- Effects ---
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// --- Event Handlers ---
const handleSendMessage = async (e?: React.FormEvent<HTMLFormElement>) => {
e?.preventDefault();
const trimmedMessage = inputMessage.trim();
if (!trimmedMessage || isLoading) return;
setError(null);
const userMessage: Message = {
id: Date.now().toString(),
text: trimmedMessage,
sender: 'user',
};
setMessages((prevMessages) => [...prevMessages, userMessage]);
setInputMessage('');
setIsLoading(true);
try {
// TODO: 调用后端时需要传递 currentAssistantId 和 currentSessionId
// const aiReply = await sendChatMessage(trimmedMessage, currentSessionId, currentAssistantId);
const aiReply = await sendChatMessage(trimmedMessage); // 暂时保持不变
const aiMessage: Message = {
id: Date.now().toString() + '_ai',
text: aiReply,
sender: 'ai',
};
setMessages((prevMessages) => [...prevMessages, aiMessage]);
} catch (err: any) {
console.error("Failed to send message:", err);
const errorMessageText = err.message || 'An unknown error occurred.';
setError(errorMessageText);
const errorMessage: Message = {
id: Date.now().toString() + '_err',
text: `错误: ${errorMessageText}`,
sender: 'ai',
isError: true,
};
setMessages((prevMessages) => [...prevMessages, errorMessage]);
} finally {
setIsLoading(false);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputMessage(e.target.value);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey && !isLoading) {
e.preventDefault();
handleSendMessage();
}
};
const toggleSessionPanel = () => {
setIsSessionPanelOpen(!isSessionPanelOpen);
};
const handleSelectAssistant = (assistantId: string) => {
setCurrentAssistantId(assistantId);
// TODO: 可能需要清空当前消息或开始新会话
console.log("Selected assistant:", assistantId);
// 可以在这里加载助手的初始消息或更新聊天标题
const selectedAssistant = assistants.find(a => a.id === assistantId);
setMessages([
{ id: `init-${assistantId}`, text: `你好!我是${selectedAssistant?.name || '助手'}${selectedAssistant?.description || ''}`, sender: 'ai' },
]);
setCurrentSessionId(`new-${Date.now()}`); // 切换助手时通常开始新会话
setSessions(prev => [...prev, {id: `new-${Date.now()}`, title: `${selectedAssistant?.name} 的新对话`, createdAt: new Date()}]); // 添加新会话到列表
}
// --- JSX Rendering ---
return (
// 最外层 Flex 容器
<div className="flex h-full gap-4"> {/* 使用 gap 添加间距 */}
{/* 左侧助手面板 */}
<aside className="w-64 bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 flex flex-col">
<h2 className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-200 flex items-center justify-between">
<span></span>
{/* TODO: 添加助手创建/编辑入口 */}
{/* <button className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700" title="">
<Settings2 size={18} />
</button> */}
</h2>
<button className="mb-4 w-full px-3 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors text-sm flex items-center justify-center gap-2">
<UserPlus size={16} />
</button>
<div className="flex-1 overflow-y-auto space-y-2 pr-1"> {/* 添加右内边距防止滚动条遮挡 */}
{/* 渲染助手列表 */}
{assistants.map(assistant => (
<div
key={assistant.id}
onClick={() => handleSelectAssistant(assistant.id)}
className={`p-3 rounded-lg cursor-pointer flex items-center gap-3 ${
currentAssistantId === assistant.id
? 'bg-red-100 dark:bg-red-900/50 ring-2 ring-red-300 dark:ring-red-700' // 添加选中高亮和边框
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={assistant.description}
>
<span className="text-xl">{assistant.avatar || '👤'}</span> {/* 显示头像 */}
<div className="flex-1 overflow-hidden">
<p className={`text-sm font-medium truncate ${currentAssistantId === assistant.id ? 'text-red-800 dark:text-red-200' : 'text-gray-800 dark:text-gray-200'}`}>
{assistant.name}
</p>
<p className={`text-xs truncate ${currentAssistantId === assistant.id ? 'text-red-600 dark:text-red-400' : 'text-gray-500 dark:text-gray-400'}`}>
{assistant.description}
</p>
</div>
</div>
))}
</div>
</aside>
{/* 中间主聊天区域 */}
<div className="flex flex-col flex-1 bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
{/* 聊天窗口标题 - 显示当前助手和切换会话按钮 */}
<div className="flex justify-between items-center p-4 border-b dark:border-gray-700">
<div className="flex items-center gap-2">
<span className="text-xl">{assistants.find(a => a.id === currentAssistantId)?.avatar || '👤'}</span>
<h1 className="text-lg font-semibold text-gray-800 dark:text-gray-200">
{assistants.find(a => a.id === currentAssistantId)?.name || '助手'}
</h1>
{/* TODO: 显示模型、温度等信息 */}
{/* <span className="text-xs bg-gray-200 dark:bg-gray-700 px-1.5 py-0.5 rounded">
{assistants.find(a => a.id === currentAssistantId)?.model}
</span> */}
</div>
<button
onClick={toggleSessionPanel}
className="p-1 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
title={isSessionPanelOpen ? "关闭会话面板" : "打开会话面板"}
>
{isSessionPanelOpen ? <PanelRightClose size={20} /> : <PanelRightOpen size={20} />}
</button>
</div>
{/* 消息显示区域 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((message) => (
<div
key={message.id}
className={`flex ${
message.sender === 'user' ? 'justify-end' : 'justify-start'
}`}
>
<div
className={`max-w-xs md:max-w-md lg:max-w-lg px-4 py-2 rounded-lg shadow ${
message.sender === 'user'
? 'bg-red-500 text-white'
: message.isError
? 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300'
: 'bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200'
}`}
>
<p className="text-sm whitespace-pre-wrap">{message.text}</p>
</div>
</div>
))}
{isLoading && (
<div className="flex justify-center items-center p-2">
<Loader2 className="h-5 w-5 animate-spin text-gray-500 dark:text-gray-400" />
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">AI ...</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* 消息输入区域 */}
<div className="p-4 border-t dark:border-gray-700">
<form onSubmit={handleSendMessage} className="flex items-center space-x-2">
<input
type="text"
value={inputMessage}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={isLoading ? "AI 正在回复..." : "输入你的消息..."}
disabled={isLoading}
className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 dark:focus:ring-red-600 disabled:opacity-70 transition-opacity"
aria-label="聊天输入框"
/>
<button
type="submit"
disabled={!inputMessage.trim() || isLoading}
className="p-2 rounded-lg bg-red-500 text-white hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center h-10 w-10"
aria-label={isLoading ? "正在发送" : "发送消息"}
>
{isLoading ? <Loader2 className="h-5 w-5 animate-spin" /> : <SendHorizontal size={20} />}
</button>
</form>
</div>
</div>
{/* 右侧会话管理面板 */}
<aside className={`bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 flex flex-col transition-all duration-300 ease-in-out ${isSessionPanelOpen ? 'w-64' : 'w-0 p-0 border-0 overflow-hidden opacity-0'}`}> {/* 调整关闭时的样式 */}
<h2 className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-200 whitespace-nowrap"></h2> {/* 改为话题 */}
<button className="mb-4 w-full px-3 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors text-sm whitespace-nowrap flex items-center justify-center gap-2">
+
</button>
<div className="flex-1 overflow-y-auto space-y-2 pr-1">
{sessions.map(session => (
<div
key={session.id}
onClick={() => setCurrentSessionId(session.id)}
className={`p-2 rounded-lg cursor-pointer text-sm truncate whitespace-nowrap ${
currentSessionId === session.id
? 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-400 font-medium'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={session.title}
>
{session.title}
</div>
))}
</div>
</aside>
</div>
);
}

View File

@ -1,26 +0,0 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@ -0,0 +1,12 @@
// File: frontend/app/knowledge/page.tsx
// Description: 知识库页面占位符
export default function KnowledgePage() {
return (
<div>
<h1 className="text-2xl font-semibold mb-4 dark:text-gray-200"></h1>
<p className="text-gray-700 dark:text-gray-400"></p>
{/* 后续添加文件上传、列表展示等功能 */}
</div>
);
}

View File

@ -1,7 +1,15 @@
// File: frontend/app/layout.tsx
// Description: 全局根布局文件,包含 HTML 结构和侧边栏导航
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import Link from "next/link";
import { BotMessageSquare, Workflow, Database, Settings, Users } from "lucide-react"; // 添加 Users 图标
import "./../styles/globals.css"; // 确保正确引入全局样式
// 配置 Inter 字体
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
subsets: ["latin"], subsets: ["latin"],
@ -12,23 +20,73 @@ const geistMono = Geist_Mono({
subsets: ["latin"], subsets: ["latin"],
}); });
// 定义应用元数据
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "CherryAI",
description: "Generated by create next app", description: "AI 对话、工作流与 RAG 平台",
}; };
// 定义导航链接项
const navItems = [
{ name: "对话", href: "/chat", icon: BotMessageSquare },
{ name: "助手", href: "/assistants", icon: Users }, // 将助手管理移到这里
{ name: "工作流", href: "/workflow", icon: Workflow },
{ name: "知识库", href: "/knowledge", icon: Database },
{ name: "设置", href: "/settings", icon: Settings }, // 添加设置示例
];
// 根布局组件
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang="zh-CN">
<body <body className={`${geistSans.variable} ${geistMono.variable} antialiased flex h-screen bg-gray-100 dark:bg-gray-900`}>
className={`${geistSans.variable} ${geistMono.variable} antialiased`} {/* 侧边栏导航 */}
> <aside className="w-16 bg-white dark:bg-gray-800 p-3 shadow-md flex flex-col items-center"> {/* 调整内边距和对齐 */}
{children} {/* Logo */}
<div className="mb-6"> {/* 调整 Logo 边距 */}
<Link href="/" className="flex items-center justify-center text-3xl font-bold text-red-600 dark:text-red-500" title="CherryAI 主页">
<span>🍒</span> {/* 只显示图标 */}
</Link>
</div>
{/* 导航链接 */}
<nav className="flex-grow w-full">
<ul className="flex flex-col items-center space-y-2"> {/* 调整列表项间距 */}
{navItems.map((item) => (
<li key={item.name} className="w-full">
<Link
href={item.href}
className="relative flex items-center justify-center p-2 rounded-lg text-gray-600 dark:text-gray-400 hover:bg-red-100 dark:hover:bg-red-900/50 hover:text-red-700 dark:hover:text-red-400 transition-colors duration-200 group" // 居中图标
title={item.name} // 保留原生 title
>
<item.icon className="h-6 w-6 flex-shrink-0" />
{/* Tooltip 文字标签 */}
<span
className="absolute left-full top-1/2 -translate-y-1/2 ml-3 px-2 py-1 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 delay-150 whitespace-nowrap pointer-events-none" // 使用 pointer-events-none 避免干扰悬浮
>
{item.name}
</span>
</Link>
</li>
))}
</ul>
</nav>
{/* 侧边栏底部 (可选,放用户头像或设置入口) */}
<div className="mt-auto">
<button className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700" title="用户设置">
<Settings className="h-6 w-6 text-gray-600 dark:text-gray-400" />
</button>
</div>
</aside>
{/* 主内容区域 */}
<main className="flex-1 overflow-y-auto p-6 bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
{children} {/* 这里会渲染当前路由匹配到的页面组件 */}
</main>
</body> </body>
</html> </html>
); );
} }

View File

@ -1,103 +1,13 @@
import Image from "next/image"; // File: frontend/app/page.tsx
// Description: 应用主页 (根路径 '/')
export default function Home() { export default function HomePage() {
return ( return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]"> <div className="flex flex-col items-center justify-center h-full">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start"> <h1 className="text-4xl font-bold mb-4 text-gray-800 dark:text-gray-200"> CherryAI 🍒</h1>
<Image <p className="text-lg text-gray-600 dark:text-gray-400">
className="dark:invert" 使
src="/next.svg" </p>
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div> </div>
); );
} }

View File

@ -0,0 +1,12 @@
// File: frontend/app/workflow/page.tsx
// Description: 工作流页面占位符
export default function WorkflowPage() {
return (
<div>
<h1 className="text-2xl font-semibold mb-4 dark:text-gray-200"></h1>
<p className="text-gray-700 dark:text-gray-400"> React Flow </p>
{/* 后续添加 React Flow 画布和节点面板 */}
</div>
);
}

51
frontend/lib/api.ts Normal file
View File

@ -0,0 +1,51 @@
// File: frontend/lib/api.ts (新建或修改)
// Description: 用于调用后端 API 的工具函数
import axios from "axios";
// 从环境变量读取后端 API 地址,如果没有则使用默认值
// 确保在 .env.local 文件中定义 NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1";
// 创建 axios 实例,可以设置一些全局配置
const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
"Content-Type": "application/json",
},
});
/**
*
* @param message
* @returns AI
* @throws API
*/
export const sendChatMessage = async (message: string): Promise<string> => {
try {
// 发送 POST 请求到后端的 /chat/ 端点
const response = await apiClient.post("/chat/", { message });
// 检查响应数据和 reply 字段是否存在
if (response.data && response.data.reply) {
return response.data.reply; // 返回 AI 的回复
} else {
// 如果响应格式不符合预期,抛出错误
throw new Error("Invalid response format from server");
}
} catch (error) {
console.error("Error calling chat API:", error); // 在控制台打印详细错误
// 检查是否是 Axios 错误并且有响应体
if (axios.isAxiosError(error) && error.response) {
// 尝试从响应体中获取错误详情,否则提供通用消息
throw new Error(
error.response.data?.detail || "Failed to communicate with server"
);
}
// 如果不是 Axios 错误或没有响应体,抛出通用错误
throw new Error("Failed to send message. Please try again.");
}
};
// --- 你可以在这里添加调用其他后端 API 的函数 ---
// export const getWorkflows = async () => { ... };

View File

@ -9,20 +9,22 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"axios": "^1.9.0",
"lucide-react": "^0.503.0",
"next": "15.3.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0"
"next": "15.3.1"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5", "@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.3.1", "eslint-config-next": "15.3.1",
"@eslint/eslintrc": "^3" "tailwindcss": "^4",
"typescript": "^5"
}, },
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39" "packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
} }

View File

@ -8,6 +8,12 @@ importers:
.: .:
dependencies: dependencies:
axios:
specifier: ^1.9.0
version: 1.9.0
lucide-react:
specifier: ^0.503.0
version: 0.503.0(react@19.1.0)
next: next:
specifier: 15.3.1 specifier: 15.3.1
version: 15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -620,6 +626,9 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
available-typed-arrays@1.0.7: available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -628,6 +637,9 @@ packages:
resolution: {integrity: sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==} resolution: {integrity: sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==}
engines: {node: '>=4'} engines: {node: '>=4'}
axios@1.9.0:
resolution: {integrity: sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==}
axobject-query@4.1.0: axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -689,6 +701,10 @@ packages:
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
engines: {node: '>=12.5.0'} engines: {node: '>=12.5.0'}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
concat-map@0.0.1: concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@ -742,6 +758,10 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
detect-libc@2.0.4: detect-libc@2.0.4:
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -960,10 +980,23 @@ packages:
flatted@3.3.3: flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
follow-redirects@1.15.9:
resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
for-each@0.3.5: for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
form-data@4.0.2:
resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
engines: {node: '>= 6'}
function-bind@1.1.2: function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
@ -1287,6 +1320,11 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true hasBin: true
lucide-react@0.503.0:
resolution: {integrity: sha512-HGGkdlPWQ0vTF8jJ5TdIqhQXZi6uh3LnNgfZ8MHiuxFfX3RZeA79r2MW2tHAZKlAVfoNE8esm3p+O6VkIvpj6w==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
math-intrinsics@1.1.0: math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -1299,6 +1337,14 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
minimatch@3.1.2: minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
@ -1439,6 +1485,9 @@ packages:
prop-types@15.8.1: prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
punycode@2.3.1: punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -2238,12 +2287,22 @@ snapshots:
async-function@1.0.0: {} async-function@1.0.0: {}
asynckit@0.4.0: {}
available-typed-arrays@1.0.7: available-typed-arrays@1.0.7:
dependencies: dependencies:
possible-typed-array-names: 1.1.0 possible-typed-array-names: 1.1.0
axe-core@4.10.3: {} axe-core@4.10.3: {}
axios@1.9.0:
dependencies:
follow-redirects: 1.15.9
form-data: 4.0.2
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
axobject-query@4.1.0: {} axobject-query@4.1.0: {}
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
@ -2311,6 +2370,10 @@ snapshots:
color-string: 1.9.1 color-string: 1.9.1
optional: true optional: true
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
concat-map@0.0.1: {} concat-map@0.0.1: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
@ -2363,6 +2426,8 @@ snapshots:
has-property-descriptors: 1.0.2 has-property-descriptors: 1.0.2
object-keys: 1.1.1 object-keys: 1.1.1
delayed-stream@1.0.0: {}
detect-libc@2.0.4: {} detect-libc@2.0.4: {}
doctrine@2.1.0: doctrine@2.1.0:
@ -2729,10 +2794,19 @@ snapshots:
flatted@3.3.3: {} flatted@3.3.3: {}
follow-redirects@1.15.9: {}
for-each@0.3.5: for-each@0.3.5:
dependencies: dependencies:
is-callable: 1.2.7 is-callable: 1.2.7
form-data@4.0.2:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
mime-types: 2.1.35
function-bind@1.1.2: {} function-bind@1.1.2: {}
function.prototype.name@1.1.8: function.prototype.name@1.1.8:
@ -3052,6 +3126,10 @@ snapshots:
dependencies: dependencies:
js-tokens: 4.0.0 js-tokens: 4.0.0
lucide-react@0.503.0(react@19.1.0):
dependencies:
react: 19.1.0
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
merge2@1.4.1: {} merge2@1.4.1: {}
@ -3061,6 +3139,12 @@ snapshots:
braces: 3.0.3 braces: 3.0.3
picomatch: 2.3.1 picomatch: 2.3.1
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
minimatch@3.1.2: minimatch@3.1.2:
dependencies: dependencies:
brace-expansion: 1.1.11 brace-expansion: 1.1.11
@ -3207,6 +3291,8 @@ snapshots:
object-assign: 4.1.1 object-assign: 4.1.1
react-is: 16.13.1 react-is: 16.13.1
proxy-from-env@1.1.0: {}
punycode@2.3.1: {} punycode@2.3.1: {}
queue-microtask@1.2.3: {} queue-microtask@1.2.3: {}

View File

@ -0,0 +1,4 @@
@import "tailwindcss";
@import "tailwindcss/preflight";
@tailwind utilities;

View File

@ -0,0 +1,25 @@
// File: frontend/tailwind.config.ts
// Description: Tailwind CSS 配置文件,启用基于系统偏好的暗色模式
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}", // 如果有 pages 目录
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}", // 主要关注 app 目录
],
darkMode: "media", // <--- 关键:设置为 'media' 以跟随系统设置
theme: {
extend: {
// 你可以在这里扩展你的主题,例如添加自定义颜色、字体等
// backgroundImage: {
// "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
// "gradient-conic":
// "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
// },
},
},
plugins: [],
};
export default config;