0.2.2 onlyoffice修复
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
MNOTE is a knowledge management system combining Notion-like block documents, mind mapping, and AI-powered productivity tools. Built as a hybrid web/desktop application with local-first capabilities.
|
||||
|
||||
**Tech Stack:**
|
||||
- Frontend: Next.js 16 (App Router), React 19, BlockNote editor
|
||||
- Backend: FastAPI + Celery (Python)
|
||||
- Desktop: Electron with embedded Next.js standalone
|
||||
- Database: Supabase (PostgreSQL), migrating to Convex for some features
|
||||
- AI: Tool-based agent system with 30+ built-in tools
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Quick Start
|
||||
```bash
|
||||
npm run desktop:hot # Start frontend + backend + Celery (recommended)
|
||||
npm run desktop:local # Local-only mode (offline)
|
||||
npm run desktop # Production desktop mode
|
||||
```
|
||||
|
||||
### Frontend Only (wolai-frontend/)
|
||||
```bash
|
||||
pnpm dev # Next.js dev server on port 3000
|
||||
pnpm build # Production build
|
||||
pnpm start # Start production server
|
||||
pnpm lint # ESLint check
|
||||
pnpm test # Run Vitest unit tests
|
||||
```
|
||||
|
||||
### Backend Only (wolai-backend/)
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload --port 8000 # FastAPI dev server
|
||||
celery -A app.workers.celery_app worker --loglevel=info # Celery worker
|
||||
```
|
||||
|
||||
### Desktop Build
|
||||
```bash
|
||||
npm run build:desktop:next # Build Next.js standalone to desktop-electron/desktop-next/
|
||||
npm run dist:win # Build Windows NSIS installer
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
cd pw-tests/scripts
|
||||
python e2e_mindmap_sync.py # Run E2E test
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Directory Structure
|
||||
```
|
||||
wolai-frontend/ # Main Next.js frontend (ACTIVE - use this)
|
||||
├── src/
|
||||
│ ├── app/ # Next.js App Router
|
||||
│ │ ├── (app)/ # Main app layout (sidebar + content)
|
||||
│ │ ├── (auth)/ # Auth pages
|
||||
│ │ ├── api/ # API routes (BFF pattern)
|
||||
│ │ ├── documents/[id]/ # Document pages
|
||||
│ │ └── mindmap/ # Mindmap pages
|
||||
│ ├── components/
|
||||
│ │ ├── editor/ # Editor components (BlockNote, blocks)
|
||||
│ │ └── sidebar/ # File tree navigation
|
||||
│ ├── lib/
|
||||
│ │ ├── ai-agent/ # AI agent runtime & tools
|
||||
│ │ ├── supabase/ # Supabase client wrappers
|
||||
│ │ └── mindmap/ # Mindmap storage/logic
|
||||
│ └── store/ # Zustand state stores
|
||||
wolai-backend/ # FastAPI backend
|
||||
├── app/
|
||||
│ ├── main.py # FastAPI entry point
|
||||
│ ├── routers/ # API routes
|
||||
│ ├── services/ # Business logic
|
||||
│ └── workers/ # Celery tasks
|
||||
desktop-electron/ # Electron wrapper
|
||||
supabase/ # Database migrations
|
||||
```
|
||||
|
||||
**WARNING:** Root `src/` directory may be legacy/mirror. Always work in `wolai-frontend/src/` for frontend changes.
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**BFF (Backend for Frontend):** Next.js API routes handle auth + Supabase queries. Frontend never talks directly to Supabase (except via client library).
|
||||
|
||||
**AI Agent System:** Tool-based architecture with execution loop, SSE streaming, and permission model (read/write). See `wolai-frontend/src/lib/ai-agent/`.
|
||||
|
||||
**Desktop + Web Parity:** Same Next.js codebase serves both. Desktop uses embedded server with runtime config override (`window.__MNOTE_RUNTIME_CONFIG__`).
|
||||
|
||||
**Mindmap First-Class:** Stored as structured JSON, not just visualization. Supports node refs (attachments, URLs with page numbers), AI expansion.
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### Required Variables
|
||||
|
||||
**wolai-frontend/.env.local:**
|
||||
```bash
|
||||
NEXT_PUBLIC_SUPABASE_URL=...
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
|
||||
NEXT_PUBLIC_BACKEND_URL=...
|
||||
NEXT_PUBLIC_ONLYOFFICE_BASE_URL=...
|
||||
```
|
||||
|
||||
**wolai-backend/.env:**
|
||||
```bash
|
||||
SUPABASE_URL=...
|
||||
SUPABASE_SERVICE_ROLE_KEY=...
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
**AI Configuration (ai.md or env):**
|
||||
```
|
||||
apikey: sk-xxx
|
||||
https://api.openai.com/v1
|
||||
gpt-4
|
||||
```
|
||||
|
||||
**Desktop (auto-generated in data/electron/config.json):**
|
||||
```json
|
||||
{
|
||||
"networkMode": "remote-client|local|tunnel|auto",
|
||||
"supabaseUrl": "...",
|
||||
"backendUrl": "..."
|
||||
}
|
||||
```
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
- **File encoding:** UTF-8, Chinese comments allowed
|
||||
- **Indentation:** 2 spaces (TS/TSX), 4 spaces (Python)
|
||||
- **Naming:** PascalCase for components (`MindmapBlock.tsx`), `useXxx` for hooks, `*.test.ts(x)` for tests
|
||||
- **Imports:** Use `@/` alias for absolute imports
|
||||
- **Commits:** Version tags (`0.1.13 ...`) or conventional commits (`feat(mindmap):`, `fix:`, `chore:`)
|
||||
|
||||
## Key Files for Understanding
|
||||
|
||||
- `wolai-frontend/src/app/layout.tsx` - Root providers
|
||||
- `wolai-frontend/src/lib/ai-agent/runtime/runAgent.ts` - AI agent engine
|
||||
- `wolai-frontend/src/lib/ai-agent/tools/builtins/registryBuiltins.ts` - All AI tools
|
||||
- `wolai-frontend/src/components/editor/document-content.tsx` - Editor core
|
||||
- `wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx` - Mindmap UI
|
||||
- `desktop-electron/main.js` - Desktop entry point
|
||||
- `scripts/desktop-hot.js` - Dev orchestration
|
||||
- `AGENTS.md` - Project guidelines (Chinese)
|
||||
- `CODE_INDEX.md` - Complete code map (Chinese)
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Never commit API keys or tokens
|
||||
- Use existing ESLint/Vitest configurations
|
||||
- Desktop data stored in `<install_dir>/data/` (portable)
|
||||
- Supabase local: see `supabase.md` for ports/URLs
|
||||
- Route directories contain `()` and `[]` - use `-LiteralPath` in PowerShell
|
||||
|
||||
## AI Agent Tool Development
|
||||
|
||||
When adding new AI capabilities, register tools in `wolai-frontend/src/lib/ai-agent/tools/builtins/`. Tools are organized by scope:
|
||||
- **Global:** search_web, docs_search/read
|
||||
- **Mindmap:** mindmap_get, mindmap_apply_ops
|
||||
- **Document:** doc_get, doc_insert_blocks
|
||||
- **OnlyOffice:** oo_get_selection, oo_replace_selection
|
||||
|
||||
Permission model: `{ read: "allow" | "confirm", write: "allow" | "confirm" }`
|
||||
@@ -1,170 +0,0 @@
|
||||
# Cloudflare 部署与性能优化方案(全盘)
|
||||
|
||||
> 目标:前端可通过 Cloudflare 域名访问;后端(Supabase / LightRAG / ingest_service / Redis 等)通过 Cloudflare Tunnel 或等价方式对外暴露;同时解决当前“页面切换卡顿”的体感问题,并为后续 OCR(MinerU)与 RAG 自动入库留出扩展空间。
|
||||
|
||||
## 0. 现状结论(基于当前代码扫描)
|
||||
|
||||
### 0.1 当前代码对 Cloudflare(Edge/Workers/Pages)的直接阻塞点
|
||||
|
||||
`wolai-frontend` 内存在大量 Node 内置依赖与本地文件系统读写(`fs/path/process.cwd()`),例如:
|
||||
|
||||
- `wolai-frontend/src/lib/mindmap-files.ts`、`wolai-frontend/src/app/api/mindmap/**`:读写 `public/documents` / `public/mindmaps` 下的本地文件
|
||||
- `wolai-frontend/src/app/api/documents/{create,duplicate,copy-tree}`:本地文件写入/拷贝
|
||||
- `wolai-frontend/src/lib/ai/onlineAiConfig.ts`:本地读取配置文件
|
||||
- 部分 API 还使用 `crypto.randomUUID`
|
||||
|
||||
这些在 Cloudflare Pages/Workers 的运行时中**不可用或不推荐**(没有传统 Node 文件系统/进程工作目录概念),因此“把整个 Next.js(含 API Routes)直接部署到 Cloudflare 并保持当前行为”会遇到现实阻碍。
|
||||
|
||||
### 0.2 当前“页面切换卡”的高概率根因
|
||||
|
||||
在改造前,`/documents/[id]` 会把 `documents.content` 作为 Server Component props 直接带到前端编辑器(BlockNote)。当 `content` 较大时,会显著增加:
|
||||
|
||||
- 服务器到浏览器的 RSC Flight payload 体积
|
||||
- 浏览器端 JSON 解析、反序列化与 hydration 负担
|
||||
|
||||
这类卡顿会在“切换不同页面”场景中放大。
|
||||
|
||||
> 已落地修复:`/documents/[id]` 不再直接携带 `documents.content`,改为客户端按需请求 `/api/documents/content` 后再加载编辑器内容(见下方“P0 优化已实现”)。
|
||||
|
||||
---
|
||||
|
||||
## 1. Cloudflare 部署拓扑:推荐的三种路径
|
||||
|
||||
### 路径 A(推荐优先):Cloudflare 仅做反代/加速,本地跑完整 Next(含 API)
|
||||
|
||||
**适用场景**:希望最少改代码、尽快可用;允许前端与 API 都跑在你本地/服务器(Docker),Cloudflare 只提供域名与隧道入口。
|
||||
|
||||
- Cloudflare Tunnel 将 `https://你的域名` 指向本地的 `wolai-frontend`(Next server)
|
||||
- `/api/*` 也由同一 Next server 提供(无需跨域)
|
||||
- Supabase/LightRAG 等服务同样通过 Tunnel 暴露(可用子域名或路径分流)
|
||||
|
||||
**优点**
|
||||
- 几乎不需要重构(保留 `fs`/本地 mindmap 文件体系)
|
||||
- cookie / session 同源,前端调用 `/api` 最简单
|
||||
|
||||
**缺点**
|
||||
- 首屏与静态资源仍取决于你本地带宽/延迟(可通过 Cloudflare 缓存静态资源缓解)
|
||||
- 需要你保证本地服务稳定在线
|
||||
|
||||
### 路径 B:Cloudflare 部署“纯静态前端”(SPA/静态 Next),API 全部回源到本地
|
||||
|
||||
**适用场景**:希望前端 CDN 化(加载快),后端服务仍由你本地/服务器提供;愿意做一定的前后端解耦与配置改造。
|
||||
|
||||
关键改造点:
|
||||
- 前端不能依赖 Next Server Components 的“服务器取数”,而应改为客户端请求后端 API
|
||||
- Next 的 `app/api/*` 要迁移到后端服务(或保留在本地 Next,但需要确保 Cloudflare Pages 的 `/api` 反代到本地)
|
||||
- 所有本地文件读写(mindmap 文件、copy-tree)必须移动到“后端服务”中执行
|
||||
|
||||
**优点**
|
||||
- 前端体验更好(静态资源全球 CDN)
|
||||
- 后端仍可保留现有 Node 能力(fs、pdf 处理、脚本等)
|
||||
|
||||
**缺点**
|
||||
- 需要统一“API Base URL / 反代规则 / CORS / cookie 域”
|
||||
- 需要规划“哪些 API 放在本地 Next,哪些独立成后端服务”
|
||||
|
||||
### 路径 C:Cloudflare 运行 Next(Workers/Pages Functions)+ 后端回源
|
||||
|
||||
**适用场景**:希望尽可能多逻辑在 Cloudflare 边缘,但愿意严格约束 Node 能力,重构较大。
|
||||
|
||||
现实结论:以当前代码结构(大量 `fs/path/process.cwd()`)来看,除非先做较大改造,否则不建议直接走该路径。
|
||||
|
||||
---
|
||||
|
||||
## 2. 性能优化路线图(按优先级)
|
||||
|
||||
### P0(已实现,立刻收益):文档内容按需加载,减小路由切换负担
|
||||
|
||||
已改动:
|
||||
- 新增 `GET /api/documents/content?documentId=...`:仅返回 `documents.content`
|
||||
- `/documents/[id]` 不再 `select content`,避免 RSC 携带大字段
|
||||
- 编辑器内容在客户端加载:`DocumentContent` 在 `initialContent==null` 时拉取内容并展示“页面内容加载中...”
|
||||
- `BlockNoteEditor` 在 `normalizedInitialContent` 变化时重新初始化(只会从 `null -> 实际内容` 触发一次)
|
||||
|
||||
预期收益:
|
||||
- 切换页面时,RSC payload 更小、解析更快
|
||||
- 为 Cloudflare 反代/跨网访问(带宽较差)场景奠定基础
|
||||
|
||||
### P1(强烈推荐,低风险高收益):侧边栏与文件树性能
|
||||
|
||||
1) 文件树虚拟列表
|
||||
- `FileTree` 当前对 `rows` 直接 `map` 渲染,文档/附件数量上来后会明显卡顿
|
||||
- 已有 `@tanstack/react-virtual` 用例(`PrivateTree`),可复用同一技术栈把 `FileTree` 也做虚拟化
|
||||
|
||||
2) `assetsByDoc` 构建从 O(n^2) 优化为 O(n)
|
||||
- 当前实现每次 push 前会 `some()` 去重(n 较大时会变慢)
|
||||
- 建议改为 `Map<docId, Map<assetKey, asset>>` 一次遍历构建
|
||||
|
||||
3) `/api/sidebar` 数据裁剪与拆分
|
||||
- `fetchSidebarDataset` 对 `media_assets` 使用 `select("*")`,字段过多会拉大 payload
|
||||
- 建议改为只取侧边栏需要字段;并进一步拆分:
|
||||
- 文档树(documents)
|
||||
- 垃圾桶(trashed docs/assets)
|
||||
- 附件列表(只取当前展开节点/或按 docId 批量懒加载)
|
||||
|
||||
### P2(中期):文档内容存储与传输优化(大文档体验)
|
||||
|
||||
1) 内容分片/分页加载(可选)
|
||||
- 对 `documents.content` 进行“块级分页”(例如按顶层 block 分块),在编辑器侧逐步加载
|
||||
|
||||
2) 只拉取必要字段
|
||||
- 页面切换只拉 meta(title/updated_at/options/stats),内容按需加载(已完成)
|
||||
- backlinks / history 等面板可延迟加载或切换时懒加载
|
||||
|
||||
3) 缓存与压缩策略(配合 Cloudflare)
|
||||
- 对静态资源启用强缓存(Cloudflare 默认可做)
|
||||
- 对“用户私有”接口设置 `Cache-Control: private`,避免被共享缓存污染
|
||||
- 对“公开内容(public pages)”可用 `s-maxage` + `stale-while-revalidate`
|
||||
|
||||
---
|
||||
|
||||
## 3. Cloudflare 反代/穿透下的关键工程点(必须规划)
|
||||
|
||||
### 3.1 统一 API 入口与路径分流
|
||||
|
||||
建议以同一域名提供:
|
||||
- `https://mnote.example.com/`:前端
|
||||
- `https://mnote.example.com/api/*`:后端 API(本地 Next 或独立服务)
|
||||
- `https://mnote.example.com/supabase/*`:如需(或直接使用 supabase 子域名)
|
||||
- `https://mnote.example.com/lightrag/*`:LightRAG 7777(建议仅内部/鉴权后暴露)
|
||||
|
||||
这样可以最大化减少 CORS 与 cookie 域问题。
|
||||
|
||||
### 3.2 上传链路必须“直传”
|
||||
|
||||
Cloudflare/反代链路对大文件上传很敏感:
|
||||
- 建议前端改为“拿签名 URL 后直传到 Supabase Storage”(你已有 `/api/media/signed-url`)
|
||||
- 避免通过前端服务器中转大文件(会受 CF/反代限制、也会拖慢)
|
||||
|
||||
### 3.3 本地文件系统能力的迁移策略
|
||||
|
||||
当前 mindmap 文件存储在 `public/documents/**` 本地目录,这对 Cloudflare 部署不友好。
|
||||
|
||||
两条路:
|
||||
- 保留“本地 Node 后端”(路径 A / B),让所有文件读写都在本地后端做
|
||||
- 或迁移到 Supabase Storage(推荐长期):mindmap 变成一个可版本化的对象文件(支持软删/回收站/延迟清理)
|
||||
|
||||
---
|
||||
|
||||
## 4. 观测与量化(否则很难证明“不卡了”)
|
||||
|
||||
建议先落地最小可用的观测:
|
||||
|
||||
1) 前端导航耗时
|
||||
- 在 `router.push` 前后打点(`performance.mark`),记录到 console 或上报
|
||||
|
||||
2) 关键 API 延迟
|
||||
- `/api/sidebar`、`/api/documents/content`、`/api/search/*`:记录开始/结束与 payload 大小
|
||||
|
||||
3) 体感指标
|
||||
- 首次可交互(TTI)
|
||||
- 文档打开到“编辑器可输入”的时间
|
||||
|
||||
---
|
||||
|
||||
## 5. 下一步执行清单(建议按顺序)
|
||||
|
||||
1) 选择 Cloudflare 部署路径(A/B/C)
|
||||
2) P1:`FileTree` 虚拟化 + `assetsByDoc` O(n) 化 + `/api/sidebar` select 裁剪
|
||||
3) P2:公开页面缓存策略、上传直传、mindmap 存储迁移方案
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# 路径 A 实施手册:Cloudflare 入口 + 本地/自建服务器跑完整后端
|
||||
|
||||
> 目标:用户访问你的域名(Cloudflare,当前为 `aichem.dpdns.org`),请求通过 Cloudflare Tunnel 回源到你自建服务器(或家里电脑)上的 Next.js(wolai-frontend)与 Supabase/LightRAG/MinerU 等服务。
|
||||
|
||||
## 1. 推荐拓扑(最接近你当前代码形态)
|
||||
|
||||
- `app.aichem.dpdns.org` → `wolai-frontend`(Next 3000:页面 + /api/*)
|
||||
- `supabase.aichem.dpdns.org` → `Supabase Kong`(示例 18000)
|
||||
- `backend.aichem.dpdns.org` → `wolai-backend`(示例 8000,可选)
|
||||
- `onlyoffice.aichem.dpdns.org` → `OnlyOffice Document Server`(示例 8081)
|
||||
- `lightrag.aichem.dpdns.org` → `LightRAG`(示例 7777,建议加 Access)
|
||||
- `mineru.aichem.dpdns.org` → `MinerU`(示例 18888,建议加 Access)
|
||||
|
||||
> 用子域名的原因:Supabase JS 在浏览器端需要一个“稳定的 base URL”。把 Supabase 放在 path(例如 /supabase)会牵涉 WebSocket/重写/多服务路径复杂度,不建议前期这样做。
|
||||
|
||||
## 2. Cloudflare Tunnel 配置
|
||||
|
||||
仓库提供了示例文件(已按 `aichem.dpdns.org` 预填 hostname,可直接改端口/删减服务):
|
||||
- `scripts/cloudflared/config.yml.example`
|
||||
- `scripts/cloudflared/docker-compose.cloudflared.example.yml`
|
||||
|
||||
你需要做的事:
|
||||
1) Cloudflare 控制台创建 Tunnel
|
||||
2) 下载 `credentials.json`
|
||||
3) 替换 `config.yml` 里的 `tunnel` 与 `credentials-file`
|
||||
4) 绑定 DNS:为每个 hostname 绑定到该 tunnel
|
||||
5) 在服务器上运行 cloudflared(Windows 可直接运行;Linux 可用 docker compose)
|
||||
|
||||
## 3. 环境变量(关键:区分“浏览器访问地址”和“服务端内网地址”)
|
||||
|
||||
在路径 A 下,**浏览器端**必须访问 Cloudflare 域名;但 **Next 服务端**访问 Supabase/Backend 建议走内网地址(更快、更稳定,不绕 Cloudflare)。
|
||||
|
||||
### 3.1 wolai-frontend
|
||||
|
||||
浏览器端(公开):
|
||||
- `NEXT_PUBLIC_SUPABASE_URL=https://supabase.aichem.dpdns.org`
|
||||
- `NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon key>`
|
||||
- `NEXT_PUBLIC_BACKEND_URL=https://backend.aichem.dpdns.org`(可选)
|
||||
- `NEXT_PUBLIC_ONLYOFFICE_BASE_URL=https://onlyoffice.aichem.dpdns.org`(OnlyOffice)
|
||||
- `NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE=supabase.aichem.dpdns.org`(可选:仅当你的 signedUrl 里仍出现 127.0.0.1/host.docker.internal 时用)
|
||||
|
||||
服务端内网(仅 Next 服务器用,不暴露给浏览器):
|
||||
- `SUPABASE_INTERNAL_URL=http://127.0.0.1:18000`
|
||||
- `SUPABASE_ANON_KEY=<同一个 anon key>`
|
||||
- `BACKEND_URL=http://127.0.0.1:8000`(可选)
|
||||
|
||||
推荐用 `wolai-frontend/.env.production.local` 来承载生产配置;仓库提供了模板:
|
||||
- `wolai-frontend/.env.production.example`
|
||||
|
||||
> 已做代码支持:`wolai-frontend/src/lib/supabase/server.ts` 会优先用 `SUPABASE_INTERNAL_URL`;`wolai-frontend/src/app/api/media/ocr/route.ts` 会优先用 `BACKEND_URL`。
|
||||
|
||||
### 3.2 ingest_service / rag_gateway
|
||||
|
||||
你现在的 `services/ingest_service/app/core/config.py` 已优先读取仓库根目录 `.env.local/.env`。
|
||||
因此只要在根目录 `.env.local` 正确配置:
|
||||
- `SUPABASE_URL=http://127.0.0.1:18000`
|
||||
- `SUPABASE_SERVICE_ROLE_KEY=...`
|
||||
- `LIGHTRAG_URL=http://127.0.0.1:7777`
|
||||
- `MINERU_ENDPOINT=http://127.0.0.1:18888`
|
||||
|
||||
即可。
|
||||
|
||||
## 4. 安全建议(路径 A 很重要)
|
||||
|
||||
- `lightrag.<域名>`、`mineru.<域名>`:建议用 Cloudflare Access 或至少 IP 白名单,否则等同于把内部能力直接暴露到公网。
|
||||
- `supabase.<域名>`:若暴露到公网,务必确认 RLS 与 Auth 配置正确;不要泄露 service_role key。
|
||||
@@ -1,58 +0,0 @@
|
||||
在 Cloudflare Tunnel 中遇到本地到 Cloudflare 的高延迟,通常和**隧道连接的节点选择、网络路由、本地环境配置**有关,以下是针对性的解决步骤(按优先级排序):
|
||||
|
||||
|
||||
### 一、优先优化:切换 Cloudflare Tunnel 连接的边缘节点
|
||||
Cloudflare Tunnel 默认会自动选择“最近”的边缘节点,但实际网络路由可能导致延迟高,可手动指定低延迟节点:
|
||||
1. **查看当前连接的节点**:
|
||||
在本地运行隧道的终端中,执行 `cloudflared tunnel info <你的隧道名称>`,查看输出中的 `Connected to` 字段(如 `ams` 对应阿姆斯特丹节点)。
|
||||
2. **手动指定低延迟节点**:
|
||||
修改隧道启动命令,添加 `--edge-ip-version auto --region <目标区域代码>` 参数(区域代码参考 Cloudflare 边缘节点列表,如 `hkg` 对应香港、`sfo` 对应旧金山):
|
||||
```bash
|
||||
# 示例:指定香港节点(适合国内/亚洲地区)
|
||||
cloudflared tunnel run --region hkg <你的隧道名称>
|
||||
```
|
||||
3. **测试不同区域节点的延迟**:
|
||||
用 `ping` 测试 Cloudflare 边缘节点的延迟(如 `ping hkg.cloudflare.com`),选择延迟最低的区域(通常国内选 `hkg`、`sin` 新加坡,欧美选 `sfo`、`lax`)。
|
||||
|
||||
|
||||
### 二、检查本地网络与路由问题
|
||||
1. **测试本地网络基础延迟**:
|
||||
先排除本地网络本身的问题:
|
||||
- 测试本地到 Cloudflare 公共 IP 的延迟:`ping 1.1.1.1`(Cloudflare DNS 地址),若延迟本身超过 100ms,说明本地网络到 Cloudflare 骨干网的路由存在拥堵。
|
||||
- 切换本地网络(如从 Wi-Fi 换有线、换不同运营商网络),验证是否是网络环境导致。
|
||||
2. **关闭本地代理/VPN 干扰**:
|
||||
若本地开启了代理、VPN,可能会改变隧道的网络路由,导致延迟升高,建议临时关闭后重新启动隧道测试。
|
||||
|
||||
|
||||
### 三、优化 Cloudflare Tunnel 配置
|
||||
1. **启用 QUIC 协议**:
|
||||
Cloudflare Tunnel 支持更高效的 QUIC 协议(默认用 HTTP/2),可降低延迟:
|
||||
在隧道配置文件(通常是 `~/.cloudflared/config.yml`)中添加:
|
||||
```yaml
|
||||
protocol: quic
|
||||
```
|
||||
然后重启隧道。
|
||||
2. **调整隧道连接池大小**:
|
||||
增加隧道的连接数,避免单连接拥堵:
|
||||
在 `config.yml` 中添加:
|
||||
```yaml
|
||||
max-conns: 10 # 默认为 4,可适当增大到 8-12
|
||||
```
|
||||
|
||||
|
||||
### 四、检查 Cloudflare 账号与隧道状态
|
||||
1. **查看隧道的健康状态**:
|
||||
在 Cloudflare 控制台的“网络→隧道”页面,检查隧道的“状态”是否为“健康”,若显示“不稳定”,可重启本地 `cloudflared` 进程。
|
||||
2. **确认隧道绑定的域名解析**:
|
||||
若隧道绑定了自定义域名,检查域名的解析是否指向 Cloudflare 边缘节点(而非直接解析到本地 IP),错误的解析会绕开 Cloudflare 导致延迟升高。
|
||||
|
||||
|
||||
### 五、极端情况:更换 Cloudflare 区域
|
||||
若以上方法无效,可尝试将 Cloudflare 账号的“默认区域”切换到离本地更近的区域:
|
||||
1. 登录 Cloudflare 控制台,进入“我的个人资料→区域”;
|
||||
2. 选择离本地物理位置最近的区域(如国内选“Asia”),保存后重新启动隧道。
|
||||
|
||||
|
||||
通过以上步骤,通常能解决大部分 Cloudflare Tunnel 的高延迟问题。优先尝试**切换边缘节点+启用 QUIC 协议**,这两个操作对延迟的优化最明显。
|
||||
|
||||
要不要我帮你整理一份**隧道延迟测试的脚本**,可以自动检测不同区域节点的延迟并推荐最优选项?
|
||||
@@ -1,394 +0,0 @@
|
||||
# 本地优先(Local-first)Win 客户端改造方案(不依赖客户端 Docker)
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
你已明确选择“方案 2”:以 **丝滑体验** 为第一优先级,把主要交互(新建/删除/打开/编辑/附件管理/文件树)尽可能做成 **本地零延迟**;在线能力只承担 **多设备同步、鉴权、RAG/OCR 结果回传** 等“不需要即时展示”的部分。
|
||||
|
||||
同时你希望:
|
||||
|
||||
- **Windows 客户端可打包分发**(家人/同事无需安装 Docker)
|
||||
- 后续可扩展:安卓端可以继续使用网页版(或再做安卓壳/客户端)
|
||||
- 现有服务端栈(Supabase/LightRAG/Redis/MinerU/OnlyOffice)在需要时仍可使用,但不强制落到每个客户端
|
||||
|
||||
## 2. 现状盘点(基于当前仓库)
|
||||
|
||||
- 当前仓库根目录的 `pnpm run desktop` / `pnpm run desktop:hot` 本质是“一键启动脚本”,会启动:
|
||||
- `wolai-frontend`(Next.js)
|
||||
- `wolai-backend`(FastAPI)
|
||||
- `services/ingest_service`(自动入库/清理/触发)
|
||||
- `services/rag_gateway`(RAG 查询网关)
|
||||
- 这不是可分发的“桌面客户端”:仍依赖 Node/Python 环境、端口服务、配置文件,且前端资源与 API 仍走 HTTP。
|
||||
|
||||
结论:要实现你要的“丝滑 + 易安装”,需要把“桌面壳 + 本地数据层 + 后台任务/同步”做成独立产品形态,而不是继续依赖 Cloudflare 反代公网访问。
|
||||
|
||||
## 3. 推荐总体架构(分层 + 可演进)
|
||||
|
||||
### 3.1 角色划分
|
||||
|
||||
**A. 桌面客户端(Windows,主力)**
|
||||
|
||||
- UI:本地窗口(WebView/Electron)加载本地页面(不经公网)
|
||||
- 本地数据层:本地数据库(建议 SQLite)+ 本地文件目录(附件/导入文件)
|
||||
- 本地任务队列:后台线程/进程异步执行(避免 UI 卡顿)
|
||||
- 网络:只做“同步/索引请求/鉴权”,且尽量后台化
|
||||
|
||||
**B. 家庭/办公室“轻服务端”(可选,但强烈建议)**
|
||||
|
||||
你目前已有 Docker 相关服务,更适合放到一台“常开机器”(NAS/迷你主机/家用服务器)上,而不是每个客户端:
|
||||
|
||||
- Supabase(鉴权/同步/共享数据/存储桶/pgvector)
|
||||
- Redis(队列/后台任务)
|
||||
- LightRAG(入库/检索)
|
||||
- MinerU(OCR)
|
||||
- OnlyOffice Document Server(如仍需要网页内编辑 Office)
|
||||
|
||||
**C. 公网访问(可选)**
|
||||
|
||||
- 安卓端/外网访问时,继续使用网页版(可走 Cloudflare、或 VPN/IPv6)
|
||||
- 但桌面端的“主要交互体验”不再依赖公网链路
|
||||
|
||||
### 3.2 本地优先的数据归属
|
||||
|
||||
核心原则:**“能本地完成的操作,绝不阻塞在网络请求上。”**
|
||||
|
||||
- 文档/思维导图的“当前版本内容”:本地可用(SQLite + 文件)
|
||||
- 附件文件本体:本地可用(本地目录),需要时再同步/上传
|
||||
- 远端(Supabase)只承担:
|
||||
- 多设备同步与共享(最终一致)
|
||||
- RAG/OCR 的产物存储与检索
|
||||
- 登录鉴权(如你希望跨设备一致账号体系)
|
||||
|
||||
## 4. 技术路线:桌面壳怎么做(不跑 Docker)
|
||||
|
||||
你现在的前端是 Next.js(含 Server Components + API Routes),要做桌面客户端通常有两条路线:
|
||||
|
||||
### 路线 1(推荐落地优先):桌面壳 + 本地 HTTP(保持现有 Next/FastAPI)
|
||||
|
||||
- 桌面壳:Tauri 2 或 Electron(两者都能)
|
||||
- 客户端启动时:
|
||||
1) 启动本机 `wolai-frontend`(生产模式)到 `127.0.0.1:3000`
|
||||
2) 启动本机 `wolai-backend`(或逐步把关键 API 收敛到一个本地服务)
|
||||
3) 桌面壳打开 `http://127.0.0.1:3000`(同源、零公网)
|
||||
|
||||
优点:
|
||||
- 改造小:最大复用当前 Next/FastAPI
|
||||
- 体验立刻提升:不再经过 Cloudflare/Tunnel
|
||||
|
||||
缺点:
|
||||
- “安装体积/依赖”需要进一步工程化(见第 8 节分阶段)
|
||||
|
||||
### 路线 2(长期更优雅):本地壳 + 静态前端 + 本地 API(逐步去 Next Server 依赖)
|
||||
|
||||
- 把前端逐步改成更“纯前端”的形态(或迁 Vite/React SPA)
|
||||
- 本地 API 用一个可打包的二进制提供(Rust/Go)+ SQLite
|
||||
|
||||
优点:
|
||||
- 最终安装最轻、启动更快、可控性最好
|
||||
|
||||
缺点:
|
||||
- 改造量大,不建议作为第一阶段目标
|
||||
|
||||
**建议决策**:先走“路线 1”把体验做顺,再按收益逐步往“路线 2”靠拢。
|
||||
|
||||
## 5. “丝滑”的关键:本地任务队列 + 最终一致同步
|
||||
|
||||
### 5.1 本地操作必须立刻返回
|
||||
|
||||
把下列操作改为“本地先完成,后台再同步”:
|
||||
|
||||
- 新建/重命名/移动/删除(进入回收站)
|
||||
- 打开文档(本地读)
|
||||
- 编辑器保存(本地落盘 + 去抖)
|
||||
- 附件导入(本地复制到附件目录,立即可用)
|
||||
|
||||
### 5.2 同步模型(建议:Oplog/变更日志)
|
||||
|
||||
为避免“每次都全量上传大字段”导致卡顿,建议本地维护一个 `oplog`:
|
||||
|
||||
- 每个操作写入:`op_id、entity_type、entity_id、op_type、payload、created_at、synced_at`
|
||||
- 后台同步器按顺序上传到 Supabase(或服务端)
|
||||
- 冲突策略(第一阶段先简单):
|
||||
- 同一文档若出现冲突:保留双方版本(生成“冲突副本”),避免覆盖丢数据
|
||||
|
||||
### 5.3 建议的本地 SQLite 最小表结构(用于落地与排期)
|
||||
|
||||
> 目标:先把“本地秒开 + 不依赖网络”的体验做出来;同步与协作逐步增强。
|
||||
|
||||
- `local_documents`
|
||||
- `id`(uuid)、`parent_id`、`title`、`content_json`(或文件路径)、`updated_at`、`deleted_at`
|
||||
- `local_media_assets`
|
||||
- `id`、`document_id`、`file_path`、`mime`、`sha256`、`size`、`created_at`、`deleted_at`
|
||||
- `local_mindmaps`
|
||||
- `id`、`document_id`(可选)、`data_json`、`updated_at`、`deleted_at`
|
||||
- `local_trash_jobs`
|
||||
- `entity_type`、`entity_id`、`purge_after`(时间戳)、`canceled_at`
|
||||
- `local_oplog`
|
||||
- `op_id`、`entity_type`、`entity_id`、`op_type`、`payload_json`、`created_at`、`synced_at`、`retry_count`
|
||||
- `local_sync_state`
|
||||
- `remote_cursor`(用于增量拉取)、`last_pull_at`、`last_push_at`
|
||||
|
||||
### 5.4 与当前 Supabase 表的映射(基于 supabase_local 现有表)
|
||||
|
||||
你当前 Supabase(`public` schema)已经有这些关键表:`documents`、`media_assets`、`mindmap_meta`/`mindmap_nodes`、`rag_index_sources`、`lightrag_*`、`document_embeddings` 等。
|
||||
|
||||
建议映射策略(第一阶段):
|
||||
|
||||
- 本地 `local_documents` <-> 远端 `public.documents`
|
||||
- 远端作为“同步与共享的汇聚层”,本地为“即时交互层”
|
||||
- 本地 `local_media_assets` <-> 远端 `public.media_assets` + `storage.objects`
|
||||
- 远端文件本体尽量走 Storage(签名 URL/直传),避免经 API 中转
|
||||
- 思维导图:本地 `local_mindmaps` <-> 远端 `mindmap_meta`/`mindmap_nodes`
|
||||
- 目前“思维导图删除后垃圾桶没有出现”,本质是缺少统一的 `deleted_at` 与回收站视图
|
||||
- 建议把 mindmap 也纳入统一软删体系(与 documents/media_assets 同标准)
|
||||
|
||||
## 6. 回收站 + 延迟删除(解决误删与垃圾堆积)
|
||||
|
||||
你提出的需求非常关键:误删后 `Ctrl+Z` 可恢复,所以“删文件”不应立即删 OCR/RAG 资源。
|
||||
|
||||
建议统一成两段式删除:
|
||||
|
||||
1) **软删(立刻)**:进入回收站
|
||||
- 本地:移动到 `.trash/`(或打标 `deleted_at`),UI 可见
|
||||
- 服务端:对应记录打 `deleted_at`(不做物理删除)
|
||||
|
||||
2) **延迟清理(例如 10 分钟后)**:后台任务执行硬删除
|
||||
- 若用户在延迟窗口内恢复:取消清理任务
|
||||
- 手动“清空垃圾桶”:触发立即清理(仍建议带二次确认)
|
||||
|
||||
注意:你现有的报错 `rag_index_sources` 外键约束,说明服务端“删除顺序/级联规则”需要统一:
|
||||
|
||||
- 设计层面建议:对 RAG 相关表引入 `ON DELETE CASCADE` 或“先删子表再删父表”的硬规则
|
||||
- 实现层面建议:垃圾桶清理走同一个 `purge` 流程,确保顺序一致
|
||||
|
||||
基于你当前的约束信息:`rag_index_sources_document_id_fkey` 的 `ON DELETE` 是 `NO ACTION`(也就是不会级联删除),因此“清空垃圾桶”若直接删 `documents` 就必然触发外键错误。这里推荐两条修复路径(二选一):
|
||||
|
||||
1) **保留外键 NO ACTION**:清理时严格按顺序删除子表(例如先删 `rag_index_sources`、再删 `documents`)
|
||||
2) **改为 ON DELETE CASCADE**:允许删除 `documents` 时自动清理 `rag_index_sources`(更省心,但要确认不会误删共享/审计数据)
|
||||
|
||||
## 7. RAG/OCR(MinerU)在本地优先架构中的位置
|
||||
|
||||
你的期望是:客户端体验优先,OCR/RAG 可以后台慢慢做。因此推荐:
|
||||
|
||||
- **客户端只负责触发与展示结果**(不在客户端跑 Docker)
|
||||
- **OCR/RAG 在“轻服务端”执行**(可继续用你现有 Docker/服务)
|
||||
|
||||
### 7.1 触发策略(建议)
|
||||
|
||||
当本地文件发生变化(导入/更新):
|
||||
|
||||
- 计算 `content_hash`(用于去重与增量)
|
||||
- 将任务写入本地队列(或远端队列)
|
||||
- 后台上传文件到服务端(或 Supabase Storage)
|
||||
- 服务端按文件类型处理:
|
||||
- PDF/图片型 PDF:MinerU OCR -> 结构化文本
|
||||
- docx/pptx/xlsx:提取文本(必要时转 pdf 再 OCR)
|
||||
- 图片:OCR + 元数据
|
||||
- 最终写入:标准化 chunk + embedding + 元数据(Supabase/pgvector)
|
||||
|
||||
### 7.2 Embedding 建议(中英混合 + 化学语料,尽量免费)
|
||||
|
||||
优先推荐“可自托管/本地跑”的 embedding(服务端跑即可):
|
||||
|
||||
- `bge-m3`:多语种通用表现稳定,适合中英混合
|
||||
- `multilingual-e5-large`(或同系):检索向 embedding 生态成熟
|
||||
- 你现有 `Ollama` 方案里预拉的 `qwen3-embedding:8b`:可作为可用基线(但模型较大,CPU 可能慢,建议服务端有 GPU 或更强 CPU)
|
||||
|
||||
第一阶段建议先选 1 个跑通“自动入库闭环”,再用你提供的化学书籍片段做小规模对比评测(Recall@k + 人工判读)。
|
||||
|
||||
### 7.3 任务编排建议(复用现有 Supabase 表)
|
||||
|
||||
你当前 Supabase 已有两张非常适合做“可观测后台任务”的表:
|
||||
|
||||
- `public.rag_index_sources`:有 `status/attempts/last_error/source_updated_at/last_enqueued_at/last_processed_at`
|
||||
- `public.background_tasks`:有 `task_type/status/progress/message/updated_at`
|
||||
|
||||
建议用法:
|
||||
|
||||
1) **入库入口统一**:任何“需要入库/重建索引”的源(documents/media_assets/mindmap/ocr_text)都落一条 `rag_index_sources`
|
||||
2) **执行过程可视化**:真正跑 OCR/切块/embedding/入库时,同步写 `background_tasks`(用于前端显示进度条/日志)
|
||||
3) **幂等与去重**:
|
||||
- 用 `source_updated_at` + `content_hash`(可在 payload 里存)判断是否需要重跑
|
||||
- 同一 `source_id` 新任务到来时,若已有 `RUNNING` 则只更新 `last_enqueued_at`,避免并发重复跑
|
||||
|
||||
### 7.4 OCR 决策(PDF 有文本 vs 图片型 PDF)
|
||||
|
||||
针对你提供的两类测试 PDF(“已 OCR 过含文本”与“图片型 PDF”),建议服务端在入库前做一个轻量判断:
|
||||
|
||||
- 先尝试“抽取文本”(例如每页抽样、统计可抽取字符数)
|
||||
- 若字符数/覆盖率低于阈值,再走 MinerU OCR
|
||||
- OCR 产物建议保留:
|
||||
- `raw_text`(纯文本,用于检索)
|
||||
- `layout`(段落/页码/坐标等,用于引用定位与高亮)
|
||||
|
||||
这样可以避免对“本来就有文本层的 PDF”重复 OCR,节省大量时间。
|
||||
|
||||
### 7.5 LightRAG 版本对齐(你提到的 v1.4.9.10)
|
||||
|
||||
当前仓库内的 `LightRAG` 代码与其自带虚拟环境显示版本为 `1.4.9.9`(`LightRAG/lightrag/__init__.py` 与 `LightRAG/.venv` 一致)。
|
||||
|
||||
建议策略:
|
||||
|
||||
- **先稳**:在“本地优先客户端”主线落地前,LightRAG 不做大范围升级,避免把变量叠加到体验问题上
|
||||
- **再对齐**:单独开一个“LightRAG 升级分支计划”(仅改 LightRAG 目录/服务启动脚本/兼容性),升级到你说的 `v1.4.9.10` 后,用你提供的两份测试 PDF 跑一遍入库回归(含 OCR 分支)
|
||||
|
||||
## 8. 分阶段落地路线(建议按收益/难度)
|
||||
|
||||
### P0(最小可用,立刻变丝滑)
|
||||
|
||||
- 明确“桌面端默认走本机”:`127.0.0.1` / 局域网 IP
|
||||
- 桌面端不经过 Cloudflare(Cloudflare 仅保留给“手机/外网”)
|
||||
- 这一步不解决“安装依赖”,但先把“体验问题”排除在公网链路之外
|
||||
|
||||
### P1(可分发 Win 客户端:无需 Docker)
|
||||
|
||||
目标:家人/同事拿到安装包即可用(最多只安装一次 VC 运行库/WebView2)。
|
||||
|
||||
- 引入桌面壳(Tauri 或 Electron)
|
||||
- 把必要服务以“sidecar”方式随客户端分发并由壳统一拉起/关闭:
|
||||
- Next 前端(建议 next standalone 输出 + 内置 Node)
|
||||
- 后端 API(优先收敛成一个本地服务;Python 可先保留,后续再二进制化)
|
||||
- 本地数据层落地:SQLite + 本地文件目录
|
||||
|
||||
### P2(减少依赖与体积)
|
||||
|
||||
- Python 服务逐步二进制化(PyInstaller/Nuitka 或重写关键路径为 Rust/Go)
|
||||
- 能迁到本地 API 的 Next `app/api/*` 逐步迁移,减少 Next server 负担
|
||||
|
||||
### P3(多端)
|
||||
|
||||
- 安卓端继续用网页版(外网可用 VPN/IPv6/Cloudflare 其一)
|
||||
- 桌面端与网页版共享同一套“同步协议/数据模型”,但桌面端以本地为主
|
||||
|
||||
## 9. 你需要做的关键取舍(我建议的默认值)
|
||||
|
||||
1) **OnlyOffice 的定位**
|
||||
- 如果你追求“打开速度极致”:Windows 客户端对 docx/xlsx/pptx 优先“用本机 Office/OnlyOffice Desktop 打开”,应用内只做预览/管理/版本
|
||||
- OnlyOffice Document Server 保留给:手机端/外网网页端
|
||||
|
||||
2) **服务端部署位置**
|
||||
- 若你有一台常开机器:把 Supabase/LightRAG/MinerU/Redis/OnlyOffice 放到 LAN 里,桌面端访问延迟会非常低
|
||||
- 外网访问建议优先 VPN(你是小范围用户),避免 Cloudflare 造成的不可控绕路
|
||||
|
||||
## 10. 下一步我建议你先确认的 5 个问题(确认后我再给出更“可执行”的工程拆分)
|
||||
|
||||
1) Windows 客户端你更偏好:`Tauri`(小体积)还是 `Electron`(生态成熟)?
|
||||
2) 你是否接受“第一阶段仍需要安装 Node/Python”,还是必须“一键安装无运行时依赖”?
|
||||
3) 你希望本地数据目录放哪:`%USERPROFILE%\\Documents\\MNOTE` 还是跟仓库同级?
|
||||
4) 多人同时编辑是否重要?(决定是否引入 Yjs 协作服务端/冲突策略)
|
||||
5) 你的“轻服务端”准备放在哪台机器(家用主机/NAS/云服务器)?
|
||||
|
||||
## 11. 你已确认的项目决策(已记录,后续按此推进)
|
||||
|
||||
你已明确选择:
|
||||
|
||||
1) 桌面壳:`Electron`
|
||||
2) 依赖:可以安装任何东西,但希望“安装后即可启动客户端”(减少手工配置)
|
||||
3) 本地数据:放在**安装目录**(用户上传文件先落到该目录,再与远程 Supabase 同步)
|
||||
4) 协作:暂不做多人同时编辑
|
||||
5) 轻服务端:当前家用主机(先这样)
|
||||
|
||||
### 11.1 关键风险提醒:Windows 的“安装目录”通常不可写
|
||||
|
||||
如果你用的是常见的安装方式(MSI/NSIS 默认装到 `C:\\Program Files\\...`),Windows 会对普通用户进程启用 UAC/权限限制,**安装目录默认不可写**,会导致:
|
||||
|
||||
- 上传文件/本地数据库写入失败或变成“时好时坏”
|
||||
- 需要管理员权限运行(体验差、也不安全)
|
||||
|
||||
你已进一步明确:**标准安装**,但安装位置必须可选且尽量不在 C 盘(数据会越来越大)。
|
||||
|
||||
这里的关键点不在“C 盘 vs 非 C 盘”,而在“是否安装在受保护目录(如 Program Files)”。即使装到 `D:\\Program Files\\...`,也可能同样不可写。
|
||||
|
||||
为满足“标准安装 + 数据放安装目录 + 不在 C 盘”,我建议按下面规则实现(作为默认方案):
|
||||
|
||||
- **安装路径强约束**:安装器允许用户自选安装目录,但**禁止选择 C 盘**,并且默认推荐路径形如 `D:\\Apps\\MNOTE`(而不是 `D:\\Program Files\\...`)。
|
||||
- **数据目录固定在安装目录内**:例如 `D:\\Apps\\MNOTE\\data`(满足“数据跟安装目录走”)。
|
||||
- **安装时设置 data 目录 ACL**:安装器在安装阶段为 `data` 目录授予当前用户(或 Users 组)可写权限,避免运行时写入失败。
|
||||
|
||||
> 如果你希望“强制必须装到非 C 盘”,这是可做的;但如果同事机器没有 D 盘,也需要允许选择 E/F 等其它盘符。
|
||||
|
||||
结论:你不需要做“绿色版”,也不需要把数据放到 `userData`;但必须在安装器层面把“安装目录可写”这件事做成默认成立。
|
||||
|
||||
## 12. Electron 客户端落地方案(不让客户端跑 Docker)
|
||||
|
||||
### 12.1 本地优先的最小形态(推荐作为第一阶段实现)
|
||||
|
||||
Electron 只做三件事:
|
||||
|
||||
- 提供 UI(窗口内渲染网页)
|
||||
- 提供本地数据目录与 SQLite(零延迟读写)
|
||||
- 后台做“同步/上传/触发服务端入库”(不阻塞 UI)
|
||||
|
||||
客户端**不运行 Docker**,也不强制在客户端跑 LightRAG / MinerU / Supabase。
|
||||
|
||||
### 12.2 与现有代码的衔接方式(尽量少改,先跑通)
|
||||
|
||||
第一阶段建议继续复用你现有的 HTTP 形态,但把链路从“公网 Cloudflare”改为“本机/局域网”:
|
||||
|
||||
- Electron 启动时拉起本机服务(作为 sidecar):
|
||||
- `wolai-frontend`:Next production(本机 `127.0.0.1:3000`)
|
||||
- `wolai-backend`:FastAPI(本机 `127.0.0.1:8000`)
|
||||
- Electron 窗口直接打开 `http://127.0.0.1:3000`
|
||||
|
||||
这样 UI 完全不走公网,页面切换/打开文档会立刻变“本地速度”。
|
||||
|
||||
> 说明:你仓库根目录的 `pnpm run desktop` 目前更像“开发者一键启动”;后续我们会新增专用的“Electron sidecar 启动器”,把端口探测、日志、崩溃重启、退出清理做成稳定产品级行为。
|
||||
|
||||
### 12.3 依赖与打包策略(让“安装后可启动”)
|
||||
|
||||
你允许安装任何东西,但希望安装后可启动。我建议用“两阶段”逐步降低门槛:
|
||||
|
||||
**阶段 1(最快落地)**
|
||||
- 安装前置:Node、Python(以及必要的 VC 运行库、WebView2)
|
||||
- Electron installer 在首次启动时做自检(缺什么就引导安装)
|
||||
|
||||
**阶段 2(更像产品)**
|
||||
- 把后端打成单文件/少量文件的可执行程序(PyInstaller/Nuitka)
|
||||
- 把前端打成 Next standalone(或更进一步变成静态 + 本地 API)
|
||||
- Electron 安装包内置所有运行时(用户无需额外安装 Node/Python)
|
||||
|
||||
## 13. 轻服务端(家用主机)承担什么
|
||||
|
||||
鉴于你暂时不做多人实时协作,且优先“本地丝滑”,建议家用主机只承担:
|
||||
|
||||
- Supabase:鉴权 + 多端同步汇聚层 + Storage + pgvector
|
||||
- ingest_service:监听/轮询需要入库的源(`rag_index_sources`),做切块/embedding/入库
|
||||
- MinerU:OCR 工作负载(图片/图片型 PDF)
|
||||
- LightRAG:统一入库/检索(或作为 ingest_service 内部依赖)
|
||||
- Redis:任务队列/限流(可选)
|
||||
- OnlyOffice Document Server:主要给“网页/手机端”使用;Windows 客户端可优先走本机 Office/OnlyOffice Desktop 提升打开速度
|
||||
|
||||
## 14. 下一步里程碑(可直接开工的任务清单)
|
||||
|
||||
### M0:确定安装与数据目录规则(必须先定)
|
||||
|
||||
- 标准安装(NSIS/MSI),允许用户选择安装路径
|
||||
- 默认路径推荐 `D:\\Apps\\MNOTE`
|
||||
- 禁止安装到 C 盘(可配置为硬限制或安装器强提示)
|
||||
- 数据目录固定为 `$INSTDIR\\data` 并在安装时设置写权限
|
||||
|
||||
### M1:Electron 外壳 + 本机服务启动(体验立竿见影)
|
||||
|
||||
- 新增 `electron-app/`(主进程 + 渲染进程)
|
||||
- 主进程实现:
|
||||
- 启动前端(Next production)到 `127.0.0.1:3000`
|
||||
- 启动后端(FastAPI)到 `127.0.0.1:8000`
|
||||
- 进程托管:崩溃重启、退出清理、日志落盘(UTF-8)
|
||||
- 渲染进程直接加载 `http://127.0.0.1:3000`
|
||||
|
||||
### M2:本地数据目录与 SQLite(真正 local-first)
|
||||
|
||||
- 选定数据根目录(绿色版:`./data`;标准版:`userData`)
|
||||
- 引入 SQLite(文档/附件索引/oplog/回收站任务)
|
||||
- 把“创建/删除/打开”的关键路径改为本地优先(网络失败也能操作)
|
||||
|
||||
### M3:同步(最终一致)
|
||||
|
||||
- 本地 `local_oplog` 推送到 Supabase
|
||||
- 从 Supabase 拉取增量变更,更新本地 SQLite
|
||||
- 冲突先采用“生成冲突副本”的保守策略
|
||||
|
||||
### M4:入库/OCR 后台化(不阻塞 UI)
|
||||
|
||||
- 客户端只负责:上传文件 + 写 `rag_index_sources`(或触发 API)
|
||||
- 家用主机跑 ingest_service/MinerU/LightRAG 完成入库
|
||||
- 客户端订阅/轮询 `background_tasks` 显示进度(可选)
|
||||
@@ -1,153 +0,0 @@
|
||||
# 从 Cloudflare Tunnel 切换到 SakuraFrp(NATFRP)操作指南
|
||||
|
||||
本文用于把当前的 Cloudflare Tunnel 替换为 SakuraFrp(NATFRP)HTTP/HTTPS 穿透,并保持域名不变(例如 `app.aichem.dpdns.org` 等),从而让远程浏览器与客户端都能通过固定域名访问本机服务。
|
||||
|
||||
参考文档(官方):`https://doc.natfrp.com/app/http.html`
|
||||
|
||||
---
|
||||
|
||||
## 0. 你需要先确认的前置条件
|
||||
|
||||
1. 你拥有 SakuraFrp 账号,并已开通/购买支持 **HTTP/HTTPS** 的穿透资源(隧道/节点)。
|
||||
2. 你能管理 DNS(这里是 `*.aichem.dpdns.org`),可以新增/修改 CNAME 记录并等待生效。
|
||||
3. 本机服务在本地能正常访问(端口以你实际为准):
|
||||
- Web 前端:例如 `http://127.0.0.1:3000`
|
||||
- 后端:例如 `http://127.0.0.1:8000`
|
||||
- Supabase(Kong 网关):例如 `http://127.0.0.1:54321`
|
||||
- ONLYOFFICE(Web):例如 `http://127.0.0.1:8081`
|
||||
|
||||
> 注意:NATFRP 的“HTTP/HTTPS 隧道”通常只需要你填 **本地地址 + 本地端口 + 域名**;外部访问走 NATFRP 节点转发,不要求你在路由器上做端口映射。
|
||||
|
||||
---
|
||||
|
||||
## 1. 建议的域名与本地端口映射(按你的项目约定)
|
||||
|
||||
请把这些域名继续作为“唯一入口”,不要在客户端里写 `127.0.0.1`(避免远程客户端失败):
|
||||
|
||||
| 对外域名 | 作用 | 本地服务(示例) |
|
||||
|---|---|---|
|
||||
| `app.aichem.dpdns.org` | Web 前端(Next.js) | `127.0.0.1:3000` |
|
||||
| `backend.aichem.dpdns.org` | FastAPI 后端 | `127.0.0.1:8000` |
|
||||
| `supabase.aichem.dpdns.org` | Supabase Kong | `127.0.0.1:54321` |
|
||||
| `onlyoffice.aichem.dpdns.org` | ONLYOFFICE(Web) | `127.0.0.1:8081` |
|
||||
|
||||
> 如果你本机端口不是这些值:以你实际运行的端口为准(只要域名保持不变,应用侧无需改动)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 关闭 Cloudflare Tunnel(避免“同时生效”导致混乱)
|
||||
|
||||
目标:确保 `app/backend/supabase/onlyoffice` 这几个域名不再回源到 Cloudflare tunnel。
|
||||
|
||||
建议顺序:
|
||||
|
||||
1. 在 Cloudflare Zero Trust / Tunnel 面板中暂停或删除对应 Public Hostname 规则。
|
||||
2. 在本机停止 `cloudflared`(如果你用的是服务方式/计划任务):
|
||||
- Windows 服务:停止对应服务
|
||||
- 或命令行启动的:关闭该进程窗口
|
||||
3. 等待 1~2 分钟,确保 Cloudflare 侧不再回源。
|
||||
|
||||
---
|
||||
|
||||
## 3. 在 SakuraFrp 客户端 App 中创建(或导入)HTTP/HTTPS 隧道
|
||||
|
||||
### 3.1 为每个域名创建 1 条隧道
|
||||
|
||||
在 SakuraFrp 客户端 App(或其网页管理面板)里分别创建 4 条隧道(建议一个域名一条,便于排障):
|
||||
|
||||
- 类型:HTTP 或 HTTPS(建议优先 HTTPS)
|
||||
- 本地地址:`127.0.0.1`
|
||||
- 本地端口:填你实际端口(参考上表)
|
||||
- 绑定域名:填对应子域名(例如 `app.aichem.dpdns.org`)
|
||||
|
||||
### 3.2 HTTPS 证书的两种做法(选其一)
|
||||
|
||||
做法 A(推荐):在 SakuraFrp 的隧道设置里启用/配置 HTTPS(如果支持自动证书或上传证书)。
|
||||
|
||||
做法 B:只用 HTTP 隧道,然后在你自己的反代层(例如 VPS 上的 Caddy/Nginx)终止 TLS,再转发到 NATFRP 的 HTTP 外网域名。
|
||||
|
||||
> 你当前使用的是 `https://app.aichem.dpdns.org`,因此最终必须能提供 HTTPS(要么由 NATFRP 提供,要么由你自己的反代层提供)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 修改 DNS:把域名解析指向 SakuraFrp
|
||||
|
||||
核心思路:让 `app.aichem.dpdns.org` 等域名,解析到 NATFRP 提供的“外网域名/入口”。
|
||||
|
||||
常见做法(按 NATFRP 面板给出的信息填写):
|
||||
|
||||
- 为 `app.aichem.dpdns.org` 设置 CNAME → NATFRP 给该隧道分配的外网域名
|
||||
- 为 `backend.aichem.dpdns.org` 设置 CNAME → 对应隧道外网域名
|
||||
- 为 `supabase.aichem.dpdns.org` 设置 CNAME → 对应隧道外网域名
|
||||
- 为 `onlyoffice.aichem.dpdns.org` 设置 CNAME → 对应隧道外网域名
|
||||
|
||||
建议:
|
||||
|
||||
- TTL 先设短一些(例如 60/120 秒)便于切换与回滚
|
||||
- 修改后等待解析生效(可用 `nslookup` 或在线 DNS 工具验证)
|
||||
|
||||
---
|
||||
|
||||
## 5. 启动隧道并启动本机服务
|
||||
|
||||
1. 启动 SakuraFrp 客户端 App,确保 4 条隧道状态为“已连接/运行中”。
|
||||
2. 启动本机服务:
|
||||
- 前端(Next.js):监听 `127.0.0.1:3000`(或你的端口)
|
||||
- 后端(FastAPI):监听 `127.0.0.1:8000`
|
||||
- Supabase:确保 Kong 网关在 `127.0.0.1:54321`
|
||||
- ONLYOFFICE:确保可通过 `http://127.0.0.1:8081` 打开
|
||||
|
||||
> 建议先确保“本机直连正常”,再用外网域名验证;不要一上来就只看外网,排障会非常痛苦。
|
||||
|
||||
---
|
||||
|
||||
## 6. 外网验证清单(强烈建议逐项验证)
|
||||
|
||||
下面命令建议在“非本机网络”(例如手机热点)执行,避免被 hosts/缓存误导:
|
||||
|
||||
```bash
|
||||
curl -I https://app.aichem.dpdns.org/
|
||||
curl -I https://app.aichem.dpdns.org/_next/static/chunks/ # 这里应当返回 200/403,而不是 404
|
||||
|
||||
curl -I https://backend.aichem.dpdns.org/health # 以你后端真实 health 路由为准
|
||||
curl -I https://supabase.aichem.dpdns.org/auth/v1/health # Supabase GoTrue 健康检查
|
||||
|
||||
curl -I https://onlyoffice.aichem.dpdns.org/web-apps/apps/api/documents/api.js
|
||||
```
|
||||
|
||||
如果 `/_next/static/...` 404,通常说明:
|
||||
- 前端没跑起来;或
|
||||
- 回源不是你预期的服务;或
|
||||
- 你用的是 Next.js standalone,但 `.next/static` 没放到 server 期望位置。
|
||||
|
||||
---
|
||||
|
||||
## 7. 应用侧通常不需要改(但你应当检查这 3 件事)
|
||||
|
||||
在本项目的预期做法里,“域名是契约”,穿透只是把契约指到不同回源:
|
||||
|
||||
1. `wolai-frontend/public/mnote-env.json` 中的域名配置仍然使用:
|
||||
- `supabaseUrl = https://supabase.aichem.dpdns.org`
|
||||
- `backendUrl = https://backend.aichem.dpdns.org`
|
||||
- `onlyofficeBaseUrlWeb = https://onlyoffice.aichem.dpdns.org`
|
||||
- `onlyofficeBaseUrlDesktop = http://127.0.0.1:<本地onlyoffice端口>`(桌面端走本地)
|
||||
2. 确保桌面端(Electron)不要再写死 `127.0.0.1` 作为“登录域名”,否则远程客户端必然失败。
|
||||
3. Supabase 若涉及 OAuth 回调/邮件链接,域名变更会影响 redirect;但你这里“域名不变”,所以理论上不需要改 Supabase 配置。
|
||||
|
||||
---
|
||||
|
||||
## 8. 常见坑与处理建议
|
||||
|
||||
1. **WebSocket**:Supabase realtime、ONLYOFFICE 都可能用到 WebSocket;如果某条隧道出现“能打开页面但实时断开”,优先检查 NATFRP 节点/隧道是否支持 WebSocket。
|
||||
2. **超时/大文件**:ONLYOFFICE 打开大文档/图片时可能超时;可在 NATFRP/反代层上调大超时、允许更大 body。
|
||||
3. **缓存污染**:切换通道后浏览器可能缓存旧回源;建议强刷或无痕窗口验证。
|
||||
4. **DNS 未生效**:`nslookup app.aichem.dpdns.org` 看到的目标仍是旧值时,先别排应用,先把 DNS 跑通。
|
||||
|
||||
---
|
||||
|
||||
## 9. 回滚方案(出问题快速恢复)
|
||||
|
||||
1. 把 DNS CNAME 改回 Cloudflare Tunnel 的目标(或恢复 Cloudflare 的 Public Hostname 规则)。
|
||||
2. 停掉 SakuraFrp 对应隧道(避免双通道造成回源混乱)。
|
||||
3. 验证 `https://app.aichem.dpdns.org` 能回到 Cloudflare 路径。
|
||||
|
||||
@@ -60,6 +60,39 @@ function buildUpstreamRequestHead(req, targetUrl) {
|
||||
lines.push(`${req.method || "GET"} ${upstreamPath} HTTP/1.1`);
|
||||
|
||||
const headers = req.headers || {};
|
||||
const headerValue = (name) => {
|
||||
const v = headers[name];
|
||||
if (!v) return "";
|
||||
return Array.isArray(v) ? v[0] : String(v);
|
||||
};
|
||||
|
||||
// 说明:ONLYOFFICE 在反向代理下会依赖 X-Forwarded-* 推导“对外地址”,用于拼出 ws/wss 与缓存资源 URL。
|
||||
// 这里尽量沿用上游(frp/nginx)注入的 x-forwarded-proto/host;若缺失,则回退到本机 http。
|
||||
const originLike = headerValue("origin") || headerValue("referer") || "";
|
||||
const originUrl = (() => {
|
||||
try {
|
||||
if (!originLike) return null;
|
||||
return new URL(originLike);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const forwardedHostRaw =
|
||||
headerValue("x-forwarded-host") || (originUrl ? originUrl.host : "") || headerValue("host") || "";
|
||||
const forwardedProto =
|
||||
(headerValue("x-forwarded-proto") || "").split(",")[0].trim() ||
|
||||
(originUrl ? originUrl.protocol.replace(":", "") : "") ||
|
||||
"http";
|
||||
const forwardedPort = (() => {
|
||||
const fromHeader = (headerValue("x-forwarded-port") || "").split(",")[0].trim();
|
||||
if (fromHeader) return fromHeader;
|
||||
const hostHasPort = forwardedHostRaw.includes(":") ? forwardedHostRaw.split(":").pop() : "";
|
||||
if (hostHasPort && /^\d+$/.test(hostHasPort)) return hostHasPort;
|
||||
return forwardedProto === "https" ? "443" : "80";
|
||||
})();
|
||||
const forwardedHost = forwardedHostRaw.split(",")[0].trim() || "localhost";
|
||||
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
if (!v) continue;
|
||||
const key = String(k);
|
||||
@@ -71,6 +104,12 @@ function buildUpstreamRequestHead(req, targetUrl) {
|
||||
}
|
||||
}
|
||||
|
||||
// 说明:补齐/覆盖 forward 信息,避免 ONLYOFFICE 返回指向内部端口的绝对 URL。
|
||||
lines.push(`x-forwarded-host: ${forwardedHost}`);
|
||||
lines.push(`x-forwarded-proto: ${forwardedProto}`);
|
||||
lines.push(`x-forwarded-port: ${forwardedPort}`);
|
||||
lines.push(`x-forwarded-prefix: ${ONLYOFFICE_PREFIX}`);
|
||||
|
||||
// 说明:Host 必须指向 ONLYOFFICE_INTERNAL_URL,否则上游可能拒绝 Upgrade。
|
||||
lines.push(`Host: ${targetUrl.host}`);
|
||||
lines.push("");
|
||||
@@ -144,7 +183,16 @@ async function main() {
|
||||
if (isOnlyOfficePath(req.url || "/")) {
|
||||
try {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[dev-server][onlyoffice-ws] upgrade", req.url);
|
||||
console.log(
|
||||
"[dev-server][onlyoffice-ws] upgrade",
|
||||
req.url,
|
||||
"host=",
|
||||
req.headers.host,
|
||||
"xfp=",
|
||||
req.headers["x-forwarded-proto"],
|
||||
"xfh=",
|
||||
req.headers["x-forwarded-host"],
|
||||
);
|
||||
} catch {}
|
||||
proxyOnlyOfficeUpgrade(req, socket, head);
|
||||
return;
|
||||
|
||||
@@ -81,51 +81,57 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
// 说明:Convex 模式下,保存写回 Convex Files,并更新 media_assets.storage_id/file_url。
|
||||
const userId = String(process.env.DEV_USER_ID || "dev-user").trim() || "dev-user";
|
||||
const client = getConvexHttpClient();
|
||||
try {
|
||||
// 说明:Convex 模式下,保存写回 Convex Files,并更新 media_assets.storage_id/file_url。
|
||||
const userId = String(process.env.DEV_USER_ID || "dev-user").trim() || "dev-user";
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const asset = await client.query(api.mediaAssets.getById, { userId, id: assetId });
|
||||
if (!asset) {
|
||||
const asset = await client.query(api.mediaAssets.getById, { userId, id: assetId });
|
||||
if (!asset) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
const downloadUrl = tryRewriteOnlyOfficeDownloadUrl(body.url);
|
||||
const upstream = await fetch(downloadUrl, { method: "GET", redirect: "follow" });
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
const buf = Buffer.from(await upstream.arrayBuffer());
|
||||
|
||||
const uploadUrl = await client.mutation(api.mediaAssets.generateUploadUrl, { userId });
|
||||
if (!uploadUrl || typeof uploadUrl !== "string") {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
const uploadRes = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": asset.mime_type || "application/octet-stream" },
|
||||
body: buf,
|
||||
});
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
const uploadJson = (await uploadRes.json().catch(() => null)) as { storageId?: string } | null;
|
||||
const storageId = String(uploadJson?.storageId || "");
|
||||
if (!storageId) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
await client.mutation(api.mediaAssets.replaceStorageFromUpload, {
|
||||
userId,
|
||||
id: assetId,
|
||||
storageId: storageId as any,
|
||||
});
|
||||
|
||||
return NextResponse.json({ error: 0 });
|
||||
} catch (error) {
|
||||
// 说明:避免异常导致 ONLYOFFICE 重试/阻塞(例如 Convex 未部署新 mutation)。
|
||||
console.error("[onlyoffice/callback] convex writeback failed:", error);
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
const downloadUrl = tryRewriteOnlyOfficeDownloadUrl(body.url);
|
||||
const upstream = await fetch(downloadUrl, { method: "GET", redirect: "follow" });
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
const buf = Buffer.from(await upstream.arrayBuffer());
|
||||
|
||||
const uploadUrl = await client.mutation(api.mediaAssets.generateUploadUrl, { userId });
|
||||
if (!uploadUrl || typeof uploadUrl !== "string") {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
const uploadRes = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": asset.mime_type || "application/octet-stream" },
|
||||
body: buf,
|
||||
});
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
const uploadJson = (await uploadRes.json().catch(() => null)) as { storageId?: string } | null;
|
||||
const storageId = String(uploadJson?.storageId || "");
|
||||
if (!storageId) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
await client.mutation(api.mediaAssets.replaceStorageFromUpload, {
|
||||
userId,
|
||||
id: assetId,
|
||||
storageId: storageId as any,
|
||||
});
|
||||
|
||||
return NextResponse.json({ error: 0 });
|
||||
}
|
||||
|
||||
const { data: asset, error: assetError } = await supabaseAdmin
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -44,12 +45,23 @@ window.__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true;
|
||||
var proxyPrefix = location.origin.replace(/\\/+$/, '') + '/onlyoffice-server';
|
||||
var internal = {
|
||||
'http://127.0.0.1:8081': true,
|
||||
'http://localhost:8081': true
|
||||
'http://localhost:8081': true,
|
||||
// 说明:同上,兜底错误的 https://127.0.0.1:8081
|
||||
'https://127.0.0.1:8081': true,
|
||||
'https://localhost:8081': true
|
||||
};
|
||||
function rewrite(u) {
|
||||
try {
|
||||
var abs = new URL(u, location.origin);
|
||||
var origin = abs.protocol + '//' + abs.host;
|
||||
// 说明:外网 https 访问时,如果 ONLYOFFICE 错误生成了 http://<host>/onlyoffice-server/...,
|
||||
// 浏览器会以 Mixed Content 阻止请求;这里直接升级为 https。
|
||||
if (location.protocol === 'https:' && abs.protocol === 'http:' && abs.host === location.host) {
|
||||
if (abs.pathname === '/onlyoffice-server' || abs.pathname.indexOf('/onlyoffice-server/') === 0) {
|
||||
abs.protocol = 'https:';
|
||||
return abs.toString();
|
||||
}
|
||||
}
|
||||
if (!internal[origin]) return u;
|
||||
return proxyPrefix + abs.pathname + abs.search + abs.hash;
|
||||
} catch (e) {
|
||||
@@ -154,13 +166,57 @@ const proxy = async (request: NextRequest, pathParts: string[]) => {
|
||||
// 说明:ONLYOFFICE 在被反向代理时,会根据 X-Forwarded-* 推导自身对外地址,
|
||||
// 用于生成静态资源/缓存文件的 URL。若缺失这些信息,可能会返回指向内部端口
|
||||
//(例如 http://127.0.0.1:8081/cache/...)的绝对 URL,导致浏览器跨域请求被 CORS 拦截。
|
||||
headers.set("x-forwarded-host", incomingUrl.host);
|
||||
headers.set("x-forwarded-proto", incomingUrl.protocol.replace(":", ""));
|
||||
if (incomingUrl.port) {
|
||||
headers.set("x-forwarded-port", incomingUrl.port);
|
||||
} else {
|
||||
headers.set("x-forwarded-port", incomingUrl.protocol === "https:" ? "443" : "80");
|
||||
}
|
||||
const runtimeCfg = getMnoteRuntimeConfig();
|
||||
const xfp = (request.headers.get("x-forwarded-proto") || "").split(",")[0].trim();
|
||||
const xfh = (request.headers.get("x-forwarded-host") || "").split(",")[0].trim();
|
||||
const xfpPort = (request.headers.get("x-forwarded-port") || "").split(",")[0].trim();
|
||||
|
||||
const originLike =
|
||||
request.headers.get("origin") ||
|
||||
request.headers.get("referer") ||
|
||||
"";
|
||||
const originUrl = (() => {
|
||||
try {
|
||||
if (!originLike) return null;
|
||||
return new URL(originLike);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const cloudflare = (() => {
|
||||
try {
|
||||
const raw = String(runtimeCfg.cloudflareAppOrigin || "").trim();
|
||||
if (!raw) return null;
|
||||
return new URL(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const inferredProto =
|
||||
xfp ||
|
||||
(originUrl ? originUrl.protocol.replace(":", "") : "") ||
|
||||
(cloudflare && cloudflare.host === incomingUrl.host ? cloudflare.protocol.replace(":", "") : "") ||
|
||||
incomingUrl.protocol.replace(":", "");
|
||||
const inferredHost =
|
||||
xfh ||
|
||||
(originUrl ? originUrl.host : "") ||
|
||||
incomingUrl.host;
|
||||
const inferredPort =
|
||||
xfpPort ||
|
||||
(originUrl ? originUrl.port : "") ||
|
||||
(cloudflare && cloudflare.host === incomingUrl.host ? cloudflare.port : "") ||
|
||||
incomingUrl.port ||
|
||||
(inferredProto === "https" ? "443" : "80");
|
||||
|
||||
const proto = inferredProto || "http";
|
||||
const host = inferredHost || incomingUrl.host;
|
||||
const port = inferredPort || (proto === "https" ? "443" : "80");
|
||||
|
||||
headers.set("x-forwarded-host", host);
|
||||
headers.set("x-forwarded-proto", proto);
|
||||
headers.set("x-forwarded-port", port);
|
||||
headers.set("x-forwarded-prefix", "/onlyoffice-server");
|
||||
// 说明:避免上游返回 gzip 后被 Node fetch 自动解压,但仍带着 content-encoding,
|
||||
// 导致浏览器二次解压报 ERR_CONTENT_DECODING_FAILED。
|
||||
|
||||
@@ -150,7 +150,14 @@ const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUr
|
||||
return `${window.location.origin.replace(/\/+$/, "")}${normalizedBase}`;
|
||||
})();
|
||||
|
||||
const internalOrigins = new Set<string>(["http://127.0.0.1:8081", "http://localhost:8081"]);
|
||||
const internalOrigins = new Set<string>([
|
||||
"http://127.0.0.1:8081",
|
||||
"http://localhost:8081",
|
||||
// 说明:部分环境下 ONLYOFFICE 会错误拼出 https://127.0.0.1:8081 这类 URL,
|
||||
// 浏览器会报 ERR_SSL_PROTOCOL_ERROR(因为 8081 实际是 http)。这里也一起兜底重写。
|
||||
"https://127.0.0.1:8081",
|
||||
"https://localhost:8081",
|
||||
]);
|
||||
try {
|
||||
if (onlyofficeBaseUrlDesktop) {
|
||||
const u = new URL(onlyofficeBaseUrlDesktop);
|
||||
@@ -166,6 +173,19 @@ const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUr
|
||||
const rewriteUrl = (input: string) => {
|
||||
try {
|
||||
const u = new (win as any).URL(input, (win as any).location?.origin || window.location.origin);
|
||||
// 说明:外网 https 访问时,若 ONLYOFFICE 错误生成 http://<host>/onlyoffice-server/... 会被 Mixed Content 阻止;
|
||||
// 这里提前升级为 https,避免浏览器直接拦截请求。
|
||||
try {
|
||||
const loc = (win as any).location;
|
||||
if (loc?.protocol === "https:" && u.protocol === "http:" && u.host === loc.host) {
|
||||
if (u.pathname === "/onlyoffice-server" || u.pathname.startsWith("/onlyoffice-server/")) {
|
||||
u.protocol = "https:";
|
||||
return u.toString();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const origin = `${u.protocol}//${u.host}`;
|
||||
if (!internalOrigins.has(origin)) return input;
|
||||
return `${proxyPrefix}${u.pathname}${u.search}${u.hash}`;
|
||||
|
||||
Reference in New Issue
Block a user