diff --git a/README_CN.md b/README_CN.md new file mode 100644 index 000000000..04b41a5da --- /dev/null +++ b/README_CN.md @@ -0,0 +1,195 @@ +# Bolt 开源代码库 + +![Bolt Open Source Codebase](./public/social_preview_index.jpg) + +> 欢迎来到 **Bolt** 开源代码库! 本仓库包含了一个使用 bolt.new 核心组件的简单示例应用,旨在帮助你开始构建基于 StackBlitz 的 **WebContainer API** 驱动的 **AI 驱动软件开发工具**。 + +### 为什么选择 Bolt + WebContainer API 进行开发 + +通过使用 Bolt + WebContainer API 构建,你可以创建基于浏览器的应用程序,让用户直接在浏览器中**提示、运行、编辑和部署**全栈 Web 应用,无需虚拟机。借助 WebContainer API,你可以构建应用程序,让 AI 直接访问和完全控制用户浏览器标签页内的 **Node.js 服务器**、**文件系统**、**包管理器**和**开发终端**。这种强大的组合允许你创建一种新类型的开发工具,支持所有主流 JavaScript 库和 Node 包,无需远程环境或本地安装即可开箱即用。 + +### Bolt(本仓库)与 [Bolt.new](https://bolt.new) 有什么区别? + +- **Bolt.new**: 这是 StackBlitz 的**商业产品** - 一个托管的、基于浏览器的 AI 开发工具,使用户能够直接在浏览器中提、运行、编辑和部署全栈 Web 应用程序。它基于 [Bolt 开源仓库](https://github.com/stackblitz/bolt.new) 构建,并由 StackBlitz 的 **WebContainer API** 提供支持。 + +- **Bolt(本仓库)**: 这个开源仓库提供了用于构建 **Bolt.new** 的核心组件。该仓库包含 Bolt 的 UI 界面以及使用 [Remix Run](https://remix.run/) 构建的服务器组件。通过利用这个仓库和 StackBlitz 的 **WebContainer API**,你可以创建自己的 AI 驱动的开发工具和完全在浏览器中运行的全栈应用程序。 + +# 开始使用 Bolt 进行开发 + +Bolt 将 AI 的能力与沙盒化的开发环境相结合,创造了一种协作体验,让代码可以由助手和程序员共同开发。Bolt 结合了 [WebContainer API](https://webcontainers.io/api)、[Claude Sonnet 3.5](https://www.anthropic.com/news/claude-3-5-sonnet)、[Remix](https://remix.run/) 和 [AI SDK](https://sdk.vercel.ai/)。 + +### WebContainer API + +Bolt 使用 [WebContainers](https://webcontainers.io/) 在浏览器中运行生成的代码。WebContainers 为 Bolt 提供了一个使用 [WebContainer API](https://webcontainers.io/api) 的全栈沙盒环境。WebContainers 直接在浏览器中运行全栈应用程序,无需云托管 AI 代理的成本和安全隐患。WebContainers 是交互式和可编辑的,使 Bolt 的 AI 能够运行码并理解用户的任何更改。 + +[WebContainer API](https://webcontainers.io) 对个人和开源使用是免费的。如果你正在构建商业用途的应用程序,可以在[这里](https://stackblitz.com/pricing#webcontainer-api)了解更多关于我们的 WebContainer API 商业使用定价。 + +### Remix 应用 + +Bolt 使用 [Remix](https://remix.run/) 构建,并使用 [CloudFlare Pages](https://pages.cloudflare.com/) 和 [CloudFlare Workers](https://workers.cloudflare.com/) 部署。 + +### AI SDK 集成 + +Bolt 使用 [AI SDK](https://github.com/vercel/ai) 与 AI 模型集成。目前,Bolt 支持使用 Anthropic 的 Claude Sonnet 3.5。 +你可以从 [Anthropic API 控制台](https://console.anthropic.com/) 获取 API 密钥以在 Bolt 中使用。 +看看 [Bolt 如何使用 AI SDK](https://github.com/stackblitz/bolt.new/tree/main/app/lib/.server/llm) + +## 先决条件 + +在开始之前,请确保已安装以下内容: + +- Node.js (v20.15.1) +- pnpm (v9.4.0) + +## 设置 + +1. 克隆仓库(如果你还没有): + +```bash +git clone https://github.com/stackblitz/bolt.new.git +``` + +2. 安装依赖: + +```bash +pnpm install +``` + +3. 在根目录创建一个 `.env.local` 文件,并添加你的 Anthropic API 密钥: + +ANTHROPIC_API_KEY=XXX + + +可选地,你可以设置调试级别: + +VITE_LOG_LEVEL=debug + + +**重要**: 永不要将你的 `.env.local` 文件提交到版本控制。它已经包含在 .gitignore 中。 + +## 项目结构 + +以下是 Bolt 项目的详细目录结构: + +``` +bolt.new/ +├── app/ +│ ├── components/ +│ │ ├── chat/ # 聊天相关组件 +│ │ ├── editor/ # 编辑器相关组件 +│ │ ├── header/ # 头部组件 +│ │ ├── sidebar/ # 侧边栏组件 +│ │ ├── ui/ # 通用UI组件 +│ │ └── workbench/ # 工作台组件 +│ ├── lib/ +│ │ ├── .server/ +│ │ │ └── llm/ # 语言模型相关代码 +│ │ │ ├── api-key.ts # API密钥处理 +│ │ │ ├── base-url.ts # 基础URL配置 +│ │ │ ├── constants.ts # 常量定义 +│ │ │ ├── model.ts # 模型配置 +│ │ │ ├── prompts.ts # 提示词模板 +│ │ │ ├── stream-text.ts # 文本流处理 +│ │ │ └── switchable-stream.ts # 可切换流实现 +│ │ ├── ai/ # AI 相关功能 +│ │ ├── hooks/ # React hooks +│ │ ├── persistence/ # 数据持久化 +│ │ ├── runtime/ # 运行时相关代码 +│ │ └── stores/ # 状态管理 +│ ├── routes/ # Remix 路由 +│ ├── styles/ +│ │ ├── components/ # 组件特定样式 +│ │ └── index.scss # 主样式文件 +│ └── utils/ # 工具函数 +├── public/ # 静态资源 +│ └── icons/ # 图标文件 +├── types/ # TypeScript 类型定义 +├── .editorconfig # 编辑器配置 +├── .env.example # 环境变量示例 +├── .eslintignore # ESLint 忽略配置 +├── .gitignore # Git 忽略配置 +├── .prettierignore # Prettier 忽略配置 +├── .prettierrc # Prettier 配置 +├── eslint.config.mjs # ESLint 配置 +├── package.json # 项目依赖和脚本 +├── pnpm-lock.yaml # pnpm 锁文件 +├── README.md # 英文说明文档 +├── README_CN.md # 中文说明文档 +├── tsconfig.json # TypeScript 配置 +├── uno.config.ts # UnoCSS 配置 +└── vite.config.ts # Vite 配置文件 + + +主要目录和文件说明: + +- `app/`: 包含应用程序的主要源代码。 + - `components/`: 包含所有 React 组件,按功能分类。 + - `lib/`: 包含核心库和工具。 + - `.server/`: 包含服务器端代码。 + - `llm/`: 语言模型相关代码。 + - `api-key.ts`: 处理 API 密钥的逻辑。 + - `base-url.ts`: 配置基础 URL。 + - `constants.ts`: 定义常量。 + - `model.ts`: 配置和初始化语言模型。 + - `prompts.ts`: 定义系统提示和其他提示模板。 + - `stream-text.ts`: 处理文本流的逻辑。 + - `switchable-stream.ts`: 实现可切换的流。 + - `ai/`: 包含 AI 相关功能。 + - `hooks/`: 包含自定义 React hooks。 + - `persistence/`: 包含数据持久化相关代码。 + - `runtime/`: 包含运行时相关代码。 + - `stores/`: 包含状态管理相关代码。 + - `routes/`: 包含 Remix 路由定义。 + - `styles/`: 包含全局样式文件和组件特定样式。 + - `utils/`: 包含通用工具函数。 +- `public/`: 包含静态资源文件,如图标。 +- `types/`: 包含 TypeScript 类型定义。 +- 根目录下的配置文件: + - `.editorconfig`: 编辑器配置文件。 + - `.env.example`: 环境变量示例文件。 + - `.eslintignore` 和 `eslint.config.mjs`: ESLint 相关配置。 + - `.gitignore`: Git 忽略文件配置。 + - `.prettierignore` 和 `.prettierrc`: Prettier 代码格式化配置。 + - `package.json`: 项目依赖和脚本定义。 + - `tsconfig.json`: TypeScript 配置文件。 + - `uno.config.ts`: UnoCSS 配置文件。 + - `vite.config.ts`: Vite 构建工具的配置文件。 + +## 可用脚本 + +- `pnpm run dev`: 启动开发服务器。 +- `pnpm run build`: 构建项目。 +- `pnpm run start`: 使用 Wrangler Pages 在本地运行构建的应用程序。此脚本使用 `bindings.sh` 设置必要的绑定,因此你不必重复环境变量。 +- `pnpm run preview`: 构建项目然后在本地启动,用于测试生产构建。注意,HTTP 流目前无法按预期与 `wrangler pages dev` 一起工作。 +- `pnpm test`: 使用 Vitest 运行测试套件。 +- `pnpm run typecheck`: 运行 TypeScript 类型检查。 +- `pnpm run typegen`: 使用 Wrangler 生成 TypeScript 类型。 +- `pnpm run deploy`: 构建项目并将其部署到 Cloudflare Pages。 + +## 开发 + +要启动开发服务器: + +```bash +pnpm run dev +``` + +这将启动 Remix Vite 开发服务器。 + +## 测试 + +运行测试套件: + +```bash +pnpm test +``` + +## 部署 + +要将应用程序部署到 Cloudflare Pages: + +```bash +pnpm run deploy +``` + +确保你拥有必要的权限,并且 Wrangler 已正确配置为你的 Cloudflare 账户。 diff --git a/app/components/auth/Login.tsx b/app/components/auth/Login.tsx new file mode 100644 index 000000000..05e6d1bbd --- /dev/null +++ b/app/components/auth/Login.tsx @@ -0,0 +1,81 @@ +import React, { useState } from 'react'; +import { useAuth } from '~/hooks/useAuth'; +import type { LoginResponse } from '~/routes/api.auth.login'; + +interface LoginProps { + onClose: () => void; + onLoginSuccess: () => void; +} + +export function Login({ onClose, onLoginSuccess }: LoginProps) { + const [phone, setPhone] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const { login } = useAuth(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ phone, password }), + }); + const data = (await response.json()) as LoginResponse; + if (response.ok && data.token && data.user) { + login(data.token, data.user); + onClose(); // 登录成功后关闭登录窗口 + onLoginSuccess(); + } else { + setError(data.error || '登录失败,请检查您的手机号和密码'); + } + } catch (error) { + console.error('Login failed:', error); + setError('登录失败,请稍后再试'); + } + }; + + return ( +
+
+ + setPhone(e.target.value)} + required + className="mt-1 block w-full px-3 py-2 bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded-md shadow-sm focus:outline-none focus:ring-bolt-elements-button-primary-background focus:border-bolt-elements-button-primary-background" + /> +
+
+ + setPassword(e.target.value)} + required + className="mt-1 block w-full px-3 py-2 bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded-md shadow-sm focus:outline-none focus:ring-bolt-elements-button-primary-background focus:border-bolt-elements-button-primary-background" + /> +
+ {error &&
{error}
} +
+ +
+
+ ); +} diff --git a/app/components/auth/LoginDialog.tsx b/app/components/auth/LoginDialog.tsx new file mode 100644 index 000000000..be4beb64f --- /dev/null +++ b/app/components/auth/LoginDialog.tsx @@ -0,0 +1,20 @@ +import { Dialog, DialogTitle, DialogDescription, DialogRoot } from '~/components/ui/Dialog'; +import { Login } from '~/components/auth/Login'; + +interface LoginDialogProps { + isOpen: boolean; + onClose: () => void; +} + +export function LoginDialog({ isOpen, onClose }: LoginDialogProps) { + return ( + + + 登录 + + + + + + ); +} diff --git a/app/components/auth/LoginRegisterDialog.tsx b/app/components/auth/LoginRegisterDialog.tsx new file mode 100644 index 000000000..078633412 --- /dev/null +++ b/app/components/auth/LoginRegisterDialog.tsx @@ -0,0 +1,39 @@ +import { useState } from 'react'; +import { Dialog, DialogTitle, DialogDescription, DialogRoot } from '~/components/ui/Dialog'; +import { Login } from './Login'; +import { Register } from './Register'; + +interface LoginRegisterDialogProps { + isOpen: boolean; + onClose: () => void; + onLoginSuccess: () => void; +} + +export function LoginRegisterDialog({ isOpen, onClose, onLoginSuccess }: LoginRegisterDialogProps) { + const [isLoginView, setIsLoginView] = useState(true); + + const toggleView = () => setIsLoginView(!isLoginView); + + return ( + + + {isLoginView ? '登录' : '注册'} + + {isLoginView ? ( + + ) : ( + + )} +
+ +
+
+
+
+ ); +} diff --git a/app/components/auth/PaymentDialog.tsx b/app/components/auth/PaymentDialog.tsx new file mode 100644 index 000000000..0519ecba6 --- /dev/null +++ b/app/components/auth/PaymentDialog.tsx @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/components/auth/PaymentModal.tsx b/app/components/auth/PaymentModal.tsx new file mode 100644 index 000000000..a12e72696 --- /dev/null +++ b/app/components/auth/PaymentModal.tsx @@ -0,0 +1,115 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Dialog, DialogTitle, DialogDescription, DialogRoot } from '~/components/ui/Dialog'; +import { toast } from 'react-toastify'; +import { useAuth } from '~/hooks/useAuth'; // 导入 useAuth hook + +interface PaymentModalProps { + isOpen: boolean; + onClose: () => void; + paymentData: PaymentResponse; + onPaymentSuccess: () => void; +} + +interface PaymentResponse { + status: string; + msg: string; + no: string; + pay_type: string; + order_amount: string; + pay_amount: string; + qr_money: string; + qr: string; + qr_img: string; + did: string; + expires_in: string; + return_url: string; + orderNo: string; +} + +interface PaymentStatusResponse { + status: string; + error?: string; +} + +export function PaymentModal({ isOpen, onClose, paymentData, onPaymentSuccess }: PaymentModalProps) { + const [timeLeft, setTimeLeft] = useState(parseInt(paymentData.expires_in)); + const { token } = useAuth(); // 获取认证token + + const checkPaymentStatus = useCallback(async () => { + if (!token) { + console.error('No authentication token available'); + return; + } + + try { + const response = await fetch(`/api/check-payment-status?orderNo=${paymentData.orderNo}`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + if (!response.ok) { + if (response.status === 404) { + console.error('Order not found'); + return; + } + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json() as PaymentStatusResponse; + if (data.status === 'completed') { + onPaymentSuccess(); + onClose(); + toast.success('支付成功!'); + } + } catch (error) { + console.error('Error checking payment status:', error); + toast.error('检查支付状态时出错,请稍后再试'); + } + }, [paymentData.orderNo, onPaymentSuccess, onClose, token]); + + useEffect(() => { + if (!isOpen) return; + + let timer: NodeJS.Timeout; + + const checkAndUpdateStatus = () => { + setTimeLeft((prevTime) => { + if (prevTime <= 1) { + clearInterval(timer); + onClose(); + toast.error('支付超时,请重新发起支付'); + return 0; + } + return prevTime - 1; + }); + + checkPaymentStatus(); + }; + + timer = setInterval(checkAndUpdateStatus, 3000); // 每3秒检查一次支付状态 + + return () => clearInterval(timer); + }, [isOpen, onClose, checkPaymentStatus]); + + return ( + + + 请扫码支付 + +
+
+ 支付二维码 +
+
+

订单金额: ¥{paymentData.order_amount}

+

订单号: {paymentData.orderNo}

+

支付方式: {paymentData.pay_type}

+
+
+

剩余支付时间: {timeLeft}秒

+
+
+
+
+
+ ); +} diff --git a/app/components/auth/ProfileDialog.tsx b/app/components/auth/ProfileDialog.tsx new file mode 100644 index 000000000..694c112bb --- /dev/null +++ b/app/components/auth/ProfileDialog.tsx @@ -0,0 +1,38 @@ +import { Dialog, DialogTitle, DialogDescription, DialogRoot } from '~/components/ui/Dialog'; +import { useAuth } from '~/hooks/useAuth'; + +interface ProfileDialogProps { + isOpen: boolean; + onClose: () => void; +} + +export function ProfileDialog({ isOpen, onClose }: ProfileDialogProps) { + const { user } = useAuth(); + + if (!user) return null; + + return ( + + + 个人信息 + +
+
+ +

{user.nickname}

+
+
+ +

{user.phone}

+
+ {/* 可以根据需要添加更多用户信息 */} +
+
+
+
+ ); +} diff --git a/app/components/auth/Register.tsx b/app/components/auth/Register.tsx new file mode 100644 index 000000000..3cbe6e424 --- /dev/null +++ b/app/components/auth/Register.tsx @@ -0,0 +1,184 @@ +import React, { useState, useRef } from 'react'; +import { useAuth } from '~/hooks/useAuth'; +import type { RegisterResponse } from '~/routes/api.auth.register'; +import { uploadToOSS } from '~/utils/uploadToOSS'; + +interface RegisterProps { + onClose: () => void; + onRegisterSuccess: () => void; +} + +export function Register({ onClose, onRegisterSuccess }: RegisterProps) { + const [phone, setPhone] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [nickname, setNickname] = useState(''); + const [avatar, setAvatar] = useState(null); + const [avatarPreview, setAvatarPreview] = useState(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const fileInputRef = useRef(null); + const { login } = useAuth(); + + const handleAvatarChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setAvatar(file); + const reader = new FileReader(); + reader.onloadend = () => { + setAvatarPreview(reader.result as string); + }; + reader.readAsDataURL(file); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setIsLoading(true); + + if (password !== confirmPassword) { + setError('两次输入的密码不一致'); + setIsLoading(false); + return; + } + if (!avatar) { + setError('请上传头像'); + setIsLoading(false); + return; + } + + try { + const avatarUrl = await uploadToOSS(avatar); + + const registerResponse = await fetch('/api/auth/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + phone, + password, + nickname, + avatarUrl, + }), + }); + + const data = await registerResponse.json() as RegisterResponse; + if (registerResponse.ok && data.token && data.user) { + login(data.token, data.user); + onClose(); // 关闭注册窗口 + onRegisterSuccess(); // 调用注册成功的回调 + } else { + setError(data.error || '注册失败,请稍后再试'); + } + } catch (error) { + console.error('Registration failed:', error); + setError('注册失败,请稍后再试'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {error && ( +
+ {error} +
+ )} +
+ + setPhone(e.target.value)} + required + className="mt-1 block w-full px-3 py-2 bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded-md shadow-sm focus:outline-none focus:ring-bolt-elements-button-primary-background focus:border-bolt-elements-button-primary-background" + /> +
+
+ + setNickname(e.target.value)} + required + className="mt-1 block w-full px-3 py-2 bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded-md shadow-sm focus:outline-none focus:ring-bolt-elements-button-primary-background focus:border-bolt-elements-button-primary-background" + /> +
+
+ +
+
+ {avatarPreview ? ( + Avatar preview + ) : ( + No image + )} +
+ + +
+
+
+ + setPassword(e.target.value)} + required + className="mt-1 block w-full px-3 py-2 bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded-md shadow-sm focus:outline-none focus:ring-bolt-elements-button-primary-background focus:border-bolt-elements-button-primary-background" + /> +
+
+ + setConfirmPassword(e.target.value)} + required + className="mt-1 block w-full px-3 py-2 bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded-md shadow-sm focus:outline-none focus:ring-bolt-elements-button-primary-background focus:border-bolt-elements-button-primary-background" + /> +
+
+ +
+
+ ); +} diff --git a/app/components/auth/RegisterDialog.tsx b/app/components/auth/RegisterDialog.tsx new file mode 100644 index 000000000..474806bf2 --- /dev/null +++ b/app/components/auth/RegisterDialog.tsx @@ -0,0 +1,20 @@ +import { Dialog, DialogTitle, DialogDescription, DialogRoot } from '~/components/ui/Dialog'; +import { Register } from '~/components/auth/Register'; + +interface RegisterDialogProps { + isOpen: boolean; + onClose: () => void; +} + +export function RegisterDialog({ isOpen, onClose }: RegisterDialogProps) { + return ( + + + 注册 + + + + + + ); +} diff --git a/app/components/auth/SubscriptionDialog.tsx b/app/components/auth/SubscriptionDialog.tsx new file mode 100644 index 000000000..6e384aa1f --- /dev/null +++ b/app/components/auth/SubscriptionDialog.tsx @@ -0,0 +1,281 @@ +import { Dialog, DialogTitle, DialogDescription, DialogRoot } from '~/components/ui/Dialog'; +import { useState, useEffect, useCallback } from 'react'; +import { useAuth } from '~/hooks/useAuth'; +import { toast } from 'react-toastify'; +import { PaymentModal } from './PaymentModal'; +import { LoginRegisterDialog } from './LoginRegisterDialog'; +import type { SubscriptionPlan } from '~/types/subscription'; +import pkg from 'lodash'; +const {toString} = pkg; +import { TokenReloadModal } from './TokenReloadModal'; // 新增导入 + +interface SubscriptionDialogProps { + isOpen: boolean; + onClose: () => void; +} + +interface UserSubscription { + tokenBalance: number; + subscription: { + plan: { + _id: string; + name: string; + tokens: number; + price: number; + description: string; + save_percentage?: number; + }; + expirationDate: string; + } | null; + } + +interface PaymentResponse { + status: string; + msg: string; + no: string; + pay_type: string; + order_amount: string; + pay_amount: string; + qr_money: string; + qr: string; + qr_img: string; + did: string; + expires_in: string; + return_url: string; +} + +interface PurchaseResponse { + success: boolean; + paymentData?: PaymentResponse; + error?: string; +} + +export function SubscriptionDialog({ isOpen, onClose }: SubscriptionDialogProps) { + const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly'); + const [subscriptionPlans, setSubscriptionPlans] = useState([]); + const [userSubscription, setUserSubscription] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const { user, token, isAuthenticated, login } = useAuth(); + const [paymentData, setPaymentData] = useState(null); + const [isLoginRegisterOpen, setIsLoginRegisterOpen] = useState(false); + const [isTokenReloadModalOpen, setIsTokenReloadModalOpen] = useState(false); + + useEffect(() => { + if (isOpen) { + fetchSubscriptionPlans(); + if (isAuthenticated && token) { + fetchUserSubscription(); + } + } + }, [isOpen, isAuthenticated, token]); + + const fetchSubscriptionPlans = async () => { + setIsLoading(true); + try { + const response = await fetch('/api/subscription-plans'); + if (!response.ok) { + throw new Error('获取订阅计划失败'); + } + const data = await response.json() as SubscriptionPlan[]; + setSubscriptionPlans(data); + } catch (error) { + console.error('获取订阅计划时出错:', error); + toast.error('获取订阅计划失败,请稍后再试'); + } finally { + setIsLoading(false); + } + }; + + const fetchUserSubscription = async () => { + if (!token) return; + try { + const headers = { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }; + const userSubResponse = await fetch('/api/user-subscription', { headers }); + if (!userSubResponse.ok) { + throw new Error('获取用户订阅信息失败'); + } + const userSub = await userSubResponse.json() as UserSubscription; + setUserSubscription(userSub); + } catch (error) { + console.error('获取用户订阅信息时出错:', error); + toast.error('获取用户订阅信息失败,请稍后再试'); + } + }; + + const handlePurchase = async (planId: string) => { + if (!isAuthenticated) { + setIsLoginRegisterOpen(true); + return; + } + if (!token) { + toast.error('登录状态异常,请重新登录'); + return; + } + try { + const response = await fetch('/api/purchase-subscription', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + planId, + billingCycle, + }), + }); + const result = await response.json() as PurchaseResponse; + if (response.ok && result.success && result.paymentData) { + setPaymentData(result.paymentData); + } else { + toast.error(result.error || '获取支付信息失败,请稍后重试。'); + } + } catch (error) { + console.error('Error initiating purchase:', error); + toast.error('购买过程中出现错误,请稍后重试。'); + } + }; + + const handlePaymentSuccess = useCallback(() => { + fetchUserSubscription(); // 重新获取订阅信息 + toast.success('订阅成功!'); + }, [fetchUserSubscription]); + + const handleLoginSuccess = useCallback(() => { + setIsLoginRegisterOpen(false); + fetchUserSubscription(); + toast.success('登录成功!'); + }, [fetchUserSubscription]); + + const handleTokenReloadClick = () => { + setIsTokenReloadModalOpen(true); + }; + + const handleTokenReloadSuccess = useCallback(() => { + fetchUserSubscription(); // 重新获取用户订阅信息 + toast.success('代币充值成功!'); + }, [fetchUserSubscription]); + + if (isLoading) return null; + + return ( + <> + + + 订阅管理 + +
+
+

+ 注册免费账户以加速您在公共项目上的工作流程,或通过即时打开的生产环境提升整个团队的效率。 +

+
+ + {isAuthenticated && userSubscription && userSubscription.subscription && ( +
+
+
+ {toString(userSubscription.tokenBalance)} + 代币剩余。 + + {userSubscription.subscription.plan.tokens.toLocaleString()}代币将在{new Date(userSubscription.subscription.expirationDate).toLocaleDateString()}后添加。 + +
+ +
+
+ )} + +
+ + +
+ +
+ {subscriptionPlans.map((plan) => ( +
+

{plan.name}

+
+ {(plan.tokens / 1000000).toFixed(0)}M 代币 + {plan.save_percentage && ( + 节省 {plan.save_percentage}% + )} +
+

{plan.description}

+
+ ¥{plan.price * (billingCycle === 'yearly' ? 10 : 1)}/{billingCycle === 'yearly' ? '年' : '月'} +
+ +
+ ))} +
+
+
+
+
+ {paymentData && ( + setPaymentData(null)} + paymentData={paymentData} + onPaymentSuccess={handlePaymentSuccess} + /> + )} + setIsLoginRegisterOpen(false)} + onLoginSuccess={handleLoginSuccess} + /> + setIsTokenReloadModalOpen(false)} + onReloadSuccess={handleTokenReloadSuccess} + /> + + ); +} diff --git a/app/components/auth/TokenReloadModal.tsx b/app/components/auth/TokenReloadModal.tsx new file mode 100644 index 000000000..088a423b7 --- /dev/null +++ b/app/components/auth/TokenReloadModal.tsx @@ -0,0 +1,109 @@ +import React, { useState, useEffect } from 'react'; +import { Dialog, DialogTitle, DialogDescription, DialogRoot } from '~/components/ui/Dialog'; +import { useAuth } from '~/hooks/useAuth'; +import { toast } from 'react-toastify'; +import type { PurchaseResponse } from '~/routes/api.purchase-token-reload'; + +interface TokenReloadModalProps { + isOpen: boolean; + onClose: () => void; + onReloadSuccess: () => void; +} + +interface TokenReloadPack { + _id: string; + name: string; + tokens: number; + price: number; + description: string; +} + + + +export function TokenReloadModal({ isOpen, onClose, onReloadSuccess }: TokenReloadModalProps) { + const [tokenReloadPacks, setTokenReloadPacks] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const { token } = useAuth(); + + useEffect(() => { + if (isOpen) { + fetchTokenReloadPacks(); + } + }, [isOpen]); + + const fetchTokenReloadPacks = async () => { + setIsLoading(true); + try { + const response = await fetch('/api/token-reload-packs'); + if (!response.ok) { + throw new Error('获取代币充值包失败'); + } + const data = await response.json() as TokenReloadPack[]; + setTokenReloadPacks(data); + } catch (error) { + console.error('获取代币充值包时出错:', error); + toast.error('获取代币充值包失败,请稍后再试'); + } finally { + setIsLoading(false); + } + }; + + const handlePurchase = async (packId: string) => { + if (!token) { + toast.error('登录状态异常,请重新登录'); + return; + } + try { + const response = await fetch('/api/purchase-token-reload', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ packId }), + }); + const result = await response.json() as PurchaseResponse; + if (response.ok && result.success) { + onReloadSuccess(); + onClose(); + } else { + toast.error(result.error || '购买代币充值包失败,请稍后重试'); + } + } catch (error) { + console.error('Error purchasing token reload pack:', error); + toast.error('购买代币充值包过程中出现错误,请稍后重试'); + } + }; + + if (isLoading) return null; + + return ( + + + 购买代币充值包 + +
+ {tokenReloadPacks.map((pack) => ( +
+

{pack.name}

+
+ {(pack.tokens / 1000000).toFixed(0)}M 代币 +
+

{pack.description}

+
+ ¥{pack.price.toFixed(2)} +
+ +
+ ))} +
+
+
+
+ ); +} diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index c4f90f43a..7c939ee66 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -1,5 +1,5 @@ import type { Message } from 'ai'; -import React, { type RefCallback } from 'react'; +import React, { type RefCallback, useState } from 'react'; import { ClientOnly } from 'remix-utils/client-only'; import { Menu } from '~/components/sidebar/Menu.client'; import { IconButton } from '~/components/ui/IconButton'; @@ -7,6 +7,8 @@ import { Workbench } from '~/components/workbench/Workbench.client'; import { classNames } from '~/utils/classNames'; import { Messages } from './Messages.client'; import { SendButton } from './SendButton.client'; +import { TemplateSelector } from '~/components/workbench/TemplateSelector'; +import { type TemplateName } from '~/utils/templates'; import styles from './BaseChat.module.scss'; @@ -28,11 +30,11 @@ interface BaseChatProps { } const EXAMPLE_PROMPTS = [ - { text: 'Build a todo app in React using Tailwind' }, - { text: 'Build a simple blog using Astro' }, - { text: 'Create a cookie consent form using Material UI' }, - { text: 'Make a space invaders game' }, - { text: 'How do I center a div?' }, + { text: '使用 React 和 Tailwind 构建一个待办事项应用' }, + { text: '使用 Astro 构建一个简单的博客' }, + { text: '使用 Material UI 创建一个 cookie 同意表单' }, + { text: '制作一个太空入侵者游戏' }, + { text: '如何让一个 div 居中?' }, ]; const TEXTAREA_MIN_HEIGHT = 76; @@ -58,6 +60,18 @@ export const BaseChat = React.forwardRef( ref, ) => { const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200; + const [selectedTemplate, setSelectedTemplate] = useState('basic'); + + const handleTemplateChange = async (templateName: TemplateName) => { + setSelectedTemplate(templateName); + try { + console.log('templateName', templateName); + // await workbenchStore.changeTemplate(templateName); + } catch (error) { + console.error('Failed to change template:', error); + // 可以在这里添加错误处理,比如显示一个错误提示 + } + }; return (
( {!chatStarted && (

- Where ideas begin + 创意的起点

- Bring ideas to life in seconds or get help on existing projects. + 在几秒钟内将想法变为现实,或获取现有项目的帮助。

+ {/* */}
)}
( minHeight: TEXTAREA_MIN_HEIGHT, maxHeight: TEXTAREA_MAX_HEIGHT, }} - placeholder="How can Bolt help you today?" + placeholder="多八多今天能为您做些什么?" translate="no" /> @@ -152,7 +171,7 @@ export const BaseChat = React.forwardRef(
( {enhancingPrompt ? ( <>
-
Enhancing prompt...
+
正在增强提示...
) : ( <>
- {promptEnhanced &&
Prompt enhanced
} + {promptEnhanced &&
提示已增强
} )}
{input.length > 3 ? (
- Use Shift + Return for a new line + 使用 Shift + 回车 换行
) : null}
-
{/* Ghost Element */}
+
{/* 幽灵元素 */}
{!chatStarted && ( diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index dff7598e4..390f2ff14 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -12,6 +12,7 @@ import { fileModificationsToHTML } from '~/utils/diff'; import { cubicEasingFn } from '~/utils/easings'; import { createScopedLogger, renderLogger } from '~/utils/logger'; import { BaseChat } from './BaseChat'; +import { SubscriptionDialog } from '~/components/auth/SubscriptionDialog'; const toastAnimation = cssTransition({ enter: 'animated fadeInRight', @@ -24,10 +25,28 @@ export function Chat() { renderLogger.trace('Chat'); const { ready, initialMessages, storeMessageHistory } = useChatHistory(); + const [isSubscriptionDialogOpen, setIsSubscriptionDialogOpen] = useState(false); + + const handleError = (error: Error) => { + console.error('Chat error:', error); + if (error.message === 'Insufficient token balance') { + toast.error('代币余额不足,请购买更多代币或升级订阅计划', { + onClick: () => setIsSubscriptionDialogOpen(true), + }); + } else { + toast.error('发生错误,请稍后重试'); + } + }; return ( <> - {ready && } + {ready && ( + + )} { return ( @@ -48,23 +67,27 @@ export function Chat() { return
; } } - return undefined; }} position="bottom-right" pauseOnFocusLoss transition={toastAnimation} /> + setIsSubscriptionDialogOpen(false)} + /> ); } -interface ChatProps { +interface ChatImplProps { initialMessages: Message[]; - storeMessageHistory: (messages: Message[]) => Promise; + storeMessageHistory: (messages: Message[]) => void | Promise; + onError: (error: Error) => void; } -export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProps) => { +const ChatImpl = memo(function ChatImpl({ initialMessages, storeMessageHistory, onError }: ChatImplProps) { useShortcuts(); const textareaRef = useRef(null); @@ -75,7 +98,7 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp const [animationScope, animate] = useAnimate(); - const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({ + const { messages, isLoading, input, handleInputChange, setInput, stop, append, reload, error } = useChat({ api: '/api/chat', onError: (error) => { logger.error('Request failed\n\n', error); @@ -85,6 +108,15 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp logger.debug('Finished streaming'); }, initialMessages, + onResponse(response) { + if (response.status === 401) { + // 处理未授权错误 + onError(new Error('Unauthorized')); + } else if (response.status === 402) { + // 处理代币不足错误 + onError(new Error('Insufficient token balance')); + } + }, }); const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer(); @@ -100,9 +132,12 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp parseMessages(messages, isLoading); if (messages.length > initialMessages.length) { - storeMessageHistory(messages).catch((error) => toast.error(error.message)); + const result = storeMessageHistory(messages); + if (result instanceof Promise) { + result.catch((error: Error) => toast.error(error.message)); + } } - }, [messages, isLoading, parseMessages]); + }, [messages, isLoading, parseMessages, initialMessages.length, storeMessageHistory]); const scrollTextArea = () => { const textarea = textareaRef.current; @@ -198,6 +233,12 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp const [messageRef, scrollRef] = useSnapScroll(); + useEffect(() => { + if (error) { + onError(error); + } + }, [error, onError]); + return ( {() => } - {chat.started && ( - - {() => ( -
- -
- )} -
- )} +
+ {chat.started && ( + + {() => ( +
+ +
+ )} +
+ )} + {isAuthenticated ? ( + + ) : ( + <> + + + + )} + {isAuthenticated && ( + + )} +
+ + setIsLoginOpen(false)} /> + setIsRegisterOpen(false)} /> + setIsSubscriptionOpen(false)} /> ); } diff --git a/app/components/header/UserMenu.tsx b/app/components/header/UserMenu.tsx new file mode 100644 index 000000000..9e8cf74e7 --- /dev/null +++ b/app/components/header/UserMenu.tsx @@ -0,0 +1,82 @@ +import { Menu, Transition } from '@headlessui/react'; +import { Fragment, useState } from 'react'; +import { useAuth } from '~/hooks/useAuth'; +import { Avatar } from '~/components/ui/Avatar'; +import { ProfileDialog } from '~/components/auth/ProfileDialog'; +import { SubscriptionDialog } from '~/components/auth/SubscriptionDialog'; + +export function UserMenu() { + const { user, logout } = useAuth(); + const [isProfileOpen, setIsProfileOpen] = useState(false); + const [isSubscriptionOpen, setIsSubscriptionOpen] = useState(false); + + if (!user) return null; + + return ( + <> + +
+ + + {user.nickname} + + + + +
+ + {({ active }) => ( + + )} + + + {({ active }) => ( + + )} + + + {({ active }) => ( + + )} + +
+
+
+
+ + setIsProfileOpen(false)} /> + setIsSubscriptionOpen(false)} /> + + ); +} diff --git a/app/components/ui/Avatar.tsx b/app/components/ui/Avatar.tsx new file mode 100644 index 000000000..6e47dbf5a --- /dev/null +++ b/app/components/ui/Avatar.tsx @@ -0,0 +1,28 @@ +import { useState } from 'react'; +import { env } from '~/config/env.client'; + +interface AvatarProps { + src: string; + alt: string; + className?: string; +} + +export function Avatar({ src = '', alt, className = '' }: AvatarProps) { + const [imgSrc, setImgSrc] = useState(src.startsWith('http') ? src : `${env.OSS_HOST}${src}`); + const [error, setError] = useState(false); + + const handleError = () => { + setError(true); + // 设置一个默认的头像 URL + setImgSrc(`${env.OSS_HOST}/avatars/default-avatar.png`); + }; + + return ( + {alt} + ); +} diff --git a/app/components/ui/Select.tsx b/app/components/ui/Select.tsx new file mode 100644 index 000000000..a614e79ab --- /dev/null +++ b/app/components/ui/Select.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { classNames } from '~/utils/classNames'; + +interface SelectOption { + value: string; + label: string; +} + +interface SelectProps { + options: SelectOption[]; + value: string; + onChange: (value: string) => void; + className?: string; + placeholder?: string; +} + +export const Select: React.FC = ({ options, value, onChange, className, placeholder }) => { + return ( + + ); +}; diff --git a/app/components/workbench/TemplateSelector.tsx b/app/components/workbench/TemplateSelector.tsx new file mode 100644 index 000000000..1b80089aa --- /dev/null +++ b/app/components/workbench/TemplateSelector.tsx @@ -0,0 +1,21 @@ +import { memo } from 'react'; +import { templates, type TemplateName } from '~/utils/templates'; +import { Select } from '~/components/ui/Select'; + +interface TemplateSelectorProps { + className?: string; + value: TemplateName; + onChange: (templateName: TemplateName) => void; +} + +export const TemplateSelector = memo(({ className, value, onChange }: TemplateSelectorProps) => { + return ( +