├── .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 | [](https://opensource.org/licenses/MIT) [](https://nextjs.org/) [](https://tauri.app/) [](https://nextjs.org/) [](https://tauri.app/) [](https://tauri.app/) [](https://tauri.app/)
6 |
7 | [](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 | [](https://vercel.com/new/clone?repository-url=https://github.com/WindChimeEcho/read-bridge)
91 |
92 | [](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 | [](https://opensource.org/licenses/MIT) [](https://nextjs.org/) [](https://tauri.app/) [](https://nextjs.org/) [](https://tauri.app/) [](https://tauri.app/) [](https://tauri.app/)
6 |
7 | [](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 | [](https://vercel.com/new/clone?repository-url=https://github.com/WindChimeEcho/read-bridge)
91 |
92 | [](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 |

107 |
108 |
109 | ) : (
110 |
112 |
{t('book.noCover')}
113 |
114 | )}
115 |
116 |
117 |
118 | }>
119 | {book?.metadata.cover ? t('book.changeCover') : t('book.uploadCover')}
120 |
121 | {book?.metadata.cover && (
122 |
135 | )}
136 |
137 |
138 |
139 |
140 |
141 | {/* 右侧元数据表单 */}
142 |
143 |
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 |
}
58 | onClick={(e) => showDetailsModal(book.id, e)}
59 | />
60 |
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 |
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 |
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 |
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 | }
106 | onClick={handleToGithub}
107 | />
108 | }
112 | onClick={handleToSetting}
113 | />
114 | }
118 | onClick={handleToggleLanguage}
119 | />
120 | }
124 | onClick={toggleTheme}
125 | />
126 | }
130 | onClick={handlePutAway}
131 | />
132 |
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 | }
106 | style={dynamicToggleButtonStyle}
107 | onClick={toggleCollapsed}
108 | className="transition-all duration-300"
109 | />
110 | )}
111 |
112 |
113 | {children}
114 |
115 |
116 |
117 |
118 |
119 | );
120 | }
--------------------------------------------------------------------------------
/app/components/layout/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { ConfigProvider, theme } from 'antd';
4 | import { ThemeProvider as NextThemeProvider, useTheme } from 'next-themes';
5 | import { useEffect, useState } from 'react';
6 | import { useStyleStore } from '@/store/useStyleStore';
7 |
8 | function AntdProvider({ children }: { children: React.ReactNode }) {
9 | const { theme: currentTheme = '', setTheme } = useTheme();
10 | const [mounted, setMounted] = useState(false);
11 | const { lightModeTextColor, darkModeTextColor } = useStyleStore();
12 |
13 | useEffect(() => {
14 | setMounted(true);
15 | }, []);
16 |
17 | useEffect(() => {
18 | if (mounted && (!currentTheme || currentTheme === 'system')) {
19 | const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
20 | setTheme(systemTheme);
21 | }
22 | }, [mounted, currentTheme, setTheme]);
23 |
24 | useEffect(() => {
25 | if (mounted) {
26 | document.documentElement.style.setProperty(
27 | '--primary-text-color',
28 | currentTheme === 'dark' ? darkModeTextColor : lightModeTextColor
29 | );
30 | }
31 | }, [currentTheme, lightModeTextColor, darkModeTextColor, mounted]);
32 |
33 | if (!mounted) {
34 | return null;
35 | }
36 | function generateTheme(currentTheme: string) {
37 | const algorithm = currentTheme === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm;
38 | return {
39 | cssVar: true,
40 | algorithm: algorithm,
41 | token: getToken(currentTheme),
42 | components: getComponentsToken(currentTheme)
43 | }
44 | }
45 | // 修改主要主题色
46 | function getToken(currentTheme: string) {
47 | const tokens = {
48 | dark: {
49 | colorBgLayout: '#1f1f1f',
50 | colorBgContainer: '#181818',
51 | colorBgElevated: '#313131',
52 | colorText: darkModeTextColor,
53 | },
54 | light: {
55 | colorBgLayout: '#fff',
56 | colorText: lightModeTextColor,
57 | }
58 | };
59 | return tokens[currentTheme === 'dark' ? 'dark' : 'light'];
60 | }
61 |
62 | // 修改组件样式
63 | function getComponentsToken(currentTheme: string) {
64 | const components = {
65 | dark: {
66 | Card: {
67 | colorBgContainer: '#313131',
68 | colorBorderSecondary: '#636363',
69 | bodyPadding: 8
70 | },
71 | Menu: {
72 | colorBgContainer: '#1f1f1f',
73 | }
74 | },
75 | light: {
76 | Card: {
77 | bodyPadding: 8,
78 | colorBorderSecondary: '#d4d4d4'
79 | }
80 | },
81 | };
82 |
83 | return components[currentTheme === 'dark' ? 'dark' : 'light'];
84 | }
85 |
86 | return (
87 |
90 | {children}
91 |
92 | );
93 | }
94 |
95 | export function ThemeProvider({ children }: { children: React.ReactNode }) {
96 | return (
97 |
98 | {children}
99 |
100 | );
101 | }
--------------------------------------------------------------------------------
/app/components/preload.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useEffect } from 'react';
4 |
5 | export default function Preload() {
6 | useEffect(() => {
7 | const timer = setTimeout(async () => {
8 | // 预加载路由
9 | await import('@/app/setting/page');
10 | await import('@/app/read/page');
11 | }, 1000);
12 |
13 | return () => clearTimeout(timer);
14 | }, []);
15 |
16 | return null;
17 | }
--------------------------------------------------------------------------------
/app/components/sider/components/SiderChat/cpns/ChatContent.tsx:
--------------------------------------------------------------------------------
1 | import { LLMHistory } from "@/types/llm";
2 | import { useCallback, useEffect, useMemo, useRef } from "react";
3 | import { Tooltip } from "antd"
4 | import MessageBubble from './MessageBubble'
5 |
6 | type ChatContent = {
7 | history: LLMHistory
8 | }
9 |
10 | export default function ChatContent({ history }: ChatContent) {
11 | const contentRef = useRef(null);
12 | const isAutoScrollTo = useRef(false)
13 | const prevSizeRef = useRef(0);
14 | const [prompt, messages] = useMemo(() => {
15 | return [history.prompt, history.messages]
16 | }, [history])
17 |
18 | const size = useMemo(() => {
19 | const msg = messages[messages.length - 1]
20 | if (!msg) return 0
21 | if (msg.role === 'user') return 0
22 | else {
23 | const reasonLength = (msg.reasoningContent && msg.reasoningContent.length) || 0
24 | return msg.content.length + Math.max(0, reasonLength)
25 | }
26 | }, [messages])
27 |
28 | const scrollToBottom = useCallback(() => {
29 | if (!contentRef.current) return;
30 | contentRef.current.scrollTo({
31 | top: contentRef.current.scrollHeight,
32 | behavior: 'smooth'
33 | });
34 | }, [])
35 |
36 | useEffect(() => {
37 | isAutoScrollTo.current = true
38 | requestAnimationFrame(() => {
39 | scrollToBottom()
40 | })
41 | }, [messages.length, scrollToBottom])
42 | useEffect(() => {
43 | if (size < 20) {
44 | scrollToBottom()
45 | prevSizeRef.current = size;
46 | return;
47 | }
48 |
49 | if (!isAutoScrollTo.current) return
50 | if ((size - prevSizeRef.current >= 50)) {
51 | scrollToBottom();
52 | prevSizeRef.current = size;
53 | }
54 | }, [size, scrollToBottom])
55 |
56 |
57 | useEffect(() => {
58 | const contentElement = contentRef.current;
59 | if (!contentElement) return;
60 | const handleScroll = () => {
61 |
62 | // 检查是否接近底部
63 | const isAtBottom = Math.abs(
64 | contentElement.scrollHeight - contentElement.scrollTop - contentElement.clientHeight
65 | ) < 50;
66 | // 如果不在底部且自动滚动已启用,则禁用它
67 | if (!isAtBottom && isAutoScrollTo.current) {
68 | isAutoScrollTo.current = false;
69 | }
70 |
71 | // 如果在底部且自动滚动已禁用,则重新启用它
72 | // 这意味着用户已手动滚动回底部
73 | if (isAtBottom && !isAutoScrollTo.current) {
74 | isAutoScrollTo.current = true;
75 | }
76 | };
77 |
78 | contentElement.addEventListener('scroll', handleScroll);
79 |
80 | return () => {
81 | contentElement.removeEventListener('scroll', handleScroll);
82 | };
83 | }, []);
84 |
85 |
86 | return (
87 |
88 | {prompt}
}
90 | placement="bottom"
91 | >
92 |
93 | {prompt}
94 |
95 |
96 | {messages.map((msg, index) => {
97 | if (msg.role === 'user') {
98 | return
99 | } else {
100 | return
101 | }
102 | })}
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 | } onClick={() => handleSaveEdit(item)} />
70 | } onClick={handleCancelEdit} />
71 |
72 |
73 | ) : (
74 | <>
75 |
handleSelectHistory(item.id)}>{item.title}
76 |
77 | } onClick={() => handleEditHistory(item)} />
78 | } onClick={() => handleDeleteHistory(item)} />
79 |
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 |
70 | {tags.map(tag => (
71 |
78 | handleRemoveTag(tag.value)}
81 | className="max-w-[120px] h-[24px] mr-1 overflow-hidden text-ellipsis whitespace-nowrap cursor-default"
82 | >
83 | {tag.label}
84 |
85 |
86 | ))}
87 |
88 |
89 |
90 |
91 |
126 |
127 | )
128 | }
129 |
--------------------------------------------------------------------------------
/app/components/sider/components/SiderChat/cpns/ChatTools.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Button, Popover, Tooltip } from "antd"
4 | import { PlusOutlined, AlignLeftOutlined, HistoryOutlined, CloseOutlined } from "@ant-design/icons"
5 | import { useOutputOptions } from "@/store/useOutputOptions"
6 | import { useState } from "react"
7 | import { useTranslation } from "@/i18n/useTranslation";
8 |
9 | type ChatTools = {
10 | isGenerating: boolean
11 | onPlus: () => void
12 | onChangePrompt: (id: string) => void
13 | onHistory: () => void
14 | onCloseModal: () => void
15 | }
16 |
17 | export default function ChatTools({ isGenerating, onPlus, onChangePrompt, onHistory, onCloseModal }: ChatTools) {
18 | const { t } = useTranslation()
19 | const [open, setOpen] = useState(false);
20 |
21 | const handleChangePrompt = (value: string) => {
22 | onChangePrompt(value);
23 | setTimeout(() => {
24 | setOpen(false);
25 | }, 200);
26 | };
27 |
28 | return (
29 | <>
30 |
31 |
{t('sider.readingAssistant')}
32 |
}
36 | placement="leftTop"
37 | trigger="click"
38 | >
39 |
} onClick={() => setOpen(true)} />
40 |
41 |
} onClick={onPlus} />
42 |
} onClick={onHistory} />
43 |
} onClick={onCloseModal} />
44 |
45 | >
46 | )
47 | }
48 |
49 | function ChangePromptPopover({ onChangePrompt }: { onChangePrompt: (id: string) => void }) {
50 | const { promptOptions, selectedId, setSelectedId } = useOutputOptions();
51 |
52 | function handleChangePrompt(id: string) {
53 | setSelectedId(id)
54 | onChangePrompt(id)
55 | }
56 | return (
57 |
58 | {promptOptions.map((option) => {
59 | const btn = (
60 |
69 | );
70 |
71 | return option.name.length > 15 ? (
72 |
73 | {btn}
74 |
75 | ) : btn;
76 | })}
77 |
78 | );
79 | }
80 |
--------------------------------------------------------------------------------
/app/components/sider/components/SiderChat/cpns/MessageBubble.tsx:
--------------------------------------------------------------------------------
1 | import { LLMHistory } from "@/types/llm";
2 | import { memo, useCallback, useMemo, useState } from "react";
3 | import { Button, Collapse, message } from "antd";
4 | import dayjs from "dayjs";
5 | import { useTheme } from "next-themes"
6 | import { SyncOutlined, LoadingOutlined } from "@ant-design/icons";
7 | import { CopyIcon } from "@/assets/icon";
8 | import { useSiderStore } from "@/store/useSiderStore";
9 |
10 | const MessageBubble = memo(function MessageBubble({
11 | msg,
12 | isUser
13 | }: {
14 | msg: LLMHistory['messages'][number],
15 | isUser: boolean
16 | }) {
17 | const { theme } = useTheme();
18 | const isDarkMode = useMemo(() => theme === 'dark', [theme])
19 | const { thinkingExpanded, setThinkingExpanded } = useSiderStore()
20 | const [activeKey, setActiveKey] = useState(thinkingExpanded ? ['thinking'] : [])
21 |
22 | const commonClasses = useMemo(() => {
23 | return {
24 | container: "flex flex-row mb-3 items-start" + (isUser ? ' justify-end' : ' justify-start'),
25 | bubbleWrapper: (isUser ? ' ml-auto max-w-[90%]' : ' mr-auto w-[90%]'),
26 | timestampWrapper: "flex mb-1" + (isUser ? ' justify-end' : ' justify-start'),
27 | bubble: "p-2 rounded-md border border-[var(--ant-color-border)] text-sm" +
28 | (isUser
29 | ? isDarkMode ? ' bg-blue-300/40' : ' bg-blue-100'
30 | : isDarkMode ? ' bg-gray-800' : ' bg-gray-100 '),
31 | actionsWrapper: "flex mt-1 space-x-2" + (isUser ? ' justify-end' : ' justify-start'),
32 | name: "text-xs font-semibold",
33 | timestamp: "text-xs text-gray-500 ml-2",
34 | collapsePanel: isDarkMode
35 | ? "bg-gray-700 border-gray-600"
36 | : "bg-gray-50 border-gray-200",
37 | };
38 | }, [isUser, isDarkMode])
39 |
40 | const handleCollapseChange = useCallback((key: string | string[], isUser: boolean) => {
41 | setActiveKey(key);
42 | if (!isUser) {
43 | const isExpanded = Array.isArray(key) ? key.includes('thinking') : key === 'thinking';
44 | setThinkingExpanded(isExpanded);
45 | }
46 | }, [setThinkingExpanded])
47 |
48 | const hasThinkingContent = !isUser && !!msg.reasoningContent;
49 | const isThinking = hasThinkingContent && !msg.thinkingTime;
50 | const thinkingLabel = isThinking
51 | ? '思考中...'
52 | : `思考完成 (用时${msg.thinkingTime}秒)`;
53 |
54 | return (
55 |
56 |
57 |
58 | {!isUser && {msg.name}}
59 |
60 | {dayjs(msg.timestamp).format('MM-DD HH:mm')}
61 |
62 |
63 |
64 | {hasThinkingContent && (
65 |
handleCollapseChange(key, isUser)}
68 | bordered={false}
69 | className={`mb-4 overflow-hidden ${commonClasses.collapsePanel}`}
70 | items={[
71 | {
72 | key: 'thinking',
73 | label: (
74 |
75 |
76 | {isThinking && }
77 |
78 | {thinkingLabel}
79 |
80 |
81 |
}
85 | onClick={(e) => {
86 | e.stopPropagation();
87 | navigator.clipboard.writeText(msg.reasoningContent || '');
88 | message.success('思考内容已复制');
89 | }}
90 | />
91 |
92 | ),
93 | children: (
94 |
95 | {msg.reasoningContent}
96 |
97 | )
98 | }
99 | ]}
100 | />
101 | )}
102 |
103 | {msg.content.length === 0 ? (
104 |
105 | ) : (
106 | msg.content
107 | )}
108 |
109 |
110 |
111 | }
115 | onClick={() => {
116 | navigator.clipboard.writeText(msg.content)
117 | message.success(isUser ? 'Copied to clipboard' : '回复已复制')
118 | }}
119 | />
120 |
121 |
122 |
123 | )
124 | })
125 | MessageBubble.displayName = 'MessageBubble'
126 | export default MessageBubble
--------------------------------------------------------------------------------
/app/components/sider/components/SiderChat/cpns/index.ts:
--------------------------------------------------------------------------------
1 | export { default as ChatTools } from './ChatTools';
2 | export { default as ChatContent } from './ChatContent'
3 | export { default as ChatInput } from './ChatInput'
4 | export { default as ChatHistory } from './ChatHistory'
--------------------------------------------------------------------------------
/app/components/sider/components/SiderContent/cpns/CurrentSentence.tsx:
--------------------------------------------------------------------------------
1 | import { Tooltip } from "antd";
2 | import nlp from "compromise";
3 | import { useCallback, useMemo } from "react";
4 | import { useTranslation } from "@/i18n/useTranslation";
5 | import { useTheme } from "next-themes";
6 |
7 | export default function CurrentSentence({ sentence, handleWord }: { sentence: string, handleWord: (word: string) => void }) {
8 | const { t } = useTranslation()
9 | const { theme: currentTheme } = useTheme();
10 | const wordTypeColors = useMemo(() => ({
11 | 'Verb': 'text-[var(--ant-green-6)]',
12 | 'Adjective': 'text-[var(--ant-purple-7)]',
13 | 'Pivot': 'text-[var(--ant-gold-6)]',
14 | 'Noun': 'text-[var(--ant-color-text)]',
15 | }), [])
16 | const getChunkColor = useCallback((chunk: string) => {
17 | return wordTypeColors[chunk] || 'text-[var(--ant-color-text)]';
18 | }, [wordTypeColors]);
19 | const terms = useMemo(() => {
20 | if (sentence && sentence.length > 0) {
21 | const doc = nlp(sentence)
22 | return doc.terms().json()
23 | }
24 | return []
25 | }, [sentence])
26 | return (
27 |
28 |
32 | {t('sider.verb')}
33 | {t('sider.adjective')}
34 | {t('sider.pivot')}
35 | {t('sider.noun')}
36 | >
37 | }
38 | >
39 |
40 | {t('sider.selectSentence')}
41 |
42 |
43 |
44 | {terms.map((term, i) => (
45 | {
49 | handleWord(term.text)
50 | }}
51 | >
52 | {term.text}{' '}
53 |
54 | ))}
55 |
56 |
57 | )
58 | }
59 |
--------------------------------------------------------------------------------
/app/components/sider/components/SiderContent/cpns/MenuLine.tsx:
--------------------------------------------------------------------------------
1 | import { Tabs } from "antd";
2 | import React from "react";
3 |
4 | function MenuLine({
5 | selectedTab,
6 | items,
7 | onTabChange
8 | }: {
9 | selectedTab: string,
10 | items: { label: string, key: string, disabled?: boolean }[],
11 | onTabChange: (key: string) => void
12 | }) {
13 | return (
14 |
20 | )
21 | }
22 |
23 | const MemoizedMenuLine = React.memo(MenuLine);
24 | MemoizedMenuLine.displayName = 'MenuLine';
25 |
26 | export default MemoizedMenuLine;
--------------------------------------------------------------------------------
/app/components/sider/components/SiderContent/cpns/Sentences.tsx:
--------------------------------------------------------------------------------
1 | import CardComponent from "@/app/components/common/CardComponent"
2 | import { OUTPUT_TYPE } from "@/constants/prompt"
3 | import { Collapse } from "antd"
4 | import { LoadingOutlined } from "@ant-design/icons"
5 | import { useCallback, useEffect, useState } from "react"
6 | import MarkdownViewer from "@/app/components/common/MarkdownViewer"
7 |
8 | // 自定义hook抽取think处理逻辑
9 | function useThinkGenerator(generator: AsyncGenerator, outputType: 'text' | 'list') {
10 | const [text, setText] = useState("")
11 | const [list, setList] = useState([])
12 | const [thinkContext, setThinkContext] = useState('')
13 |
14 | useEffect(() => {
15 | setText("");
16 | setList([]);
17 | setThinkContext('')
18 |
19 | if (outputType === 'text') {
20 | handleThink(generator,
21 | (value) => setText((prev) => prev + value),
22 | (value) => setThinkContext((prev) => prev + value)
23 | )
24 | } else {
25 | handleThink(generator,
26 | (value) => setList((prev) => [...prev, value]),
27 | (value) => setThinkContext((prev) => prev + value)
28 | )
29 | }
30 | }, [generator, outputType])
31 |
32 | return { text, list, thinkContext }
33 | }
34 |
35 | export default function Sentences({ sentenceProcessingList }: { sentenceProcessingList: { name: string, id: string, type: string, generator: AsyncGenerator }[] }) {
36 | return (
37 |
38 | {
39 | sentenceProcessingList.map((item) => {
40 | return (
41 | item.type === OUTPUT_TYPE.MD ? :
42 |
43 | {
44 | item.type === OUTPUT_TYPE.TEXT ? :
45 | item.type === OUTPUT_TYPE.SIMPLE_LIST ? :
46 | item.type === OUTPUT_TYPE.KEY_VALUE_LIST ? :
47 | null
48 | }
49 |
50 | )
51 | })
52 | }
53 |
54 | )
55 | }
56 |
57 | function TextGenerator({ generator }: { generator: AsyncGenerator }) {
58 | const { text, thinkContext } = useThinkGenerator(generator, 'text')
59 |
60 | return
61 |
62 | {text.length === 0 &&
}
63 |
{text}
64 |
65 | }
66 |
67 | function ListGenerator({ generator, type }: { generator: AsyncGenerator, type: string }) {
68 | const handleWordAnalysis = useCallback((analysis: string, index: number) => {
69 | const [keyWord, ...rest] = analysis.split(':')
70 | return {keyWord}:{rest}
71 | }, [])
72 |
73 | const { list, thinkContext } = useThinkGenerator(generator, 'list')
74 |
75 | return (
76 |
77 |
78 | {list.length === 0 &&
}
79 | {type === OUTPUT_TYPE.KEY_VALUE_LIST
80 | ? list.map((item, index) => handleWordAnalysis(item, index))
81 | : list.map((item) =>
{item}
)
82 | }
83 |
84 | )
85 | }
86 |
87 | function handleThink(generator: AsyncGenerator, onValue: (value: string) => void, onThinkContext: (value: string) => void) {
88 | let thinking: boolean = false;
89 | (async () => {
90 | for await (const chunk of generator) {
91 | if (chunk === '') {
92 | thinking = true
93 | continue
94 | }
95 | if (thinking) {
96 | if (chunk === '') {
97 | thinking = false
98 | continue
99 | }
100 | onThinkContext(chunk)
101 | } else {
102 | onValue(chunk)
103 | }
104 | }
105 | })()
106 | }
107 |
108 | function SimpleListGenerator({ generator }: { generator: AsyncGenerator }) {
109 | return
110 | }
111 |
112 | function KeyValueListGenerator({ generator }: { generator: AsyncGenerator }) {
113 | return
114 | }
115 |
116 | function ThinkCollapse({ thinkContext }: { thinkContext: string }) {
117 | if (!thinkContext) return null;
118 | return (
119 | {thinkContext} }]}
124 | />
125 | );
126 | }
127 |
128 | function MDGenerator({ generator, className }: { generator: AsyncGenerator, className?: string }) {
129 | const { text, thinkContext } = useThinkGenerator(generator, 'text')
130 | return
131 |
132 |
133 |
134 | }
135 |
136 |
--------------------------------------------------------------------------------
/app/components/sider/components/SiderContent/cpns/WordDetails.tsx:
--------------------------------------------------------------------------------
1 | import MarkdownViewer from '@/app/components/common/MarkdownViewer'
2 |
3 | export default function WordDetails({ wordDetails }: { wordDetails: string }) {
4 | return
5 | }
6 |
7 | WordDetails.displayName = 'WordDetails';
--------------------------------------------------------------------------------
/app/components/sider/components/SiderContent/cpns/index.tsx:
--------------------------------------------------------------------------------
1 | export { default as CurrentSentence } from './CurrentSentence';
2 | export { default as MenuLine } from './MenuLine';
3 | export { default as Sentences } from './Sentences';
4 | export { default as WordDetails } from './WordDetails';
--------------------------------------------------------------------------------
/app/components/sider/index.tsx:
--------------------------------------------------------------------------------
1 | import SiderLayout from "./layout";
2 | import SiderPage from './page'
3 | export default function Sider() {
4 | return (
5 |
6 |
7 |
8 | )
9 | }
10 |
--------------------------------------------------------------------------------
/app/components/sider/layout.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { useState, useRef, useCallback, useEffect } from 'react';
3 | import { useSiderStore } from '@/store/useSiderStore';
4 | import { theme } from 'antd';
5 |
6 | const MIN_WIDTH = 400;
7 | const MAX_WIDTH = 600;
8 |
9 | export default function SiderLayout({ children }: { children: React.ReactNode }) {
10 | const { siderWidth, setSiderWidth } = useSiderStore();
11 | const [width, setWidth] = useState(siderWidth); // 默认宽度
12 | const isDragging = useRef(false);
13 | const startX = useRef(0);
14 | const startWidth = useRef(0);
15 |
16 | const handleMouseDown = useCallback((e: React.MouseEvent) => {
17 | isDragging.current = true;
18 | startX.current = e.clientX;
19 | startWidth.current = width;
20 | document.body.style.userSelect = 'none'; // 防止拖拽时选中文本
21 | }, [width]);
22 |
23 | const handleMouseMove = useCallback((e: MouseEvent) => {
24 | if (!isDragging.current) return;
25 | const diff = startX.current - e.clientX;
26 | const newWidth = Math.min(Math.max(startWidth.current + diff, MIN_WIDTH), MAX_WIDTH);
27 | setWidth(newWidth);
28 | }, [setWidth]);
29 |
30 | const handleMouseUp = useCallback(() => {
31 | isDragging.current = false;
32 | document.body.style.userSelect = '';
33 | setSiderWidth(width);
34 | }, [setSiderWidth, width]);
35 |
36 | // 事件监听
37 | useEffect(() => {
38 | document.addEventListener('mousemove', handleMouseMove);
39 | document.addEventListener('mouseup', handleMouseUp);
40 | return () => {
41 | document.removeEventListener('mousemove', handleMouseMove);
42 | document.removeEventListener('mouseup', handleMouseUp);
43 | };
44 | }, [handleMouseMove, handleMouseUp]);
45 |
46 | const { token } = theme.useToken();
47 | return (
48 |
52 |
56 | {children}
57 |
58 | )
59 | }
60 |
61 |
--------------------------------------------------------------------------------
/app/components/sider/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useReadingProgressStore } from "@/store/useReadingProgress"
4 | import { usePathname } from "next/navigation"
5 | import React, { useCallback, useEffect, useMemo } from "react"
6 |
7 | import SiderContent from "@/app/components/sider/components/SiderContent"
8 | import SiderChat from "@/app/components/sider/components/SiderChat"
9 | import { useSiderStore } from "@/store/useSiderStore"
10 |
11 | import { EventEmitter, EVENT_NAMES } from "@/services/EventService"
12 | import db from "@/services/DB"
13 |
14 | export default function Sider() {
15 | const { readingId } = useSiderStore()
16 | const { readingProgress, updateReadingProgress } = useReadingProgressStore()
17 | const currentLocation = useMemo(() => {
18 | return readingProgress.currentLocation || null
19 | }, [readingProgress])
20 |
21 | const pathname = usePathname()
22 |
23 | // 当返回阅读页面时 更新阅读进度
24 | useEffect(() => {
25 | if (pathname.includes('/read') && readingId) {
26 | updateReadingProgress(readingId)
27 | }
28 | }, [updateReadingProgress, pathname, readingId])
29 | const updateLineIndex = useCallback(async (lineIndex: number) => {
30 | // 发送完成后更新line
31 | if (!readingId) return
32 | await db.updateCurrentLocation(readingId, {
33 | chapterIndex: currentLocation.chapterIndex,
34 | lineIndex
35 | })
36 | const readingProgress = await updateReadingProgress(readingId)
37 | EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, readingProgress)
38 | }, [readingId, currentLocation, updateReadingProgress])
39 |
40 |
41 | useEffect(() => {
42 | const unsub = EventEmitter.on(EVENT_NAMES.SEND_LINE_INDEX, updateLineIndex)
43 | return () => {
44 | unsub()
45 | }
46 | }, [updateLineIndex])
47 |
48 |
49 | return (
50 |
51 |
52 |
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WindChimeEcho/read-bridge/a45e87af9a6b12aee380e43ec56079ad60ff42ee/app/favicon.ico
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @import 'antd/dist/reset.css';
2 | @import "vditor/dist/index.css";
3 | @tailwind base;
4 | @tailwind components;
5 | @tailwind utilities;
6 |
7 | @layer base {
8 | :root {
9 | --card-bg-color-light: #f3f4f6;
10 | --card-bg-color-dark: #1f2937;
11 | --card-bg-color: var(--card-bg-color-light);
12 | }
13 |
14 | .dark {
15 | --card-bg-color: var(--card-bg-color-dark);
16 | }
17 | }
--------------------------------------------------------------------------------
/app/home/loading.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 | export const runtime = "edge";
3 |
4 | import PageLoading from '@/app/components/PageLoading';
5 |
6 | export default function Loading() {
7 | return ;
8 | }
--------------------------------------------------------------------------------
/app/home/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import db from '@/services/DB';
4 | import { useLiveQuery } from 'dexie-react-hooks';
5 | import BookGrid from '@/app/components/BookGrid';
6 |
7 |
8 | export default function Home() {
9 | const bookPreviews = useLiveQuery(() => db.getAllBooksPreview(), []) || []
10 | return (
11 |
12 |
13 |
14 | );
15 | }
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 |
3 | import "./globals.css";
4 | import { ThemeProvider } from "@/app/components/layout/theme-provider";
5 | import { AntdRegistry } from "@ant-design/nextjs-registry";
6 | import StructureLayout from '@/app/components/layout/structure-layout';
7 |
8 |
9 | export const metadata: Metadata = {
10 | title: "ReadBridge",
11 | description: "ReadBridge",
12 | };
13 |
14 | export default function RootLayout({
15 | children,
16 | }: Readonly<{
17 | children: React.ReactNode;
18 | }>) {
19 | return (
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | {children}
29 |
30 |
31 |
32 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import React from 'react';
4 | import HomeContent from '@/app/home/page';
5 |
6 | export default function Home() {
7 | return (
8 | <>
9 |
10 | >
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/app/read/components/menu.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Menu, Button } from 'antd'
4 | import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons'
5 | import { Book } from '@/types/book'
6 | import { useSiderStore } from '@/store/useSiderStore'
7 | interface ReadMenuProps {
8 | toc: Book['toc']
9 | currentChapter: number
10 | onChapterChange: (index: number) => void
11 | }
12 |
13 | export default function ReadMenu({ toc, currentChapter, onChapterChange }: ReadMenuProps) {
14 | const { collapsed, setCollapsed } = useSiderStore()
15 |
16 | const toggleCollapsed = () => {
17 | setCollapsed(!collapsed)
18 | }
19 | const menuItems = toc.map(({ title, index }) => ({
20 | key: index,
21 | label: collapsed ? index + 1 : title,
22 | title: title
23 | }))
24 |
25 | return (
26 |
27 |
34 |
43 | )
44 | }
--------------------------------------------------------------------------------
/app/read/components/readArea/index.tsx:
--------------------------------------------------------------------------------
1 | import { Book, ReadingProgress } from "@/types/book"
2 | import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
3 | import db from "@/services/DB"
4 | import { EVENT_NAMES, EventEmitter } from "@/services/EventService"
5 | import { Radio } from "antd"
6 |
7 |
8 | export default function ReadArea({ book, readingProgress }: { book: Book, readingProgress: ReadingProgress }) {
9 | const title = useMemo(() => {
10 | return book.chapterList[readingProgress.currentLocation.chapterIndex].title
11 | }, [book, readingProgress.currentLocation.chapterIndex])
12 |
13 | const lines = useMemo(() => {
14 | return readingProgress.sentenceChapters[readingProgress.currentLocation.chapterIndex] ?? []
15 | }, [readingProgress.sentenceChapters, readingProgress.currentLocation.chapterIndex])
16 |
17 | const containerRef = useRef(null)
18 | const [selectedLine, setSelectedLine] = useState(Infinity)
19 | const lineRefsMap = useRef