├── .eslintrc.json ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .gitmodules ├── LICENSE ├── README.md ├── README.zh-CN.md ├── app ├── api │ ├── llm │ │ └── proxy │ │ │ └── route.ts │ └── tts │ │ └── volcengine │ │ └── route.ts ├── components │ ├── BookAddOrEditModal │ │ ├── cpns │ │ │ └── ChapterManager.tsx │ │ └── index.tsx │ ├── BookDetailsModal │ │ └── index.tsx │ ├── BookGrid │ │ └── index.tsx │ ├── BookUploader │ │ └── index.tsx │ ├── PageLoading.tsx │ ├── common │ │ ├── CardComponent.tsx │ │ └── MarkdownViewer │ │ │ ├── index.css │ │ │ └── index.tsx │ ├── footer │ │ └── index.tsx │ ├── header │ │ └── index.tsx │ ├── layout │ │ ├── structure-layout.tsx │ │ └── theme-provider.tsx │ ├── preload.tsx │ └── sider │ │ ├── components │ │ ├── SiderChat │ │ │ ├── cpns │ │ │ │ ├── ChatContent.tsx │ │ │ │ ├── ChatHistory.tsx │ │ │ │ ├── ChatInput.tsx │ │ │ │ ├── ChatTools.tsx │ │ │ │ ├── MessageBubble.tsx │ │ │ │ └── index.ts │ │ │ └── index.tsx │ │ └── SiderContent │ │ │ ├── cpns │ │ │ ├── CurrentSentence.tsx │ │ │ ├── MenuLine.tsx │ │ │ ├── Sentences.tsx │ │ │ ├── WordDetails.tsx │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ ├── index.tsx │ │ ├── layout.tsx │ │ └── page.tsx ├── favicon.ico ├── globals.css ├── home │ ├── loading.tsx │ └── page.tsx ├── layout.tsx ├── page.tsx ├── read │ ├── components │ │ ├── menu.tsx │ │ └── readArea │ │ │ └── index.tsx │ ├── loading.tsx │ └── page.tsx └── setting │ ├── components │ ├── AiSection │ │ ├── cpns │ │ │ ├── ModelCard.tsx │ │ │ ├── ModelFormModal.tsx │ │ │ ├── ProviderForm.tsx │ │ │ └── index.ts │ │ └── index.tsx │ ├── Card.tsx │ ├── DefaultModelSection │ │ └── index.tsx │ ├── ExpandableDescription │ │ └── index.tsx │ ├── PromptSection │ │ └── index.tsx │ ├── SentenceProcessingSection │ │ └── index.tsx │ ├── TTSSection │ │ ├── cpns │ │ │ ├── TTSForm.tsx │ │ │ └── index.ts │ │ └── index.tsx │ ├── WordProcessingSection │ │ └── index.tsx │ └── index.ts │ ├── loading.tsx │ └── page.tsx ├── assets └── icon.tsx ├── config └── llm.ts ├── constants ├── book.ts ├── llm.ts ├── output.ts ├── prompt.ts └── upload.ts ├── hooks └── useBook.ts ├── i18n ├── index.ts ├── locales │ ├── en.json │ └── zh.json └── useTranslation.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── services ├── BookService.ts ├── DB.ts ├── Download.ts ├── Epub.ts ├── EventService.ts ├── MD.ts ├── ServerUpload.ts ├── TXT.ts ├── llm │ ├── clients │ │ └── openai.ts │ └── index.ts └── tts │ ├── cpns │ ├── system.ts │ └── volcengine.ts │ └── index.ts ├── src-tauri ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── build.rs ├── capabilities │ └── default.json ├── icons │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── 32x32.png │ ├── icon.icns │ └── icon.ico ├── src │ ├── lib.rs │ └── main.rs └── tauri.conf.json ├── store ├── useHeaderStore.ts ├── useHistoryStore.ts ├── useLLMStore.ts ├── useOutputOptions.ts ├── useReadingProgress.ts ├── useSiderStore.ts ├── useStyleStore.ts └── useTTSStore.ts ├── tailwind.config.ts ├── tsconfig.json ├── types ├── book.ts ├── llm.ts ├── prompt.ts └── tts.ts └── utils ├── franc.ts ├── generator.ts ├── llm.ts └── provider.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: 'Release' 2 | 3 | on: 4 | push: 5 | branches: 6 | - release 7 | workflow_dispatch: 8 | 9 | jobs: 10 | publish-tauri: 11 | permissions: 12 | contents: write 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | include: 17 | - platform: 'macos-latest' 18 | args: '--target aarch64-apple-darwin' 19 | - platform: 'macos-latest' 20 | args: '--target x86_64-apple-darwin' 21 | - platform: 'ubuntu-22.04' 22 | args: '' 23 | - platform: 'windows-latest' 24 | args: '' 25 | 26 | runs-on: ${{ matrix.platform }} 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - name: Setup Node.js 31 | uses: actions/setup-node@v4 32 | with: 33 | node-version: 'lts/*' 34 | cache: 'npm' 35 | 36 | - name: Install Rust stable 37 | uses: dtolnay/rust-toolchain@stable 38 | with: 39 | targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} 40 | 41 | - name: Rust cache 42 | uses: swatinem/rust-cache@v2 43 | with: 44 | workspaces: './src-tauri -> target' 45 | 46 | - name: Install dependencies (Ubuntu only) 47 | if: matrix.platform == 'ubuntu-22.04' 48 | run: | 49 | sudo apt-get update 50 | sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf 51 | 52 | - name: Install frontend dependencies 53 | run: npm install 54 | 55 | - name: Build the app 56 | uses: tauri-apps/tauri-action@v0 57 | env: 58 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | with: 60 | tagName: app-v__VERSION__ 61 | releaseName: 'App v__VERSION__' 62 | releaseBody: 'See the assets to download this version and install.' 63 | releaseDraft: true 64 | prerelease: false 65 | args: ${{ matrix.args }} 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | key.json 39 | /doc -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "packages/foliate-js"] 2 | path = packages/foliate-js 3 | url = https://github.com/johnfactotum/foliate-js.git 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 WindChime-Echo 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 | # ReadBridge: AI-Enhanced Reading Assistant for Language Learning 2 | 3 | *[English](./README.md) | [中文](./README.zh-CN.md)* 4 | 5 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Next.js](https://img.shields.io/badge/Next.js-black?logo=next.js&logoColor=white)](https://nextjs.org/) [![Tauri](https://img.shields.io/badge/Tauri-24C8D8?logo=tauri&logoColor=white)](https://tauri.app/) [![Web](https://img.shields.io/badge/Platform-Web-blue)](https://nextjs.org/) [![Windows](https://img.shields.io/badge/Platform-Windows-blue?logo=windows&logoColor=white)](https://tauri.app/) [![macOS](https://img.shields.io/badge/Platform-macOS-blue?logo=apple&logoColor=white)](https://tauri.app/) [![Linux](https://img.shields.io/badge/Platform-Linux-blue?logo=linux&logoColor=white)](https://tauri.app/) 6 | 7 | [![Documentation](https://img.shields.io/badge/Documentation-docs.readbridge.cc-blue)](https://docs.readbridge.cc/) 8 | 9 | ReadBridge is an AI-powered reading assistant available as both a web application and desktop software (via Tauri). It enhances language learning through the "n+1" comprehensible input approach, helping learners engage with content in their target language. 10 | 11 | ## Overview 12 | 13 | This reading assistant enables a source-to-source language learning approach, reducing reliance on translation to your native language. The platform helps learners practice reading within the target language ecosystem, supporting natural language acquisition through contextual understanding. 14 | 15 | ## Comprehensible Input Theory 16 | 17 | ReadBridge draws inspiration from Stephen Krashen's Comprehensible Input Hypothesis, which suggests: 18 | 19 | - **Natural Acquisition**: We acquire language when we understand messages in context 20 | - **Input Level**: Learning is effective when input is slightly above current competence 21 | - **Focus on Meaning**: Understanding content takes precedence over explicit grammar study 22 | 23 | ## Project Origin 24 | 25 | The inspiration for ReadBridge came from a video I stumbled upon while browsing, which completely transformed my understanding of language learning. The video discussed three major challenges in language learning: 26 | 27 | - **Arbitrary Symbol Challenge**: Memorizing words is like remembering meaningless symbols, making this type of memory easily forgotten 28 | - **Breadth Challenge**: Vocabulary is as vast as an ocean, and pure memorization is like trying to scoop up the sea with a bucket 29 | - **Depth Challenge**: Word meaning is as deep as a well, and rote memorization only scratches the surface 30 | 31 | What impressed me most was the concept that "only 1-2 unknown words per 100 words" creates comprehensible input, and that "a word needs to be repeated at least 12 times in different contexts" to be truly mastered. This made me realize that learning vocabulary in context is as natural and effective as solving a puzzle. 32 | 33 | The video also introduced the story of Hungarian linguist Lomb Kato, who mastered 15 languages through reading original works, and research showing that ten novels by Sidney Sheldon could cover over 90% of college-level English vocabulary, with each word repeated an average of 26 times. Studies suggest that regular reading may be the primary source of most of our vocabulary, and with just half an hour of daily reading, we could complete one million words in a year. 34 | 35 | "Only this kind of reading is true reading" — this statement became my motivation for developing ReadBridge. If you're interested in this learning method, I recommend watching this video: https://www.bilibili.com/festival/jzj2023?bvid=BV1ns4y1A7fj 36 | 37 | ## Key Features 38 | 39 | - **Interactive Reading Interface**: Progress through texts sentence-by-sentence with an intuitive UI 40 | - **AI Reading Support**: Get explanations in the target language to maintain immersion 41 | - **User-Defined Difficulty**: Set prompt templates based on your self-assessed proficiency level 42 | - **Contextual Learning**: Explore vocabulary and grammar structures in authentic contexts 43 | - **Progress Tracking**: Save your reading position across chapters and books 44 | - **Customizable Configuration**: Adjust settings to match your learning preferences 45 | - **Cross-Platform**: Use in any modern browser or as a desktop application 46 | - **Book Management**: Easily import, organize, and access your reading materials 47 | - **Distraction-Free Design**: Clean interface designed for focused reading 48 | 49 | ## Getting Started 50 | 51 | ### Web Version 52 | 53 | 1. Clone the repository 54 | ```bash 55 | git clone https://github.com/WindChimeEcho/read-bridge.git 56 | cd read-bridge 57 | ``` 58 | 59 | 2. Install dependencies 60 | ```bash 61 | npm install 62 | ``` 63 | 64 | 3. Start the development server 65 | ```bash 66 | npm run dev 67 | ``` 68 | 69 | 4. Open [http://localhost:3000](http://localhost:3000) in your browser 70 | 71 | ### Desktop Version (Tauri) 72 | 73 | 1. Follow the [Tauri v2 setup guide](https://v2.tauri.app/guides/quick-start/prerequisites) to install prerequisites 74 | 75 | 2. Install dependencies and build the application 76 | ```bash 77 | npm run tauri dev 78 | ``` 79 | 80 | ## Downloads 81 | 82 | You can download the latest version of ReadBridge from our GitHub releases: 83 | 84 | - [All Releases](https://github.com/WindChimeEcho/read-bridge/releases) 85 | 86 | ## Deployment 87 | 88 | Deploy your own instance of ReadBridge with just one click: 89 | 90 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/WindChimeEcho/read-bridge) 91 | 92 | [![Deploy to Cloudflare Pages](https://img.shields.io/badge/Deploy%20to-Cloudflare%20Pages-orange.svg?style=for-the-badge&logo=cloudflare)](https://developers.cloudflare.com/pages/framework-guides/deploy-a-nextjs-site/) 93 | 94 | ## Configuration 95 | 96 | ReadBridge offers several configuration options: 97 | 98 | ### AI Settings 99 | - Configure different AI providers (OpenAI, etc.) 100 | - Set up custom models and endpoints 101 | - Manage API keys and access 102 | 103 | ### Model Configuration 104 | - Select default models for different functionalities 105 | - Customize model parameters 106 | 107 | ### Prompt Configuration 108 | - Choose from preset prompt templates or create your own 109 | - Customize prompts based on your language level and learning goals 110 | - Adjust the type of assistance you receive while reading 111 | 112 | ### Sentence Processing 113 | - Configure how texts are segmented and presented 114 | - Adjust the reading flow to match your preferences 115 | 116 | ## How AI Enhances Reading 117 | 118 | ReadBridge leverages AI in focused ways to support your reading: 119 | 120 | - **Contextual Explanations**: Get clarifications about difficult passages in the target language 121 | - **Vocabulary Support**: Understand new words through explanations rather than direct translations 122 | - **Customized Assistance**: Receive help tailored to your self-selected proficiency level 123 | - **Natural Language Interaction**: Ask questions about the text to deepen understanding 124 | 125 | ## Learning Approach 126 | 127 | ReadBridge supports language acquisition through: 128 | 129 | - **Immersion Reading**: Engage with authentic texts in the target language 130 | - **Contextual Understanding**: Learn new elements through context rather than isolated study 131 | - **Personalized Support**: Configure the AI assistance to match your current abilities 132 | - **Reading Flow**: Maintain concentration with a distraction-free interface 133 | 134 | 135 | ## Contributing 136 | 137 | Contributions are welcome! Please feel free to submit a Pull Request. 138 | 139 | ## License 140 | 141 | This project is licensed under the [MIT License](LICENSE). -------------------------------------------------------------------------------- /README.zh-CN.md: -------------------------------------------------------------------------------- 1 | # ReadBridge: AI增强型语言学习阅读助手 2 | 3 | *[English](./README.md) | [中文](./README.zh-CN.md)* 4 | 5 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Next.js](https://img.shields.io/badge/Next.js-black?logo=next.js&logoColor=white)](https://nextjs.org/) [![Tauri](https://img.shields.io/badge/Tauri-24C8D8?logo=tauri&logoColor=white)](https://tauri.app/) [![Web](https://img.shields.io/badge/-Web-blue)](https://nextjs.org/) [![Windows](https://img.shields.io/badge/平台-Windows-blue?logo=windows&logoColor=white)](https://tauri.app/) [![macOS](https://img.shields.io/badge/平台-macOS-blue?logo=apple&logoColor=white)](https://tauri.app/) [![Linux](https://img.shields.io/badge/平台-Linux-blue?logo=linux&logoColor=white)](https://tauri.app/) 6 | 7 | [![文档](https://img.shields.io/badge/文档-docs.readbridge.cc-blue)](https://docs.readbridge.cc/) 8 | 9 | ReadBridge是一款AI驱动的阅读助手,同时提供网页应用和桌面软件(通过Tauri)。它通过"n+1"可理解输入方法辅助语言学习,帮助学习者更有效地阅读目标语言内容。 10 | 11 | ## 概述 12 | 13 | 这款阅读助手实现了源语言到源语言的学习方法,减少对母语翻译的依赖。该平台帮助学习者在目标语言生态系统中练习阅读,通过上下文理解促进自然语言习得。 14 | 15 | ## 可理解输入理论 16 | 17 | ReadBridge基于Stephen Krashen的可理解输入假说,该理论认为: 18 | 19 | - **自然习得**:当我们在上下文中理解信息时,我们能自然习得语言 20 | - **输入水平**:当输入略高于当前能力水平时,学习最为有效 21 | - **意义优先**:理解内容的意义优先于显式学习语法规则 22 | 23 | ## 项目起源 24 | 25 | ReadBridge的灵感来自我在一次刷视频时的偶然发现,这个视频彻底改变了我对语言学习的理解。视频中讲述了语言学习的三大难题: 26 | 27 | - 任意符号难题:记单词就像记住无意义的符号,这种记忆容易遗忘 28 | - 宽度难题:单词量如海洋般广阔,单纯背诵就像用木桶舀海水 29 | - 深度难题:词义深度如井水,单纯记忆只能触及表面 30 | 31 | 让我印象深刻的是:"每百词中只有1-2个生词"的可理解输入理念,以及"一个单词需要在不同语境中重复至少12次"才能真正掌握。这让我意识到,在语境中学习词汇就像解谜一样自然而有效。 32 | 33 | 视频还介绍了匈牙利语言学家Lomb Kato通过阅读原著掌握了15门语言的故事,以及Sidney Sheldon的十本小说能覆盖六级词汇90%以上,平均每个词重复26次的研究发现。研究表明,日常阅读可能是我们获取大部分词汇量的主要来源,每天只需半小时,一年就能完成100万词的阅读量。 34 | 35 | "只有这一种阅读,才是真正的阅读"——这句话成为了我开发ReadBridge的动力。如果你也对这种学习方法感兴趣,推荐看看这个视频:https://www.bilibili.com/festival/jzj2023?bvid=BV1ns4y1A7fj 36 | 37 | ## 主要特点 38 | 39 | - **交互式阅读界面**:通过直观的UI逐句阅读文本 40 | - **AI阅读支持**:获取目标语言的解释,保持语言沉浸 41 | - **用户自定义难度**:根据自我评估的熟练程度设置提示词模板 42 | - **上下文学习**:在真实语境中探索词汇和语法结构 43 | - **进度跟踪**:保存各章节和书籍中的阅读位置 44 | - **自定义配置**:调整设置以匹配你的学习偏好 45 | - **跨平台**:在任何现代浏览器或作为桌面应用使用 46 | - **书籍管理**:轻松导入、组织和访问阅读材料 47 | - **无干扰设计**:简洁界面设计,专注于阅读体验 48 | 49 | ## 开始使用 50 | 51 | ### 网页版 52 | 53 | 1. 克隆仓库 54 | ```bash 55 | git clone https://github.com/WindChimeEcho/read-bridge.git 56 | cd read-bridge 57 | ``` 58 | 59 | 2. 安装依赖 60 | ```bash 61 | npm install 62 | ``` 63 | 64 | 3. 启动开发服务器 65 | ```bash 66 | npm run dev 67 | ``` 68 | 69 | 4. 在浏览器中打开 [http://localhost:3000](http://localhost:3000) 70 | 71 | ### 桌面版 (Tauri) 72 | 73 | 1. 按照 [Tauri v2 设置指南](https://v2.tauri.app/guides/quick-start/prerequisites) 安装先决条件 74 | 75 | 2. 安装依赖并构建应用 76 | ```bash 77 | npm run tauri dev 78 | ``` 79 | 80 | ## 下载 81 | 82 | 您可以从GitHub发布页面下载ReadBridge的最新版本: 83 | 84 | - [所有版本](https://github.com/WindChimeEcho/read-bridge/releases) 85 | 86 | ## 部署 87 | 88 | 只需一键部署您自己的ReadBridge实例: 89 | 90 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/WindChimeEcho/read-bridge) 91 | 92 | [![Deploy to Cloudflare Pages](https://img.shields.io/badge/部署到-Cloudflare%20Pages-orange.svg?style=for-the-badge&logo=cloudflare)](https://developers.cloudflare.com/pages/framework-guides/deploy-a-nextjs-site/) 93 | 94 | ## 配置 95 | 96 | ReadBridge提供多种配置选项: 97 | 98 | ### AI设置 99 | - 配置不同的AI提供商(OpenAI等) 100 | - 设置自定义模型和端点 101 | - 管理API密钥和访问权限 102 | 103 | ### 模型配置 104 | - 为不同功能选择默认模型 105 | - 自定义模型参数 106 | 107 | ### 提示词配置 108 | - 从预设提示词模板中选择或创建自己的模板 109 | - 根据您的语言水平和学习目标自定义提示词 110 | - 调整阅读过程中获取的辅助类型 111 | 112 | ### 句子处理 113 | - 配置文本如何分段和呈现 114 | - 调整阅读流程以匹配您的偏好 115 | 116 | ## AI如何增强阅读体验 117 | 118 | ReadBridge有针对性地利用AI支持您的阅读: 119 | 120 | - **上下文解释**:获取目标语言中关于难点段落的说明 121 | - **词汇支持**:通过解释而非直接翻译理解新词 122 | - **定制化辅助**:接收根据您自选熟练度水平调整的帮助 123 | - **自然语言交互**:提问以深化对文本的理解 124 | 125 | ## 学习方法 126 | 127 | ReadBridge通过以下方式支持语言习得: 128 | 129 | - **沉浸式阅读**:接触目标语言的真实文本 130 | - **上下文理解**:通过语境而非孤立学习新元素 131 | - **个性化支持**:根据您当前能力配置AI辅助 132 | - **阅读流畅性**:通过无干扰界面保持专注 133 | 134 | ## 贡献 135 | 136 | 欢迎贡献!请随时提交PR。 137 | 138 | ## 许可证 139 | 140 | 本项目采用 [MIT 许可证](LICENSE)。 -------------------------------------------------------------------------------- /app/api/llm/proxy/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | export const runtime = 'edge'; 3 | 4 | export async function POST(req: NextRequest) { 5 | try { 6 | const requestBody = await req.json(); 7 | const { url, apiKey, ...restBody } = requestBody; 8 | 9 | if (!url) { 10 | return NextResponse.json({ error: 'URL is required' }, { status: 400 }); 11 | } 12 | 13 | if (!apiKey) { 14 | return NextResponse.json({ error: 'API key is required' }, { status: 400 }); 15 | } 16 | 17 | // 构建转发到 OpenAI 的请求 18 | const response = await fetch(url, { 19 | method: 'POST', 20 | headers: { 21 | 'Content-Type': 'application/json', 22 | 'Authorization': `Bearer ${apiKey}`, 23 | }, 24 | body: JSON.stringify(restBody), 25 | }); 26 | 27 | // 流式响应 28 | if (restBody.stream) { 29 | // 转发流 30 | const readable = response.body; 31 | if (!readable) { 32 | return NextResponse.json({ error: 'Failed to get response stream' }, { status: 500 }); 33 | } 34 | 35 | return new NextResponse(readable, { 36 | status: response.status, 37 | headers: { 38 | 'Content-Type': 'text/event-stream', 39 | 'Cache-Control': 'no-cache', 40 | 'Connection': 'keep-alive', 41 | }, 42 | }); 43 | } 44 | 45 | // 非流式响应 46 | const data = await response.json(); 47 | return NextResponse.json(data, { status: response.status }); 48 | } catch (error) { 49 | console.error('Proxy error:', error); 50 | return NextResponse.json( 51 | { error: 'An error occurred while proxying the request' }, 52 | { status: 500 } 53 | ); 54 | } 55 | } -------------------------------------------------------------------------------- /app/api/tts/volcengine/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { TTSRequest } from '@/types/tts'; 3 | 4 | export const runtime = 'edge'; 5 | 6 | const VOLCENGINE_TTS_URL = 'https://openspeech.bytedance.com/api/v1/tts'; 7 | 8 | export async function POST(req: Request) { 9 | try { 10 | const { text, token, appid, voiceType, speedRatio } = await req.json() as TTSRequest; 11 | 12 | const response = await fetch(VOLCENGINE_TTS_URL, { 13 | method: 'POST', 14 | headers: { 15 | 'Content-Type': 'application/json', 16 | 'Authorization': `Bearer;${token}`, 17 | }, 18 | body: JSON.stringify({ 19 | app: { 20 | appid: appid, 21 | cluster: 'volcano_tts', 22 | }, 23 | user: { 24 | uid: 'uid', 25 | }, 26 | audio: { 27 | voice_type: voiceType, 28 | encoding: 'mp3', 29 | speed_ratio: speedRatio, 30 | }, 31 | request: { 32 | reqid: crypto.randomUUID(), 33 | text, 34 | operation: 'query', 35 | }, 36 | }), 37 | }); 38 | 39 | const data = await response.json(); 40 | return NextResponse.json(data); 41 | } catch (error) { 42 | console.error('TTS API error:', error); 43 | return NextResponse.json( 44 | { error: 'Failed to process TTS request' }, 45 | { status: 500 } 46 | ); 47 | } 48 | } -------------------------------------------------------------------------------- /app/components/BookAddOrEditModal/index.tsx: -------------------------------------------------------------------------------- 1 | import { Book } from "@/types/book"; 2 | import { Modal, Form, Input, Divider, Space, Upload, Button, Row, Col, Select } from "antd"; 3 | import { useCallback, useEffect, useState } from "react"; 4 | import { useTranslation } from "@/i18n/useTranslation"; 5 | import { UploadOutlined } from '@ant-design/icons'; 6 | import type { UploadProps } from 'antd'; 7 | import { COMMON_LANGUAGES } from "@/constants/book"; 8 | import ChapterManager from "./cpns/ChapterManager"; 9 | 10 | const { Option } = Select; 11 | 12 | interface BookAddOrEditModalProps { 13 | open: boolean; 14 | onCancel: () => void; 15 | onOk: (book: Book) => void; 16 | getInitialData: () => Book; 17 | type: 'add' | 'edit'; 18 | } 19 | 20 | export default function BookAddOrEditModal({ open, onCancel, onOk, getInitialData, type }: BookAddOrEditModalProps) { 21 | const { t } = useTranslation(); 22 | const [book, setBook] = useState(); 23 | const [form] = Form.useForm() 24 | 25 | useEffect(() => { 26 | if (!open) return 27 | const initialData = getInitialData(); 28 | setBook(initialData); 29 | form.setFieldsValue({ 30 | title: initialData.metadata.title, 31 | author: initialData.metadata.author, 32 | publisher: initialData.metadata.publisher, 33 | date: initialData.metadata.date, 34 | language: initialData.metadata.language, 35 | }); 36 | }, [open, getInitialData, setBook, form]); 37 | 38 | const handleFormChange = () => { 39 | const formValues = form.getFieldsValue(); 40 | if (book) { 41 | setBook({ 42 | ...book, 43 | title: formValues.title, 44 | author: formValues.author, 45 | metadata: { 46 | ...book.metadata, 47 | ...formValues 48 | } 49 | }); 50 | } 51 | }; 52 | 53 | const uploadProps: UploadProps = { 54 | beforeUpload: file => { 55 | const reader = new FileReader(); 56 | reader.readAsDataURL(file); 57 | reader.onload = () => { 58 | if (book && reader.result) { 59 | const coverData = reader.result.toString(); 60 | setBook({ 61 | ...book, 62 | metadata: { 63 | ...book.metadata, 64 | cover: { 65 | data: coverData.split(',')[1], 66 | mediaType: file.type 67 | } 68 | } 69 | }); 70 | } 71 | }; 72 | return false; 73 | } 74 | }; 75 | 76 | const handleSubmit = useCallback(() => { 77 | form.validateFields().then(() => { 78 | onOk(book as Book); 79 | }); 80 | }, [form, onOk, book]); 81 | 82 | return 91 |

92 | {t('bookDetails.bookAction', { action: type === 'add' ? t('common.add') : t('common.edit') })} 93 |

94 |
95 | 96 | {/* 左侧封面展示与上传 */} 97 | 98 |
99 | {book?.metadata.cover ? ( 100 |
101 |
102 | {t('book.coverAlt')} 107 |
108 |
109 | ) : ( 110 |
112 |

{t('book.noCover')}

113 |
114 | )} 115 | 116 | 117 | 118 | 121 | {book?.metadata.cover && ( 122 | 135 | )} 136 | 137 | 138 |
139 | 140 | 141 | {/* 右侧元数据表单 */} 142 | 143 |
148 | 153 | 154 | 155 | 156 | 160 | 161 | 162 | 163 | 167 | 168 | 169 | 170 | 174 | 175 | 176 | 177 | 178 | 183 | 195 | 196 |
197 | 198 | 199 | 200 |
201 | 202 | 203 | 204 | {/* 下方章节编辑部分 */} 205 |
206 | setBook(updatedBook)} 209 | /> 210 |
211 |
212 |
; 213 | } 214 | 215 | -------------------------------------------------------------------------------- /app/components/BookGrid/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Row, Col, Button } from 'antd'; 4 | import { BookPreview, Resource } from '@/types/book'; 5 | import BookUploader from '@/app/components/BookUploader'; 6 | import { useStyleStore } from '@/store/useStyleStore'; 7 | import { useRouter } from 'next/navigation'; 8 | import { useSiderStore } from '@/store/useSiderStore'; 9 | import { InfoCircleOutlined } from '@ant-design/icons'; 10 | import { useState } from 'react'; 11 | import BookDetailsModal from '@/app/components/BookDetailsModal'; 12 | 13 | interface BookGridProps { 14 | books: BookPreview[]; 15 | } 16 | 17 | export default function BookGrid({ books }: BookGridProps) { 18 | const { itemsPerRow, gutterX, gutterY } = useStyleStore() 19 | const router = useRouter(); 20 | const { setReadingId } = useSiderStore() 21 | const [detailsModalOpen, setDetailsModalOpen] = useState(false); 22 | const [selectedBookId, setSelectedBookId] = useState(''); 23 | 24 | const onBookClick = (id: string) => { 25 | setReadingId(id) 26 | router.push(`/read`); 27 | } 28 | 29 | const showDetailsModal = (id: string, e: React.MouseEvent) => { 30 | e.stopPropagation(); 31 | setSelectedBookId(id); 32 | setDetailsModalOpen(true); 33 | }; 34 | 35 | const closeDetailsModal = () => { 36 | setDetailsModalOpen(false); 37 | }; 38 | 39 | return ( 40 |
41 | 42 | {books.map((book) => ( 43 | 44 |
45 |
onBookClick(book.id)}> 46 | 47 |
48 |
49 |
{book.title}
50 |
51 | {book.author && ( 52 |
{book.author}
53 | )} 54 |
61 |
62 |
63 | 64 | ))} 65 | 66 |
67 | 68 |
69 | 70 |
71 | 72 | 77 |
78 | ); 79 | } 80 | 81 | function handleBase64(base64: string) { 82 | return `data:image/jpeg;base64,${base64}` 83 | } 84 | 85 | const BookCover = ({ cover, title }: { cover: Resource | undefined, title: string }) => { 86 | const imageCSS = ` 87 | w-full 88 | h-full 89 | rounded-lg 90 | ` 91 | const noCoverCSS = ` 92 | w-full 93 | h-full 94 | flex 95 | items-center 96 | justify-center 97 | border 98 | border-[var(--ant-color-border)] 99 | rounded-lg 100 | bg-[var(--ant-color-bg-elevated)] 101 | dark:bg-[var(--ant-color-bg-elevated)] 102 | ` 103 | return ( 104 | cover ? ( 105 | // eslint-disable-next-line @next/next/no-img-element 106 | {title} 107 | ) : ( 108 |
109 | No Cover 110 |
111 | ) 112 | ) 113 | } 114 | -------------------------------------------------------------------------------- /app/components/BookUploader/index.tsx: -------------------------------------------------------------------------------- 1 | import type { UploadProps } from 'antd'; 2 | import { message, Upload } from 'antd'; 3 | import { PlusOutlined } from '@ant-design/icons'; 4 | import { BOOK_FORMAT } from '@/constants/book'; 5 | import db from '@/services/DB'; 6 | import { Book } from '@/types/book'; 7 | import { handleFileUpload } from '@/services/ServerUpload'; 8 | 9 | const { Dragger } = Upload; 10 | function checkFileFormat(file: File): boolean { 11 | const fileExtension = file.name.split('.').pop()?.toLowerCase(); 12 | return Object.values(BOOK_FORMAT).some( 13 | format => format.toLowerCase() === fileExtension 14 | ); 15 | } 16 | function showError(fileNames: string) { 17 | message.error(`${fileNames} 格式不支持,请上传 ${Object.values(BOOK_FORMAT).join('/')} 格式的文件`); 18 | } 19 | const props: UploadProps = { 20 | name: 'file', 21 | multiple: true, 22 | action: '', 23 | maxCount: 1, 24 | showUploadList: false, 25 | customRequest: async (options) => { 26 | const { file } = options; 27 | let fileToUpload = file as File; 28 | 29 | if (fileToUpload.name.endsWith('.md') && !fileToUpload.type) { 30 | fileToUpload = new File( 31 | [fileToUpload], 32 | fileToUpload.name, 33 | { type: 'text/markdown' } 34 | ); 35 | } 36 | 37 | try { 38 | const result = await handleFileUpload(fileToUpload); 39 | options.onSuccess?.(result); 40 | } catch (error) { 41 | if (error instanceof Error) options.onError?.(error) 42 | else options.onError?.(new Error(String(error))); 43 | } 44 | }, 45 | accept: Object.values(BOOK_FORMAT).map(format => `.${format}`).join(','), 46 | 47 | beforeUpload: (file) => { 48 | const isAcceptedFormat = checkFileFormat(file); 49 | 50 | if (!isAcceptedFormat) { 51 | showError(file.name) 52 | return Upload.LIST_IGNORE; 53 | } 54 | 55 | return true; 56 | }, 57 | onDrop(e) { 58 | const files = Array.from(e.dataTransfer.files); 59 | const invalidFiles = files.filter(file => !checkFileFormat(file)); 60 | 61 | if (invalidFiles.length > 0) { 62 | const fileNames = invalidFiles.map(file => file.name).join(', '); 63 | showError(fileNames) 64 | } 65 | }, 66 | onChange: async (info) => { 67 | const { status, response } = info.file; 68 | if (status === 'error') return message.error(`${info.file.name} 文件导入失败, ${response.error}`); 69 | else if (status === 'done') { 70 | try { 71 | const id = await db.addBook(response as Book) 72 | await db.addReadingProgress(id) 73 | message.success(`${info.file.name} 文件导入 成功`); 74 | } catch (error) { 75 | if (error instanceof Error) message.error(`${info.file.name} 文件导入失败: ${error.message}`); 76 | else message.error(`${info.file.name} 文件导入失败`); 77 | } 78 | } 79 | }, 80 | }; 81 | 82 | export default function BookUploader() { 83 | return ( 84 |
85 | 86 | 87 | 88 |
89 | ); 90 | } -------------------------------------------------------------------------------- /app/components/PageLoading.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import React from 'react'; 4 | import { Spin, Space } from 'antd'; 5 | import { LoadingOutlined } from '@ant-design/icons'; 6 | 7 | interface PageLoadingProps { 8 | tip?: string; 9 | size?: number; 10 | fullScreen?: boolean; 11 | } 12 | 13 | export default function PageLoading({ 14 | tip = "loading...", 15 | size = 40, 16 | fullScreen = true 17 | }: PageLoadingProps) { 18 | const antIcon = ; 19 | 20 | return ( 21 |
22 | 23 | 24 |
25 |
26 |
27 |
28 | ); 29 | } -------------------------------------------------------------------------------- /app/components/common/CardComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | import { Card } from 'antd'; 3 | 4 | interface CardComponentProps { 5 | title?: string; 6 | children: ReactNode; 7 | loading?: boolean; 8 | className?: string; 9 | } 10 | 11 | export default function CardComponent({ title, children, loading = false, className = '' }: CardComponentProps) { 12 | return ( 13 | 18 | {title &&
{title}
} 19 |
{children}
20 |
21 | ); 22 | } -------------------------------------------------------------------------------- /app/components/common/MarkdownViewer/index.css: -------------------------------------------------------------------------------- 1 | .vditor-ir .vditor-reset { 2 | padding: 5px !important; 3 | } -------------------------------------------------------------------------------- /app/components/common/MarkdownViewer/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useRef, useState } from "react" 2 | import Vditor from "vditor" 3 | import { useTheme } from 'next-themes' 4 | import { Spin } from "antd" 5 | import { LoadingOutlined } from '@ant-design/icons' 6 | import './index.css' 7 | interface MarkdownViewerProps { 8 | content: string 9 | minHeight?: number 10 | className?: string 11 | } 12 | 13 | export default function MarkdownViewer({ 14 | content, 15 | minHeight = 262, 16 | className = "w-full h-[262px] overflow-y-auto" 17 | }: MarkdownViewerProps) { 18 | const isLoading = useMemo(() => { 19 | return !content 20 | }, [content]) 21 | 22 | const previewRef = useRef(null) 23 | const vditorRef = useRef(null) 24 | const [vditorReady, setVditorReady] = useState(false) 25 | const { theme } = useTheme() 26 | 27 | useEffect(() => { 28 | if (previewRef.current) { 29 | const vditor = new Vditor(previewRef.current, { 30 | cache: { 31 | id: "vditor-markdown-viewer" 32 | }, 33 | minHeight, 34 | 35 | theme: theme === 'dark' ? 'dark' : 'classic', 36 | preview: { 37 | theme: { 38 | current: theme === 'dark' ? 'dark' : 'light', 39 | }, 40 | hljs: { 41 | style: theme === 'dark' ? 'github-dark' : 'github', 42 | }, 43 | }, 44 | toolbar: ['edit-mode', 'fullscreen', { 45 | name: "more", 46 | toolbar: [ 47 | "both", 48 | "code-theme", 49 | "content-theme", 50 | "export", 51 | "outline", 52 | "preview", 53 | "devtools", 54 | "info", 55 | "help", 56 | ], 57 | },], 58 | after: () => { 59 | setVditorReady(true); 60 | }, 61 | }); 62 | vditorRef.current = vditor 63 | return () => { 64 | setVditorReady(false); 65 | try { 66 | if (vditor && typeof vditor.destroy === 'function') { 67 | vditor.destroy(); 68 | } 69 | } catch { } 70 | vditorRef.current = null 71 | }; 72 | } 73 | }, [previewRef, theme, minHeight]); 74 | 75 | useEffect(() => { 76 | if (!vditorReady || !vditorRef.current || !previewRef.current || typeof vditorRef.current.setValue !== 'function') return 77 | try { 78 | vditorRef.current.setValue(content) 79 | } catch { 80 | console.error('Error setting Vditor value'); 81 | } 82 | }, [content, vditorReady]); 83 | 84 | // 监听主题变化并更新编辑器主题 85 | useEffect(() => { 86 | if (!vditorReady || !vditorRef.current) return 87 | try { 88 | vditorRef.current.setTheme( 89 | theme === 'dark' ? 'dark' : 'classic', 90 | theme === 'dark' ? 'dark' : 'light', 91 | theme === 'dark' ? 'github-dark' : 'github' 92 | ) 93 | } catch { } 94 | }, [theme, vditorReady]); 95 | 96 | return ( 97 | }> 98 |
99 |
100 |
101 | 102 | ) 103 | } 104 | 105 | MarkdownViewer.displayName = 'MarkdownViewer'; -------------------------------------------------------------------------------- /app/components/footer/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Button, ColorPicker, theme } from 'antd'; 4 | import { useTheme } from 'next-themes'; 5 | import { useStyleStore } from '@/store/useStyleStore'; 6 | import { useEffect, useState } from 'react'; 7 | 8 | export default function Footer() { 9 | const { theme: currentTheme } = useTheme(); 10 | const { token } = theme.useToken(); 11 | const { 12 | lightModeTextColor, 13 | darkModeTextColor, 14 | setLightModeTextColor, 15 | setDarkModeTextColor 16 | } = useStyleStore(); 17 | const [mounted, setMounted] = useState(false); 18 | const [tempColor, setTempColor] = useState(''); 19 | 20 | useEffect(() => { 21 | setMounted(true); 22 | setTempColor(currentTheme === 'dark' ? darkModeTextColor : lightModeTextColor); 23 | }, [currentTheme, darkModeTextColor, lightModeTextColor]); 24 | 25 | if (!mounted) return null; 26 | 27 | const handleConfirm = () => { 28 | if (currentTheme === 'dark') { 29 | setDarkModeTextColor(tempColor); 30 | } else { 31 | setLightModeTextColor(tempColor); 32 | } 33 | }; 34 | 35 | 36 | return ( 37 |
38 | { 42 | setTempColor(color.toHexString()); 43 | }} 44 | panelRender={(panel) => ( 45 |
46 | {panel} 47 | 50 |
51 | )} 52 | /> 53 |
54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /app/components/header/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useRouter } from 'next/navigation'; 3 | import { theme } from 'antd'; 4 | import { LogoIcon } from '@/assets/icon'; 5 | import { GithubOutlined, SunOutlined, MoonOutlined, CaretUpFilled, SettingOutlined, TranslationOutlined } from '@ant-design/icons'; 6 | import { Button, Menu } from 'antd'; 7 | import { useTheme } from 'next-themes'; 8 | import { usePathname } from 'next/navigation'; 9 | import { useSiderStore } from '@/store/useSiderStore'; 10 | import { useHeaderStore } from '@/store/useHeaderStore'; 11 | import { useStyleStore } from '@/store/useStyleStore'; 12 | import { useTranslation } from '@/i18n/useTranslation'; 13 | 14 | 15 | export default function Header() { 16 | const { token } = theme.useToken(); 17 | const { theme: currentTheme, setTheme } = useTheme(); 18 | 19 | 20 | return ( 21 |
28 | 29 | 30 | setTheme(currentTheme === 'dark' ? 'light' : 'dark')} /> 31 |
32 | ); 33 | } 34 | 35 | function HeaderLogoArea() { 36 | const router = useRouter(); 37 | 38 | const { token } = theme.useToken(); 39 | return ( 40 |
router.push('/')}> 41 | 42 |
43 | Read Bridge 44 |
45 |
46 | ) 47 | } 48 | 49 | function HeaderContentArea() { 50 | const { readingId } = useSiderStore() 51 | const { t } = useTranslation() 52 | 53 | const items = [ 54 | { 55 | label: t('header.home'), 56 | key: '/', 57 | }, 58 | { 59 | label: t('header.reading'), 60 | key: '/read', 61 | disabled: !readingId, 62 | }, 63 | ] 64 | const router = useRouter(); 65 | const current = usePathname().split('?')[0] 66 | const onClick = (info: { key: string }) => { 67 | router.push(info.key); 68 | }; 69 | 70 | return ( 71 |
72 | 73 |
74 | ) 75 | } 76 | 77 | function HeaderIconArea({ theme, toggleTheme }: { theme?: string, toggleTheme: () => void }) { 78 | const iconStyle = { fontSize: 20 }; 79 | const ThemeIcon = theme === 'dark' ? SunOutlined : MoonOutlined; 80 | const { toggleCollapsed } = useHeaderStore(); 81 | const { toggleLanguage } = useStyleStore(); 82 | 83 | function handleToGithub() { 84 | window.open('https://github.com/WindChimeEcho/read-bridge', '_blank'); 85 | } 86 | 87 | function handlePutAway() { 88 | toggleCollapsed(); 89 | } 90 | 91 | const router = useRouter(); 92 | function handleToSetting() { 93 | router.push('/setting'); 94 | } 95 | 96 | function handleToggleLanguage() { 97 | toggleLanguage(); 98 | } 99 | 100 | return ( 101 |
102 |
133 | ) 134 | } 135 | -------------------------------------------------------------------------------- /app/components/layout/structure-layout.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Layout } from "antd"; 4 | import HeaderContent from "@/app/components/header"; 5 | import FooterContent from "@/app/components/footer"; 6 | import Sider from "@/app/components/sider"; 7 | import { CSSProperties } from "react"; 8 | import { useHeaderStore } from "@/store/useHeaderStore"; 9 | import { Button } from "antd"; 10 | import { CaretDownFilled } from "@ant-design/icons"; 11 | import { theme } from "antd"; 12 | import Preload from "@/app/components/preload"; 13 | 14 | const { Header, Content, Footer } = Layout; 15 | 16 | const layoutStyle: CSSProperties = { 17 | height: '100vh', 18 | width: '100%', 19 | maxWidth: '1920px', 20 | margin: '0 auto', 21 | display: 'flex', 22 | flexDirection: 'column', 23 | overflow: 'hidden', 24 | position: 'relative', 25 | }; 26 | 27 | // Dynamic header style will be used instead of this static one 28 | const headerStyle: CSSProperties = { 29 | height: 'auto', 30 | lineHeight: '64px', 31 | position: 'sticky', 32 | top: 0, 33 | zIndex: 1, 34 | padding: 0, 35 | width: '100%', 36 | transition: 'all 0.3s ease-in-out', 37 | overflow: 'hidden', 38 | }; 39 | 40 | const contentStyle: CSSProperties = { 41 | flex: 1, 42 | overflow: 'auto', 43 | }; 44 | 45 | const footerStyle: CSSProperties = { 46 | position: 'sticky', 47 | bottom: 0, 48 | padding: '0', 49 | width: '100%', 50 | height: '28px', 51 | }; 52 | 53 | const headerToggleButtonStyle: CSSProperties = { 54 | position: 'fixed', 55 | top: '-8px', 56 | right: '40px', 57 | zIndex: 2, 58 | opacity: 1, 59 | transition: 'all 0.3s ease-in-out', 60 | boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)', 61 | width: '32px', 62 | height: '28px', 63 | display: 'flex', 64 | alignItems: 'center', 65 | justifyContent: 'center', 66 | transform: 'translateY(0)', 67 | cursor: 'pointer', 68 | }; 69 | 70 | export default function RootLayout({ children }: { children: React.ReactNode }) { 71 | const { collapsed, toggleCollapsed } = useHeaderStore(); 72 | const { token } = theme.useToken(); 73 | 74 | // Dynamic header height based on collapsed state 75 | const dynamicHeaderStyle: CSSProperties = { 76 | ...headerStyle, 77 | height: collapsed ? '0' : '64px', 78 | opacity: collapsed ? 0 : 1, 79 | pointerEvents: collapsed ? 'none' : 'auto', 80 | }; 81 | 82 | // Dynamic toggle button style 83 | const dynamicToggleButtonStyle: CSSProperties = { 84 | ...headerToggleButtonStyle, 85 | opacity: collapsed ? 1 : 0, 86 | transform: collapsed ? 'translateY(0)' : 'translateY(-100%)', 87 | pointerEvents: collapsed ? 'auto' : 'none', 88 | backgroundColor: token.colorBgContainer, 89 | color: token.colorText, 90 | border: `1px solid ${token.colorBorder}`, 91 | }; 92 | 93 | const iconStyle: CSSProperties = { 94 | fontSize: 16, 95 | transition: 'transform 0.3s ease', 96 | }; 97 | 98 | return ( 99 | 100 | 101 |
102 | {collapsed && ( 103 |
104 | ) 105 | } 106 | 107 | -------------------------------------------------------------------------------- /app/components/sider/components/SiderChat/cpns/ChatHistory.tsx: -------------------------------------------------------------------------------- 1 | import { useHistoryStore } from "@/store/useHistoryStore"; 2 | import { Button, Modal } from "antd"; 3 | import { useCallback, useState } from "react"; 4 | import { EditOutlined, DeleteOutlined, CheckOutlined, CloseOutlined } from "@ant-design/icons"; 5 | import { LLMHistory } from "@/types/llm"; 6 | type ChatHistory = { 7 | isModalOpen: boolean 8 | onClose: () => void 9 | onSelect: (id: string) => void 10 | } 11 | 12 | export default function ChatHistory({ isModalOpen, onClose, onSelect }: ChatHistory) { 13 | const { groupHistoryByTime, history, deleteHistory, updateHistory } = useHistoryStore() 14 | const [editingId, setEditingId] = useState(null) 15 | const [editingTitle, setEditingTitle] = useState('') 16 | 17 | const handleSelectHistory = useCallback((id: string) => { 18 | onSelect(id) 19 | onClose() 20 | }, [onSelect, onClose]) 21 | const handleEditHistory = useCallback((item: LLMHistory) => { 22 | setEditingId(item.id) 23 | setEditingTitle(item.title) 24 | }, []) 25 | const handleDeleteHistory = useCallback((item: LLMHistory) => { 26 | deleteHistory(item) 27 | }, [deleteHistory]) 28 | const handleSaveEdit = useCallback((item: LLMHistory) => { 29 | updateHistory({ 30 | ...item, 31 | title: editingTitle 32 | }) 33 | setEditingId(null) 34 | }, [updateHistory, editingTitle]) 35 | const handleCancelEdit = useCallback(() => { 36 | setEditingId(null) 37 | }, []) 38 | 39 | const handleCancel = useCallback(() => { 40 | setEditingId(null) 41 | setEditingTitle('') 42 | onClose() 43 | }, [onClose]) 44 | return ( 45 | } onCancel={handleCancel} > 46 | { 47 | groupHistoryByTime().map((group) => ( 48 |
49 |
{group.label}
50 | {group.items.map((item) => ( 51 |
59 | {editingId === item.id ? ( 60 |
61 | setEditingTitle(e.target.value)} 65 | autoFocus 66 | onClick={(e) => e.stopPropagation()} 67 | /> 68 |
69 |
72 |
73 | ) : ( 74 | <> 75 |
handleSelectHistory(item.id)}>{item.title}
76 |
77 |
80 | 81 | )} 82 |
83 | ))} 84 |
85 | )) 86 | } 87 |
88 | ) 89 | } -------------------------------------------------------------------------------- /app/components/sider/components/SiderChat/cpns/ChatInput.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | import { Button, Popover, Tag } from "antd" 3 | import { ArrowUpOutlined, PauseOutlined } from "@ant-design/icons" 4 | import TextArea from "antd/es/input/TextArea" 5 | 6 | type ChatInput = { 7 | onSent: (input: string, tags: string[]) => void 8 | tagOptions: { 9 | label: string, 10 | value: string 11 | }[] 12 | isGenerating?: boolean 13 | onStopGeneration?: () => void 14 | } 15 | 16 | export default function ChatInput({ 17 | onSent, 18 | tagOptions, 19 | isGenerating = false, 20 | onStopGeneration 21 | }: ChatInput) { 22 | const [input, setInput] = useState('') 23 | const [tags, setTags] = useState>([]) 24 | const [tagSelectorOpen, setTagSelectorOpen] = useState(false) 25 | 26 | useEffect(() => { 27 | if (tagOptions.length > 0) { 28 | setTags([tagOptions[0]]) 29 | } 30 | }, [tagOptions]) 31 | 32 | const handleSend = () => { 33 | onSent(input, tags.map(tag => tag.value)) 34 | setInput('') 35 | } 36 | 37 | function content(onAddTag: (tag: { label: string, value: string }) => void) { 38 | return ( 39 |
40 | {tagOptions.map((tag) => { 41 | return 44 | })} 45 |
46 | ) 47 | } 48 | 49 | function handleAddTag(tag: { label: string, value: string }) { 50 | // 检查是否已存在相同value的标签 51 | if (!tags.some(t => t.value === tag.value)) { 52 | setTags([...tags, tag]) 53 | } 54 | // 关闭popover 55 | setTagSelectorOpen(false) 56 | } 57 | 58 | function handleRemoveTag(tagValue: string) { 59 | setTags(tags.filter(tag => tag.value !== tagValue)) 60 | } 61 | 62 | return ( 63 |
65 |
66 |
67 | 68 |
88 | 89 |
90 |
91 |