0.3.3 外网下载修复
This commit is contained in:
@@ -1,34 +1,34 @@
|
||||
# Repository Guidelines
|
||||
# 仓库协作指南(AGENTS)
|
||||
|
||||
## 关键事实(防止误判)
|
||||
- **当前已使用 Convex 完全替换 Supabase**:任何新功能/修复都应以 Convex 为唯一数据源与鉴权/权限基础。
|
||||
- 仓库内可能仍存在 `supabase/`、`supabase.md`、`wolai-frontend/src/lib/supabase/`、`@supabase/*` 依赖等**历史遗留**:除非任务明确要求清理,否则不要基于它们继续扩展实现。
|
||||
|
||||
## 项目结构与模块组织
|
||||
- `wolai-frontend/`:主前端(Next.js),源码在 `wolai-frontend/src/`,静态资源在 `wolai-frontend/public/`。
|
||||
- `wolai-backend/`:后端(FastAPI + Celery),源码在 `wolai-backend/app/`。
|
||||
- `wolai-frontend/convex/`:Convex functions(schema / query / mutation / action 等)。
|
||||
- `infra/convex/`:Convex 自托管(Docker Compose + README + 本地 `.env`)。
|
||||
- `wolai-backend/`:后端(FastAPI + Celery),主要用于辅助能力(如 OCR、OnlyOffice/集成类服务等)。
|
||||
- `services/`:配套服务与集成点(如 `services/mineru/` OCR、RAG 相关服务等)。
|
||||
- `supabase/`:本地 Supabase 迁移与配置(参考 `supabase.md` 的本地端口说明)。
|
||||
- `pw-tests/`:端到端测试(Playwright + Python),脚本在 `pw-tests/scripts/`,产物在 `pw-tests/artifacts/`。
|
||||
- 根目录 `src/`:Next.js 相关代码与共享模块(如 `src/app/`、`src/components/` 等)。
|
||||
- 根目录 `src/`:Next.js 相关代码与共享模块(可能为历史/镜像目录;优先改 `wolai-frontend/src/`)。
|
||||
|
||||
## 构建、测试与本地开发命令
|
||||
- 一键热启动(推荐):在仓库根目录执行 `npm run desktop:hot`(启动 `wolai-frontend` + `wolai-backend`;Redis 可用时自动启动 Celery)。
|
||||
- 前端:`cd wolai-frontend && pnpm dev|build|start`;质量检查:`pnpm lint`;单测:`pnpm test`。
|
||||
- 后端:`cd wolai-backend && pip install -r requirements.txt`;运行:`uvicorn app.main:app --reload --port 8000`;Worker:`celery -A app.workers.celery_app worker --loglevel=info`。
|
||||
- E2E:`cd pw-tests/scripts && python e2e_mindmap_sync.py`(更多脚本见 `pw-tests/README.md`)。
|
||||
- Convex(自托管)启动:见 `infra/convex/README.md`。
|
||||
|
||||
## 编码风格与命名约定
|
||||
- 文件编码:统一使用 UTF-8;缩进建议:TS/TSX 2 空格,Python 4 空格。
|
||||
- 命名示例:组件 `PascalCase.tsx`,hooks `useXxx.ts`,测试文件 `*.test.ts(x)`(Vitest 配置包含 `src/**/*.test.ts(x)`)。
|
||||
- 优先遵循现有 ESLint/Vitest 配置(见 `eslint.config.mjs`、`vitest.config.ts`)。
|
||||
|
||||
## 提交与 Pull Request 规范
|
||||
- 提交信息在历史中常见两类:版本号摘要(如 `0.1.13 ...`)与类 Conventional Commits(如 `feat(mindmap): ...` / `fix(mindmap): ...` / `chore: ...`)。新增提交建议延续该风格。
|
||||
- PR 需要:变更说明(动机/影响范围)、必要的 UI 截图、可复现步骤或测试结果、关联的 issue/任务链接。
|
||||
|
||||
## 安全与配置提示
|
||||
- 不要提交密钥/Token/账号;本地配置优先放在 `.env.local`、`wolai-frontend/.env.local`、`wolai-backend/.env`。
|
||||
- 如发现敏感信息已进入仓库,请立即轮换密钥,并与维护者确认是否需要清理历史记录。
|
||||
|
||||
## Agent/自动化协作注意
|
||||
- 仅修改与目标相关的文件;不要擅自回滚、覆盖或丢弃他人改动;代码注释使用简体中文并保持 UTF-8。
|
||||
## 协作边界(重要)
|
||||
- 仅修改与目标相关的文件;遇到与任务无关的改动保持不动;若认为会影响任务,先与维护者确认。
|
||||
- 严禁自行还原、覆盖或丢弃他人/用户已有改动(包括未提交文件)。如需清理或回滚,必须先得到明确同意。
|
||||
- 不需要进行任何 git 操作(由维护者手动确认与提交)。
|
||||
|
||||
## 代码索引
|
||||
- 更细的前后端代码层级索引见 `CODE_INDEX.md`(按路由/API/模块/职责整理,便于后续快速定位与开发)。
|
||||
- 更细的前后端代码层级索引见 `CODE_INDEX.md`(按路由/API/模块/职责整理)。
|
||||
|
||||
@@ -1,167 +1,124 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
本文件用于指导 Claude Code(claude.ai/code)在此仓库中进行开发与协作。
|
||||
|
||||
## Project Overview
|
||||
## 重要(防止误判)
|
||||
- **当前已使用 Convex 完全替换 Supabase**:数据读写、鉴权与权限控制以 Convex 为主线。
|
||||
- 仓库内若仍存在 `supabase/`、`supabase.md`、`wolai-frontend/src/lib/supabase/`、`@supabase/*` 依赖或相关代码,均应视为**历史遗留/兼容残留**;除非任务明确要求,否则不要基于它们继续扩展实现。
|
||||
|
||||
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.
|
||||
## 项目概览
|
||||
MNOTE 是一个知识管理系统,结合类 Notion 的块编辑器、思维导图与 AI 工具,支持 Web/桌面(Electron)混合形态。
|
||||
|
||||
**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
|
||||
**技术栈(核心)**
|
||||
- 前端:Next.js(App Router)、React、BlockNote
|
||||
- 数据层:Convex(自托管见 `infra/convex/`;functions 见 `wolai-frontend/convex/`)
|
||||
- 后端(辅助能力):FastAPI + Celery(OCR / OnlyOffice / 集成服务等)
|
||||
- 桌面端:Electron(内嵌 Next.js standalone)
|
||||
- AI:工具型 Agent 系统(30+ 内置工具)
|
||||
|
||||
## 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
|
||||
npm run desktop:hot # 启动前端 + 后端(Redis 可用时启 Celery)
|
||||
npm run desktop:local # 本地离线模式
|
||||
npm run desktop # 桌面端生产模式
|
||||
```
|
||||
|
||||
### Frontend Only (wolai-frontend/)
|
||||
### 仅前端(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
|
||||
pnpm dev
|
||||
pnpm build
|
||||
pnpm start
|
||||
pnpm lint
|
||||
pnpm test
|
||||
```
|
||||
|
||||
### Backend Only (wolai-backend/)
|
||||
### 仅后端(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
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
celery -A app.workers.celery_app worker --loglevel=info
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
### Convex(自托管)
|
||||
按 `infra/convex/README.md` 启动(Docker Compose)。
|
||||
|
||||
### Testing
|
||||
### E2E 测试
|
||||
```bash
|
||||
cd pw-tests/scripts
|
||||
python e2e_mindmap_sync.py # Run E2E test
|
||||
python e2e_mindmap_sync.py
|
||||
```
|
||||
|
||||
## 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
|
||||
wolai-frontend/ # 主前端(优先在这里改)
|
||||
src/ # Next.js 源码
|
||||
convex/ # Convex functions(schema/query/mutation/action)
|
||||
public/ # 静态资源 + mnote-env.json 等
|
||||
infra/convex/ # Convex 自托管(compose + README)
|
||||
wolai-backend/ # FastAPI 后端(辅助能力)
|
||||
services/ # 配套服务(OCR/RAG 等)
|
||||
pw-tests/ # 端到端测试
|
||||
desktop-electron/ # Electron 包装
|
||||
supabase/ # 历史遗留(不要当作现行架构)
|
||||
```
|
||||
|
||||
**WARNING:** Root `src/` directory may be legacy/mirror. Always work in `wolai-frontend/src/` for frontend changes.
|
||||
**注意:**仓库根目录的 `src/` 可能是历史/镜像目录;前端改动优先在 `wolai-frontend/src/`。
|
||||
|
||||
### Key Patterns
|
||||
### 关键模式
|
||||
- **BFF(Backend for Frontend)**:Next.js Route Handlers 负责鉴权与调用 Convex(query/mutation/action),前端页面通过这些 API/或 Convex provider 获取数据。
|
||||
- **桌面端与 Web 共用代码**:桌面端通过运行期注入配置(`window.__MNOTE_RUNTIME_CONFIG__`)实现同一套前端在不同网络/环境下运行。
|
||||
- **思维导图是一等公民**:以结构化 JSON 存储,支持节点引用(附件、URL + 页码)、AI 扩写等。
|
||||
|
||||
**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:**
|
||||
### 前端(wolai-frontend/.env.local)
|
||||
至少需要:
|
||||
```bash
|
||||
NEXT_PUBLIC_SUPABASE_URL=...
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
|
||||
NEXT_PUBLIC_BACKEND_URL=...
|
||||
NEXT_PUBLIC_ONLYOFFICE_BASE_URL=...
|
||||
NEXT_PUBLIC_CONVEX_URL=... # 浏览器侧 Convex URL(云端或自托管)
|
||||
NEXT_PUBLIC_USE_CONVEX=1 # 开启 Convex 模式(或 USE_CONVEX=1)
|
||||
NEXT_PUBLIC_BACKEND_URL=... # FastAPI(如启用 OCR/OnlyOffice 等)
|
||||
NEXT_PUBLIC_ONLYOFFICE_BASE_URL=... # OnlyOffice(如启用)
|
||||
```
|
||||
|
||||
**wolai-backend/.env:**
|
||||
### 服务端(仅在需要服务端直连/管理权限时)
|
||||
```bash
|
||||
SUPABASE_URL=...
|
||||
SUPABASE_SERVICE_ROLE_KEY=...
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
CONVEX_SELF_HOSTED_URL=...
|
||||
CONVEX_SELF_HOSTED_ADMIN_KEY=...
|
||||
```
|
||||
|
||||
**AI Configuration (ai.md or env):**
|
||||
```
|
||||
apikey: sk-xxx
|
||||
https://api.openai.com/v1
|
||||
gpt-4
|
||||
```
|
||||
### 桌面端运行期配置
|
||||
桌面端会读取/合并运行期配置(如 `public/mnote-env.json` 与桌面配置文件)。以 Convex 为主线配置即可;若看到 Supabase 字段,默认当作历史兼容残留处理。
|
||||
|
||||
**Desktop (auto-generated in data/electron/config.json):**
|
||||
```json
|
||||
{
|
||||
"networkMode": "remote-client|local|tunnel|auto",
|
||||
"supabaseUrl": "...",
|
||||
"backendUrl": "..."
|
||||
}
|
||||
```
|
||||
## 编码约定
|
||||
- 文件编码:UTF-8;代码注释允许中文(建议简体)。
|
||||
- 缩进:TS/TSX 2 空格,Python 4 空格。
|
||||
- 命名:组件 `PascalCase`,hooks `useXxx`,测试 `*.test.ts(x)`。
|
||||
- 导入:使用 `@/` 作为绝对路径别名。
|
||||
|
||||
## Coding Conventions
|
||||
## 关键文件(快速上手)
|
||||
- `wolai-frontend/src/app/layout.tsx`:根布局与运行期配置注入(Convex provider 挂载)。
|
||||
- `wolai-frontend/src/components/providers/convex-provider.tsx`:Convex React Client 初始化与 Provider。
|
||||
- `wolai-frontend/src/lib/convex/`:Convex client/server/route 封装。
|
||||
- `wolai-frontend/convex/`:Convex functions(数据模型与后端逻辑)。
|
||||
- `wolai-frontend/src/lib/ai-agent/runtime/runAgent.ts`:AI agent 引擎。
|
||||
- `wolai-frontend/src/lib/ai-agent/tools/builtins/registryBuiltins.ts`:内置工具注册表。
|
||||
- `scripts/desktop-hot.js`:开发编排脚本。
|
||||
- `AGENTS.md`:仓库协作指南(中文)。
|
||||
- `CODE_INDEX.md`:代码索引(中文)。
|
||||
|
||||
- **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:`)
|
||||
## 其他注意事项
|
||||
- 不要提交密钥/Token/账号;本地配置优先放在 `.env.local`、`wolai-frontend/.env.local`、`wolai-backend/.env`。
|
||||
- 路由目录含 `()` 和 `[]`(如 `(app)`、`[id]`),PowerShell 中操作建议用 `-LiteralPath` 避免通配符误匹配。
|
||||
|
||||
## Key Files for Understanding
|
||||
## AI Agent 工具开发
|
||||
新增 AI 能力时,在 `wolai-frontend/src/lib/ai-agent/tools/builtins/` 注册工具,按 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
|
||||
|
||||
- `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" }`
|
||||
权限模型:`{ read: "allow" | "confirm", write: "allow" | "confirm" }`
|
||||
|
||||
+51
-79
@@ -2,94 +2,66 @@
|
||||
|
||||
本文用于后续开发“快速定位入口/职责/调用链”,按目录与功能点整理。路径以仓库根目录为基准。
|
||||
|
||||
## 关键事实(防止误判)
|
||||
- **当前已使用 Convex 完全替换 Supabase**:数据读写、鉴权与权限控制以 Convex 为主线。
|
||||
- 仓库内若仍出现 `supabase/`、`supabase.md`、`wolai-frontend/src/lib/supabase/`、`wolai-backend/app/services/supabase_rest.py` 等,均应视为**历史遗留**(除非任务明确要求,否则不要继续扩展)。
|
||||
|
||||
## 启动链路与关键入口
|
||||
- 一键启动脚本:`scripts/desktop-hot.js`(启动 `wolai-frontend` + `wolai-backend`,Redis 可用时启动 Celery)。
|
||||
- 前端入口(Next App Router):
|
||||
- 根布局:`wolai-frontend/src/app/layout.tsx`(创建 Supabase Server Client,注入 `SupabaseProvider`/`QueryProvider`)。
|
||||
- 首页跳转:`wolai-frontend/src/app/page.tsx`(未登录跳 `/login`;确保默认工作区;跳转首个文档或创建“新页面”)。
|
||||
- 后端入口(FastAPI):`wolai-backend/app/main.py`(挂载 `root_router` 与 `/api/v1` 的 `api_router`)。
|
||||
- 一键热启动脚本:`scripts/desktop-hot.js`(启动 `wolai-frontend` + `wolai-backend`;Redis 可用时启动 Celery)。
|
||||
- 前端入口(Next.js App Router):`wolai-frontend/src/app/layout.tsx`(注入 runtime config;挂载 Convex provider)。
|
||||
- 运行期配置:`wolai-frontend/src/lib/runtime-config.ts`(读取 env / public 配置并注入到 `window.__MNOTE_RUNTIME_CONFIG__`)。
|
||||
- Convex 自托管(基础设施):`infra/convex/docker-compose.yml`、`infra/convex/README.md`。
|
||||
- Convex functions(后端逻辑/数据模型):`wolai-frontend/convex/`。
|
||||
- 后端入口(FastAPI):`wolai-backend/app/main.py`(辅助能力与集成服务,例如 OCR / OnlyOffice 等)。
|
||||
|
||||
## 前端(wolai-frontend)
|
||||
|
||||
### 页面路由(App Router)
|
||||
- 登录页:`wolai-frontend/src/app/(auth)/login/page.tsx`
|
||||
- 主应用布局:`wolai-frontend/src/app/(app)/layout.tsx`(组装侧边栏数据:workspaces、documents、trash、media、mindmap 资产等)。
|
||||
- 文档页:`wolai-frontend/src/app/(app)/documents/[id]/page.tsx`(读取 documents 表的标题/选项/统计,渲染 `DocumentShell`)
|
||||
- 思维导图全屏页:`wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/page.tsx`
|
||||
- 在线表格页:`wolai-frontend/src/app/tables/[tableId]/view/page.tsx`
|
||||
- OnlyOffice 页:`wolai-frontend/src/app/onlyoffice/page.tsx`
|
||||
- 调试页:
|
||||
- AI Agent:`wolai-frontend/src/app/dev/ai-agent/page.tsx`
|
||||
- Mindmap:`wolai-frontend/src/app/dev/mindmap/page.tsx`
|
||||
- 登录/鉴权:`wolai-frontend/src/app/(auth)/`(如 `wolai-frontend/src/app/(auth)/auth/page.tsx`)。
|
||||
- 主应用布局:`wolai-frontend/src/app/(app)/layout.tsx`。
|
||||
- 文档页:`wolai-frontend/src/app/(app)/documents/[id]/page.tsx`。
|
||||
- 思维导图全屏页:`wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/page.tsx`。
|
||||
- OnlyOffice:`wolai-frontend/src/app/onlyoffice/`。
|
||||
- 调试页:`wolai-frontend/src/app/dev/`(AI Agent / Mindmap 等)。
|
||||
|
||||
### API(Next Route Handlers)
|
||||
这些接口基本承担“鉴权 + 读写 Supabase + 返回给前端”的 BFF 职责:
|
||||
- 文档:`wolai-frontend/src/app/api/documents/*/route.ts`
|
||||
- content/title/options/save/create/duplicate/move/delete/restore/purge/stats/copy-tree/...
|
||||
- 思维导图:`wolai-frontend/src/app/api/mindmap/**/route.ts`
|
||||
- `api/mindmap/[docId]/route.ts`、`api/mindmap/[docId]/[mindmapId]/route.ts`、`api/mindmap/[docId]/[mindmapId]/ops/route.ts`
|
||||
- `api/mindmap/[id]/route.ts`(按 id 的路由分支)
|
||||
- 思维导图 AI:`wolai-frontend/src/app/api/mindmap-ai/*/route.ts`
|
||||
- `agent` / `assets` / `expand-node` / `outline-to-mindmap` / `test-pdf`
|
||||
- 媒体/附件:`wolai-frontend/src/app/api/media/*/route.ts`
|
||||
- upload/sign/signed-url/assets/batch/ocr/empty-trash/purge
|
||||
- AI Agent(统一入口):`wolai-frontend/src/app/api/ai-agent/run/route.ts`
|
||||
- 负责:加载在线/本地模型配置、构建 tool registry、按 scope 过滤工具、SSE 流式返回。
|
||||
- 后端探活转发:`wolai-frontend/src/app/api/backend/health/route.ts`
|
||||
- 其它:`wolai-frontend/src/app/api/search/*`、`wolai-frontend/src/app/api/sidebar/route.ts`、`wolai-frontend/src/app/api/workspaces/switch/route.ts` 等。
|
||||
### API(Next Route Handlers / BFF)
|
||||
这些接口承担“鉴权 + 调用 Convex(query/mutation/action)+ 返回前端”的 BFF 职责:
|
||||
- 文档相关:`wolai-frontend/src/app/api/documents/*/route.ts`。
|
||||
- 思维导图相关:`wolai-frontend/src/app/api/mindmap/**/route.ts`。
|
||||
- 思维导图 AI:`wolai-frontend/src/app/api/mindmap-ai/*/route.ts`。
|
||||
- 媒体/附件:`wolai-frontend/src/app/api/media/*/route.ts`。
|
||||
- AI Agent 统一入口:`wolai-frontend/src/app/api/ai-agent/run/route.ts`。
|
||||
- OnlyOffice 代理:`wolai-frontend/src/app/api/onlyoffice/proxy/route.ts`。
|
||||
|
||||
### Convex(前端接入层)
|
||||
- Convex React Provider:`wolai-frontend/src/components/providers/convex-provider.tsx`。
|
||||
- Convex API/Client 封装:`wolai-frontend/src/lib/convex/`(如 `api.ts`、`route.ts`、`server.ts`、`enabled.ts`)。
|
||||
- Sidebar(Convex 数据来源):`wolai-frontend/src/hooks/use-convex-sidebar-data.ts`、`wolai-frontend/src/components/sidebar/sidebar.tsx`。
|
||||
|
||||
### 编辑器与核心 UI
|
||||
- 文档壳(客户端动态加载编辑器):`wolai-frontend/src/components/editor/document-shell.tsx`
|
||||
- 文档内容(核心编辑/保存/选项/历史/AI 面板):`wolai-frontend/src/components/editor/document-content.tsx`
|
||||
- BlockNote 编辑器:`wolai-frontend/src/components/editor/blocknote-editor.tsx`
|
||||
- Block 级功能(重点在思维导图/媒体/表格):
|
||||
- 思维导图 Block:`wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx`
|
||||
- 思维导图侧栏/工具栏:`wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx`、`wolai-frontend/src/components/editor/blocks/MindmapToolbar.tsx`
|
||||
- AI Agent 面板:`wolai-frontend/src/components/editor/blocks/MindmapAiAgentPanel.tsx`、`wolai-frontend/src/components/editor/DocumentAiAgentPanel.tsx`
|
||||
- 侧边栏(文件树/回收站/资源):`wolai-frontend/src/components/sidebar/sidebar.tsx`、`wolai-frontend/src/components/sidebar/file-tree.tsx`
|
||||
- 文档壳:`wolai-frontend/src/components/editor/document-shell.tsx`。
|
||||
- 文档内容:`wolai-frontend/src/components/editor/document-content.tsx`。
|
||||
- BlockNote 编辑器:`wolai-frontend/src/components/editor/blocknote-editor.tsx`。
|
||||
- 思维导图 Block:`wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx`。
|
||||
- 侧边栏:`wolai-frontend/src/components/sidebar/`。
|
||||
|
||||
### 状态与业务库(lib/store)
|
||||
- Supabase 客户端封装:`wolai-frontend/src/lib/supabase/{client.ts,server.ts,admin.ts}`
|
||||
- 工作区与侧边栏数据:`wolai-frontend/src/lib/workspaces.ts`、`wolai-frontend/src/lib/sidebar-tree.ts`
|
||||
- 文件树算法与单测:`wolai-frontend/src/lib/file-tree/*`(`*.test.ts` 是 Vitest 单测入口之一)
|
||||
- 思维导图存储/操作:`wolai-frontend/src/lib/mindmap/*`、`wolai-frontend/src/lib/mindmap-files.ts`
|
||||
- AI 配置:`wolai-frontend/src/lib/ai/*`
|
||||
- AI Agent 引擎:
|
||||
- 运行时:`wolai-frontend/src/lib/ai-agent/runtime/runAgent.ts`
|
||||
- 工具注册:`wolai-frontend/src/lib/ai-agent/tools/registry.ts`
|
||||
- 内置工具集合:`wolai-frontend/src/lib/ai-agent/tools/builtins/registryBuiltins.ts` 与 `tools/builtins/*/*Tools.ts`
|
||||
- Zustand stores:`wolai-frontend/src/store/*`(如 `editor-bridge.ts`、`sidebar.ts`、`ai-agent-ui.ts`)
|
||||
### AI Agent(工具系统)
|
||||
- 运行时:`wolai-frontend/src/lib/ai-agent/runtime/runAgent.ts`。
|
||||
- 工具注册:`wolai-frontend/src/lib/ai-agent/tools/builtins/registryBuiltins.ts`。
|
||||
|
||||
### 状态管理
|
||||
- Zustand stores:`wolai-frontend/src/store/*`。
|
||||
|
||||
## 后端(wolai-backend)
|
||||
后端主要承担辅助能力与集成服务(如 OCR/异步任务/OnlyOffice 等),数据主链路以 Convex 为准:
|
||||
- FastAPI 入口:`wolai-backend/app/main.py`。
|
||||
- 路由聚合:`wolai-backend/app/routers/__init__.py`。
|
||||
- 健康检查:`wolai-backend/app/routers/health.py`。
|
||||
- OCR/任务相关:`wolai-backend/app/routers/tasks.py`、`wolai-backend/app/services/*`。
|
||||
- Celery:`wolai-backend/app/workers/*`。
|
||||
|
||||
### FastAPI 路由与鉴权
|
||||
- 路由聚合:`wolai-backend/app/routers/__init__.py`
|
||||
- `root_router`:`/health` + `ws`(Luckysheet)
|
||||
- `api_router`(前缀 `/api/v1`):tasks/chat
|
||||
- 健康检查:`wolai-backend/app/routers/health.py`(GET `/health`)
|
||||
- 任务(OCR):`wolai-backend/app/routers/tasks.py`
|
||||
- POST `/api/v1/tasks/ocr`(鉴权:`AuthDep`,创建 `background_tasks` 记录并投递 Celery)
|
||||
- GET `/api/v1/tasks/{task_id}`(查询任务状态)
|
||||
- SSE 占位对话:`wolai-backend/app/routers/chat.py`(GET `/api/v1/chat?query=...&document_id=...`)
|
||||
- Luckysheet 协同 WS:`wolai-backend/app/routers/luckysheet_ws.py`(`/ws/luckysheet`,校验 Supabase 用户与 workspace 成员关系)
|
||||
- 鉴权依赖:`wolai-backend/app/deps.py`(通过 Supabase `/auth/v1/user` 验证 Bearer token)
|
||||
|
||||
### Celery 与服务层
|
||||
- Celery 配置:`wolai-backend/app/workers/celery_app.py`
|
||||
- Celery 任务:`wolai-backend/app/workers/tasks.py`
|
||||
- `ocr_pipeline`:stage0 占位实现(更新 `background_tasks` 状态;回写 `documents.content/raw_text/index_status`)
|
||||
- Supabase REST 封装:`wolai-backend/app/services/supabase_rest.py`
|
||||
- 任务追踪:`wolai-backend/app/services/task_tracker.py`(写入/读取 `background_tasks`)
|
||||
- 占位集成点:
|
||||
- MinerU:`wolai-backend/app/services/mineru_service.py`
|
||||
- LightRAG:`wolai-backend/app/services/lightrag_service.py`
|
||||
- 配置:`wolai-backend/app/config.py`(`.env`,UTF-8)
|
||||
|
||||
## Supabase(本地与迁移)
|
||||
- 本地端口与 URL 说明:`supabase.md`
|
||||
- 迁移目录:`supabase/migrations/`
|
||||
|
||||
## 备注(避免踩坑)
|
||||
- `wolai-frontend` 才是 `desktop-hot` 启动的实际前端;根目录的 `src/` 与其结构相似,可能是历史/镜像目录,改动前建议先确认是否仍在使用。
|
||||
- 路由目录包含括号与中括号(如 `(app)`、`[id]`),在 PowerShell 中操作建议用 `-LiteralPath` 避免通配符误匹配。
|
||||
|
||||
## 历史遗留(不要当作现行架构)
|
||||
若你看到以下路径/模块,请默认它们不再是当前主线(除非任务明确要求处理):
|
||||
- `supabase/`、`supabase.md`
|
||||
- `wolai-frontend/src/lib/supabase/`
|
||||
- `wolai-backend/app/services/supabase_rest.py`
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// 说明:用于本地快速验证 Wolai ZIP 导入流程(Playwright)。
|
||||
// 注意:仅用于开发/测试;请勿在生产环境使用。
|
||||
|
||||
const { chromium } = require("@playwright/test");
|
||||
|
||||
const BASE_URL = process.env.MNOTE_TEST_BASE_URL || "http://127.0.0.1:3000";
|
||||
const ZIP_PATH = process.env.MNOTE_TEST_ZIP_PATH || "C:\\\\Users\\\\liaib\\\\Downloads\\\\软件开发.zip";
|
||||
const ROOT_MD_PATH = process.env.MNOTE_TEST_ROOT_MD_PATH || "ChB6p4/软件开发.md";
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
page.setDefaultTimeout(60_000);
|
||||
|
||||
// 1) 打开 /auth(若已登录会跳转到 /)
|
||||
await page.goto(`${BASE_URL}/auth`, { waitUntil: "networkidle" });
|
||||
|
||||
// 2) 如果还没登录,点“测试账号快速登录”
|
||||
const url1 = page.url();
|
||||
if (url1.includes("/auth")) {
|
||||
const quickLogin = page.getByRole("button", { name: "测试账号快速登录" });
|
||||
if (await quickLogin.isVisible().catch(() => false)) {
|
||||
await quickLogin.click();
|
||||
} else {
|
||||
// 兜底:尝试手动点击“登录”按钮(如果用户已填写)
|
||||
const submit = page.getByRole("button", { name: "登录" });
|
||||
if (await submit.isVisible().catch(() => false)) await submit.click();
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 等跳转到首页(或至少不在 /auth)
|
||||
await page.waitForURL((u) => !u.toString().includes("/auth"), { timeout: 120_000 });
|
||||
|
||||
// 4) 打开导入页
|
||||
await page.goto(`${BASE_URL}/wolai-import`, { waitUntil: "networkidle" });
|
||||
|
||||
// 5) 填本地路径(大文件不要上传)
|
||||
const zipInput = page.locator('input[placeholder*="个人.zip"]').first();
|
||||
await zipInput.fill(ZIP_PATH);
|
||||
const rootInput = page.locator('input[placeholder*="dQeAax/个人.md"]').first();
|
||||
await rootInput.fill(ROOT_MD_PATH);
|
||||
|
||||
// 6) 开始导入
|
||||
const respPromise = page.waitForResponse(
|
||||
(r) => r.url().includes("/api/wolai-import") && r.request().method() === "POST",
|
||||
{ timeout: 20 * 60_000 },
|
||||
);
|
||||
await page.getByRole("button", { name: "开始导入" }).click();
|
||||
|
||||
const resp = await respPromise;
|
||||
const status = resp.status();
|
||||
const text = await resp.text().catch(() => "");
|
||||
console.log("导入接口返回:", status);
|
||||
if (text) {
|
||||
console.log("响应体:", text.slice(0, 4000));
|
||||
}
|
||||
|
||||
if (status >= 200 && status < 300) {
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const rootDocumentId = json && typeof json.rootDocumentId === "string" ? json.rootDocumentId : "";
|
||||
if (rootDocumentId) {
|
||||
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(rootDocumentId)}`, { waitUntil: "networkidle" });
|
||||
console.log("导入成功,已打开:", page.url());
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("导入测试失败:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -40,7 +40,6 @@ import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/
|
||||
import { useSidebarData } from "@/hooks/use-sidebar-data";
|
||||
import { PrivateTree } from "@/components/sidebar/private-tree";
|
||||
import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar-tree";
|
||||
import { supabaseBrowser } from "@/lib/supabase/client";
|
||||
import { useSearchPaletteStore } from "@/store/search-palette";
|
||||
import { FileTree } from "@/components/sidebar/file-tree";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
@@ -112,22 +111,6 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
await sidebarQuery.refetch();
|
||||
}, [sidebarQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const channel = supabaseBrowser
|
||||
.channel("documents-feed")
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{ event: "*", schema: "public", table: "documents" },
|
||||
() => {
|
||||
void refreshTree();
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
return () => {
|
||||
supabaseBrowser.removeChannel(channel);
|
||||
};
|
||||
}, [refreshTree]);
|
||||
|
||||
const sections = useMemo(() => buildSidebarSectionsFromTree(tree), [tree]);
|
||||
const starredNodes = useMemo(() => sections.find((section) => section.id === "starred")?.nodes ?? [], [sections]);
|
||||
const publicNodes = useMemo(() => sections.find((section) => section.id === "public")?.nodes ?? [], [sections]);
|
||||
|
||||
+25
-2
@@ -16,13 +16,36 @@ export interface DocumentNode extends DocumentRecord {
|
||||
}
|
||||
|
||||
export function buildDocumentTree(records: DocumentRecord[]): DocumentNode[] {
|
||||
// 防御性处理:当上游数据意外包含重复 id 时,避免生成重复节点导致渲染 key 冲突。
|
||||
// 以“最后一次出现”为准(与原先 nodeMap.set 的覆盖行为保持一致)。
|
||||
const seen = new Set<string>();
|
||||
const duplicatedIds = new Set<string>();
|
||||
const uniqueRecords: DocumentRecord[] = [];
|
||||
for (let i = records.length - 1; i >= 0; i--) {
|
||||
const record = records[i];
|
||||
if (seen.has(record.id)) {
|
||||
duplicatedIds.add(record.id);
|
||||
continue;
|
||||
}
|
||||
seen.add(record.id);
|
||||
uniqueRecords.push(record);
|
||||
}
|
||||
uniqueRecords.reverse();
|
||||
|
||||
if (duplicatedIds.size > 0 && process.env.NODE_ENV !== "production") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[buildDocumentTree] 检测到重复文档 id(已自动去重):${Array.from(duplicatedIds).slice(0, 10).join(", ")}${duplicatedIds.size > 10 ? "…" : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
const nodeMap = new Map<string, DocumentNode>();
|
||||
records.forEach((record) => {
|
||||
uniqueRecords.forEach((record) => {
|
||||
nodeMap.set(record.id, { ...record, children: [] });
|
||||
});
|
||||
|
||||
const roots: DocumentNode[] = [];
|
||||
records.forEach((record) => {
|
||||
uniqueRecords.forEach((record) => {
|
||||
const node = nodeMap.get(record.id);
|
||||
if (!node) return;
|
||||
if (record.parent_id && nodeMap.has(record.parent_id)) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable */
|
||||
|
||||
/**
|
||||
* Generated `api` utility.
|
||||
*
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable */
|
||||
|
||||
/**
|
||||
* Generated data model types.
|
||||
*
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable */
|
||||
|
||||
/**
|
||||
* Generated utilities for implementing server-side Convex query and mutation functions.
|
||||
*
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable */
|
||||
|
||||
/**
|
||||
* Generated utilities for implementing server-side Convex query and mutation functions.
|
||||
*
|
||||
|
||||
@@ -328,7 +328,7 @@ export const move = mutation({
|
||||
if (movedId && item._id === movedId) patch.updated_at = ts;
|
||||
|
||||
if (Object.keys(patch).length > 0) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
|
||||
await ctx.db.patch(item._id, patch);
|
||||
}
|
||||
}
|
||||
|
||||
+120
-120
@@ -13,7 +13,7 @@ const {
|
||||
}
|
||||
} = require('../utils/utils');
|
||||
|
||||
/* eslint-disable quote-props */
|
||||
|
||||
const SpecialValues = {
|
||||
true: true,
|
||||
false: false,
|
||||
@@ -258,7 +258,7 @@ module.exports = Anchor;
|
||||
},{"../utils/col-cache":19}],3:[function(require,module,exports){
|
||||
"use strict";
|
||||
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
const colCache = require('../utils/col-cache');
|
||||
const _ = require('../utils/under-dash');
|
||||
const Enums = require('./enums');
|
||||
@@ -2127,7 +2127,7 @@ class Row {
|
||||
// Inform Streaming Writer that this row (and all rows before it) are complete
|
||||
// and ready to write. Has no effect on Worksheet document
|
||||
commit() {
|
||||
this._worksheet._commitRow(this); // eslint-disable-line no-underscore-dangle
|
||||
this._worksheet._commitRow(this);
|
||||
}
|
||||
|
||||
// helps GC by breaking cyclic references
|
||||
@@ -2189,12 +2189,12 @@ class Row {
|
||||
cDst = this.getCell(i);
|
||||
cDst.value = cSrc.value;
|
||||
cDst.style = cSrc.style;
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
|
||||
cDst._comment = cSrc._comment;
|
||||
} else if (cDst) {
|
||||
cDst.value = null;
|
||||
cDst.style = {};
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
|
||||
cDst._comment = undefined;
|
||||
}
|
||||
}
|
||||
@@ -2206,7 +2206,7 @@ class Row {
|
||||
cDst = this.getCell(i + nExpand);
|
||||
cDst.value = cSrc.value;
|
||||
cDst.style = cSrc.style;
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
|
||||
cDst._comment = cSrc._comment;
|
||||
} else {
|
||||
this._cells[i + nExpand - 1] = undefined;
|
||||
@@ -2219,7 +2219,7 @@ class Row {
|
||||
cDst = this.getCell(start + i);
|
||||
cDst.value = inserts[i];
|
||||
cDst.style = {};
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
|
||||
cDst._comment = undefined;
|
||||
}
|
||||
}
|
||||
@@ -2486,7 +2486,7 @@ module.exports = Row;
|
||||
},{"../utils/col-cache":19,"../utils/under-dash":26,"./cell":3,"./enums":7}],12:[function(require,module,exports){
|
||||
"use strict";
|
||||
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
const colCache = require('../utils/col-cache');
|
||||
class Column {
|
||||
// wrapper around column model, allowing access and manipulation
|
||||
@@ -2500,7 +2500,7 @@ class Column {
|
||||
this.column[name] = value;
|
||||
}
|
||||
|
||||
/* eslint-disable lines-between-class-members */
|
||||
|
||||
get name() {
|
||||
return this.column.name;
|
||||
}
|
||||
@@ -2543,7 +2543,7 @@ class Column {
|
||||
set totalsRowFormula(value) {
|
||||
this._set('totalsRowFormula', value);
|
||||
}
|
||||
/* eslint-enable lines-between-class-members */
|
||||
|
||||
}
|
||||
|
||||
class Table {
|
||||
@@ -2861,7 +2861,7 @@ class Table {
|
||||
target[prop] = value;
|
||||
}
|
||||
|
||||
/* eslint-disable lines-between-class-members */
|
||||
|
||||
get ref() {
|
||||
return this.table.ref;
|
||||
}
|
||||
@@ -2922,7 +2922,7 @@ class Table {
|
||||
set showColumnStripes(value) {
|
||||
this.table.style.showColumnStripes = value;
|
||||
}
|
||||
/* eslint-enable lines-between-class-members */
|
||||
|
||||
}
|
||||
|
||||
module.exports = Table;
|
||||
@@ -2982,7 +2982,7 @@ class Workbook {
|
||||
// if options is a color, call it tabColor (and signal deprecated message)
|
||||
if (options) {
|
||||
if (typeof options === 'string') {
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.trace('tabColor argument is now deprecated. Please use workbook.addWorksheet(name, {properties: { tabColor: { argb: "rbg value" } }');
|
||||
options = {
|
||||
properties: {
|
||||
@@ -2992,7 +2992,7 @@ class Workbook {
|
||||
}
|
||||
};
|
||||
} else if (options.argb || options.theme || options.indexed) {
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.trace('tabColor argument is now deprecated. Please use workbook.addWorksheet(name, {properties: { tabColor: { ... } }');
|
||||
options = {
|
||||
properties: {
|
||||
@@ -3276,7 +3276,7 @@ class Worksheet {
|
||||
throw new Error(`The first or last character of worksheet name cannot be a single quotation mark: ${name}`);
|
||||
}
|
||||
if (name && name.length > 31) {
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.warn(`Worksheet name ${name} exceeds 31 chars. This will be truncated`);
|
||||
name = name.substring(0, 31);
|
||||
}
|
||||
@@ -3378,12 +3378,12 @@ class Worksheet {
|
||||
// must iterate over all rows whether they exist yet or not
|
||||
for (let i = 0; i < nRows; i++) {
|
||||
const rowArguments = [start, count];
|
||||
// eslint-disable-next-line no-loop-func
|
||||
|
||||
inserts.forEach(insert => {
|
||||
rowArguments.push(insert[i] || null);
|
||||
});
|
||||
const row = this.getRow(i + 1);
|
||||
// eslint-disable-next-line prefer-spread
|
||||
|
||||
row.splice.apply(row, rowArguments);
|
||||
}
|
||||
} else {
|
||||
@@ -3559,7 +3559,7 @@ class Worksheet {
|
||||
const rSrc = this.getRow(src);
|
||||
const rDst = this.getRow(dest);
|
||||
rDst.style = copyStyle(rSrc.style);
|
||||
// eslint-disable-next-line no-loop-func
|
||||
|
||||
rSrc.eachCell({
|
||||
includeEmpty: styleEmpty
|
||||
}, (cell, colNumber) => {
|
||||
@@ -3581,7 +3581,7 @@ class Worksheet {
|
||||
const rDst = this._rows[rowNum + i];
|
||||
rDst.style = rSrc.style;
|
||||
rDst.height = rSrc.height;
|
||||
// eslint-disable-next-line no-loop-func
|
||||
|
||||
rSrc.eachCell({
|
||||
includeEmpty: true
|
||||
}, (cell, colNumber) => {
|
||||
@@ -3612,7 +3612,7 @@ class Worksheet {
|
||||
rDst.values = rSrc.values;
|
||||
rDst.style = rSrc.style;
|
||||
rDst.height = rSrc.height;
|
||||
// eslint-disable-next-line no-loop-func
|
||||
|
||||
rSrc.eachCell({
|
||||
includeEmpty: true
|
||||
}, (cell, colNumber) => {
|
||||
@@ -3632,7 +3632,7 @@ class Worksheet {
|
||||
rDst.values = rSrc.values;
|
||||
rDst.style = rSrc.style;
|
||||
rDst.height = rSrc.height;
|
||||
// eslint-disable-next-line no-loop-func
|
||||
|
||||
rSrc.eachCell({
|
||||
includeEmpty: true
|
||||
}, (cell, colNumber) => {
|
||||
@@ -3818,7 +3818,7 @@ class Worksheet {
|
||||
if (Array.isArray(results[0])) {
|
||||
getResult = (row, col) => results[row - top][col - left];
|
||||
} else {
|
||||
// eslint-disable-next-line no-mixed-operators
|
||||
|
||||
getResult = (row, col) => results[(row - top) * width + (col - left)];
|
||||
}
|
||||
} else {
|
||||
@@ -3937,12 +3937,12 @@ class Worksheet {
|
||||
// ===========================================================================
|
||||
// Deprecated
|
||||
get tabColor() {
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.trace('worksheet.tabColor property is now deprecated. Please use worksheet.properties.tabColor');
|
||||
return this.properties.tabColor;
|
||||
}
|
||||
set tabColor(value) {
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.trace('worksheet.tabColor property is now deprecated. Please use worksheet.properties.tabColor');
|
||||
this.properties.tabColor = value;
|
||||
}
|
||||
@@ -4034,7 +4034,7 @@ module.exports = Worksheet;
|
||||
},{"../utils/col-cache":19,"../utils/copy-style":20,"../utils/encryptor":21,"../utils/under-dash":26,"./column":4,"./data-validations":5,"./enums":7,"./image":8,"./range":10,"./row":11,"./table":12}],15:[function(require,module,exports){
|
||||
"use strict";
|
||||
|
||||
/* eslint-disable import/no-extraneous-dependencies,node/no-unpublished-require */
|
||||
/* eslint-disable node/no-unpublished-require */
|
||||
require('core-js/modules/es.promise');
|
||||
require('core-js/modules/es.promise.finally');
|
||||
require('core-js/modules/es.object.assign');
|
||||
@@ -4713,7 +4713,7 @@ module.exports = {
|
||||
(function (process,Buffer){(function (){
|
||||
"use strict";
|
||||
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
const Stream = require('readable-stream');
|
||||
const utils = require('./utils');
|
||||
const StringBuf = require('./string-buf');
|
||||
@@ -4750,7 +4750,7 @@ class StringBufChunk {
|
||||
|
||||
// copy to target buffer
|
||||
copy(target, targetOffset, offset, length) {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
|
||||
return this._data._buf.copy(target, targetOffset, offset, length);
|
||||
}
|
||||
toBuffer() {
|
||||
@@ -5123,7 +5123,7 @@ class StringBuf {
|
||||
if (this.length + inBuf.length > this.capacity) {
|
||||
this._grow(this.length + inBuf.length);
|
||||
}
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
|
||||
inBuf._buf.copy(this._buf, this._inPos, 0, inBuf.length);
|
||||
this._inPos += inBuf.length;
|
||||
}
|
||||
@@ -5277,7 +5277,7 @@ const _ = {
|
||||
const {
|
||||
length
|
||||
} = arguments;
|
||||
// eslint-disable-next-line one-var
|
||||
|
||||
let src, clone, copyIsArray;
|
||||
function assignValue(val, key) {
|
||||
src = target[key];
|
||||
@@ -5310,7 +5310,7 @@ const fs = require('fs');
|
||||
|
||||
// useful stuff
|
||||
const inherits = function (cls, superCtor, statics, prototype) {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
|
||||
cls.super_ = superCtor;
|
||||
if (!prototype) {
|
||||
prototype = statics;
|
||||
@@ -5337,7 +5337,7 @@ const inherits = function (cls, superCtor, statics, prototype) {
|
||||
cls.prototype = Object.create(superCtor.prototype, properties);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
|
||||
const xmlDecodeRegex = /[<>&'"\x7F\x00-\x08\x0B-\x0C\x0E-\x1F]/;
|
||||
const utils = {
|
||||
nop() {},
|
||||
@@ -5969,7 +5969,7 @@ const parseSax = require('../../utils/parse-sax');
|
||||
const XmlStream = require('../../utils/xml-stream');
|
||||
|
||||
/* 'virtual' methods used as a form of documentation */
|
||||
/* eslint-disable class-methods-use-this */
|
||||
|
||||
|
||||
// Base class for Xforms
|
||||
class BaseXform {
|
||||
@@ -7232,7 +7232,7 @@ module.exports = VmlTextboxXform;
|
||||
const BaseXform = require('./base-xform');
|
||||
|
||||
/* 'virtual' methods used as a form of documentation */
|
||||
/* eslint-disable class-methods-use-this */
|
||||
|
||||
|
||||
// base class for xforms that are composed of other xforms
|
||||
// offers some default implementations
|
||||
@@ -10169,7 +10169,7 @@ module.exports = DatabarXform;
|
||||
},{"../../composite-xform":48,"../../style/color-xform":128,"./cfvo-xform":84}],89:[function(require,module,exports){
|
||||
"use strict";
|
||||
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
const BaseXform = require('../../base-xform');
|
||||
const CompositeXform = require('../../composite-xform');
|
||||
class X14IdXform extends BaseXform {
|
||||
@@ -10697,7 +10697,7 @@ module.exports = DrawingXform;
|
||||
},{"../base-xform":32}],96:[function(require,module,exports){
|
||||
"use strict";
|
||||
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
const CompositeXform = require('../composite-xform');
|
||||
const ConditionalFormattingsExt = require('./cf-ext/conditional-formattings-ext-xform');
|
||||
class ExtXform extends CompositeXform {
|
||||
@@ -13309,7 +13309,7 @@ module.exports = AlignmentXform;
|
||||
},{"../../../doc/enums":7,"../../../utils/utils":27,"../base-xform":32}],127:[function(require,module,exports){
|
||||
"use strict";
|
||||
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
const BaseXform = require('../base-xform');
|
||||
const utils = require('../../../utils/utils');
|
||||
const ColorXform = require('./color-xform');
|
||||
@@ -13669,7 +13669,7 @@ module.exports = DxfXform;
|
||||
},{"../base-xform":32,"./alignment-xform":126,"./border-xform":127,"./fill-xform":130,"./font-xform":131,"./numfmt-xform":132,"./protection-xform":133}],130:[function(require,module,exports){
|
||||
"use strict";
|
||||
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
const BaseXform = require('../base-xform');
|
||||
const ColorXform = require('./color-xform');
|
||||
class StopXform extends BaseXform {
|
||||
@@ -14387,7 +14387,7 @@ module.exports = StyleXform;
|
||||
},{"../base-xform":32,"./alignment-xform":126,"./protection-xform":133}],135:[function(require,module,exports){
|
||||
"use strict";
|
||||
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
const Enums = require('../../../doc/enums');
|
||||
const XmlStream = require('../../../utils/xml-stream');
|
||||
const BaseXform = require('../base-xform');
|
||||
@@ -15739,7 +15739,7 @@ class XLSX {
|
||||
};
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
for (const entry of Object.values(zip.files)) {
|
||||
/* eslint-disable no-await-in-loop */
|
||||
|
||||
if (!entry.dir) {
|
||||
let entryName = entry.name;
|
||||
if (entryName[0] === '/') {
|
||||
@@ -16417,7 +16417,7 @@ class RowFormatter {
|
||||
}
|
||||
return Object.keys(row);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
|
||||
static createTransform(transformFunction) {
|
||||
if (types_1.isSyncTransform(transformFunction)) {
|
||||
return (row, cb) => {
|
||||
@@ -16670,7 +16670,7 @@ exports.writeToPath = (path, rows, options) => {
|
||||
},{"./CsvFormatterStream":146,"./FormatterOptions":147,"./types":152,"buffer":220,"fs":216,"stream":505,"util":527}],152:[function(require,module,exports){
|
||||
"use strict";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
Object.defineProperty(exports, "__esModule", {
|
||||
value: true
|
||||
});
|
||||
@@ -16723,7 +16723,7 @@ class CsvParserStream extends stream_1.Transform {
|
||||
this.rowTransformerValidator.rowValidator = validateFunction;
|
||||
return this;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
emit(event) {
|
||||
if (event === 'end') {
|
||||
if (!this.endEmitted) {
|
||||
@@ -16891,7 +16891,7 @@ class CsvParserStream extends stream_1.Transform {
|
||||
}
|
||||
static wrapDoneCallback(done) {
|
||||
let errorCalled = false;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
return function (err) {
|
||||
if (err) {
|
||||
if (errorCalled) {
|
||||
@@ -17726,7 +17726,7 @@ class HeaderTransformer {
|
||||
const header = headers[i];
|
||||
if (!lodash_isundefined_1.default(header)) {
|
||||
const val = row[i];
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
|
||||
if (lodash_isundefined_1.default(val)) {
|
||||
rowMap[header] = '';
|
||||
} else {
|
||||
@@ -17770,7 +17770,7 @@ class RowTransformerValidator {
|
||||
this._rowTransform = null;
|
||||
this._rowValidator = null;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
|
||||
static createTransform(transformFunction) {
|
||||
if (types_1.isSyncTransform(transformFunction)) {
|
||||
return (row, cb) => {
|
||||
@@ -27529,7 +27529,7 @@ module.exports = function xor(a, b) {
|
||||
* @author Feross Aboukhadijeh <https://feross.org>
|
||||
* @license MIT
|
||||
*/
|
||||
/* eslint-disable no-proto */
|
||||
|
||||
|
||||
'use strict';
|
||||
|
||||
@@ -27795,7 +27795,7 @@ function checked(length) {
|
||||
}
|
||||
function SlowBuffer(length) {
|
||||
if (+length != length) {
|
||||
// eslint-disable-line eqeqeq
|
||||
|
||||
length = 0;
|
||||
}
|
||||
return Buffer.alloc(+length);
|
||||
@@ -29015,7 +29015,7 @@ function isInstance(obj, type) {
|
||||
}
|
||||
function numberIsNaN(obj) {
|
||||
// For IE11 support
|
||||
return obj !== obj; // eslint-disable-line no-self-compare
|
||||
return obj !== obj;
|
||||
}
|
||||
|
||||
}).call(this)}).call(this,require("buffer").Buffer)
|
||||
@@ -29207,10 +29207,10 @@ var createMethod = function (IS_INCLUDES) {
|
||||
var index = toAbsoluteIndex(fromIndex, length);
|
||||
var value;
|
||||
// Array#includes uses SameValueZero equality algorithm
|
||||
// eslint-disable-next-line no-self-compare -- NaN check
|
||||
|
||||
if (IS_INCLUDES && el !== el) while (length > index) {
|
||||
value = O[index++];
|
||||
// eslint-disable-next-line no-self-compare -- NaN check
|
||||
|
||||
if (value !== value) return true;
|
||||
// Array#indexOf ignores holes, Array#includes - not
|
||||
} else for (;length > index; index++) {
|
||||
@@ -29385,7 +29385,7 @@ try {
|
||||
iteratorWithReturn[ITERATOR] = function () {
|
||||
return this;
|
||||
};
|
||||
// eslint-disable-next-line es/no-array-from, no-throw-literal -- required for testing
|
||||
// eslint-disable-next-line es/no-array-from -- required for testing
|
||||
Array.from(iteratorWithReturn, function () { throw 2; });
|
||||
} catch (error) { /* empty */ }
|
||||
|
||||
@@ -29825,7 +29825,7 @@ var fails = require('../internals/fails');
|
||||
module.exports = !fails(function () {
|
||||
// eslint-disable-next-line es/no-function-prototype-bind -- safe
|
||||
var test = (function () { /* empty */ }).bind();
|
||||
// eslint-disable-next-line no-prototype-builtins -- safe
|
||||
|
||||
return typeof test != 'function' || test.hasOwnProperty('prototype');
|
||||
});
|
||||
|
||||
@@ -29998,10 +29998,10 @@ module.exports =
|
||||
// eslint-disable-next-line es/no-global-this -- safe
|
||||
check(typeof globalThis == 'object' && globalThis) ||
|
||||
check(typeof window == 'object' && window) ||
|
||||
// eslint-disable-next-line no-restricted-globals -- safe
|
||||
|
||||
check(typeof self == 'object' && self) ||
|
||||
check(typeof global == 'object' && global) ||
|
||||
// eslint-disable-next-line no-new-func -- fallback
|
||||
|
||||
(function () { return this; })() || this || Function('return this')();
|
||||
|
||||
}).call(this)}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {})
|
||||
@@ -30028,7 +30028,7 @@ module.exports = {};
|
||||
'use strict';
|
||||
module.exports = function (a, b) {
|
||||
try {
|
||||
// eslint-disable-next-line no-console -- safe
|
||||
|
||||
arguments.length === 1 ? console.error(a) : console.error(a, b);
|
||||
} catch (error) { /* empty */ }
|
||||
};
|
||||
@@ -30065,7 +30065,7 @@ var split = uncurryThis(''.split);
|
||||
// fallback for non-array-like ES3 and non-enumerable old V8 strings
|
||||
module.exports = fails(function () {
|
||||
// throws an error in rhino, see https://github.com/mozilla/rhino/issues/346
|
||||
// eslint-disable-next-line no-prototype-builtins -- safe
|
||||
|
||||
return !$Object('z').propertyIsEnumerable(0);
|
||||
}) ? function (it) {
|
||||
return classof(it) === 'String' ? split(it, '') : $Object(it);
|
||||
@@ -30119,7 +30119,7 @@ var getterFor = function (TYPE) {
|
||||
|
||||
if (NATIVE_WEAK_MAP || shared.state) {
|
||||
var store = shared.state || (shared.state = new WeakMap());
|
||||
/* eslint-disable no-self-assign -- prototype methods protection */
|
||||
|
||||
store.get = store.get;
|
||||
store.has = store.has;
|
||||
store.set = store.set;
|
||||
@@ -30669,7 +30669,7 @@ var makeBuiltIn = module.exports = function (value, name, options) {
|
||||
};
|
||||
|
||||
// add fake Function#toString for correct work wrapped methods / constructors with methods like LoDash isNative
|
||||
// eslint-disable-next-line no-extend-native -- required
|
||||
|
||||
Function.prototype.toString = makeBuiltIn(function toString() {
|
||||
return isCallable(this) && getInternalState(this).source || inspectSource(this);
|
||||
}, 'toString');
|
||||
@@ -30845,7 +30845,7 @@ module.exports = !$assign || fails(function () {
|
||||
A[symbol] = 7;
|
||||
alphabet.split('').forEach(function (chr) { B[chr] = chr; });
|
||||
return $assign({}, A)[symbol] !== 7 || objectKeys($assign({}, B)).join('') !== alphabet;
|
||||
}) ? function assign(target, source) { // eslint-disable-line no-unused-vars -- required for `.length`
|
||||
}) ? function assign(target, source) {
|
||||
var T = toObject(target);
|
||||
var argumentsLength = arguments.length;
|
||||
var index = 1;
|
||||
@@ -31172,7 +31172,7 @@ exports.f = NASHORN_BUG ? function propertyIsEnumerable(V) {
|
||||
|
||||
},{}],318:[function(require,module,exports){
|
||||
'use strict';
|
||||
/* eslint-disable no-proto -- safe */
|
||||
|
||||
var uncurryThisAccessor = require('../internals/function-uncurry-this-accessor');
|
||||
var anObject = require('../internals/an-object');
|
||||
var aPossiblePrototype = require('../internals/a-possible-prototype');
|
||||
@@ -31547,7 +31547,7 @@ module.exports = function () {
|
||||
if (SymbolPrototype && !SymbolPrototype[TO_PRIMITIVE]) {
|
||||
// `Symbol.prototype[@@toPrimitive]` method
|
||||
// https://tc39.es/ecma262/#sec-symbol.prototype-@@toprimitive
|
||||
// eslint-disable-next-line no-unused-vars -- required for .length
|
||||
|
||||
defineBuiltIn(SymbolPrototype, TO_PRIMITIVE, function (hint) {
|
||||
return call(valueOf, this);
|
||||
}, { arity: 1 });
|
||||
@@ -31713,7 +31713,7 @@ var trunc = require('../internals/math-trunc');
|
||||
// https://tc39.es/ecma262/#sec-tointegerorinfinity
|
||||
module.exports = function (argument) {
|
||||
var number = +argument;
|
||||
// eslint-disable-next-line no-self-compare -- NaN check
|
||||
|
||||
return number !== number || number === 0 ? 0 : trunc(number);
|
||||
};
|
||||
|
||||
@@ -32113,7 +32113,7 @@ if ($stringify) {
|
||||
// `JSON.stringify` method
|
||||
// https://tc39.es/ecma262/#sec-json.stringify
|
||||
$({ target: 'JSON', stat: true, arity: 3, forced: WRONG_SYMBOLS_CONVERSION || ILL_FORMED_UNICODE }, {
|
||||
// eslint-disable-next-line no-unused-vars -- required for `.length`
|
||||
|
||||
stringify: function stringify(it, replacer, space) {
|
||||
var args = arraySlice(arguments);
|
||||
var result = apply(WRONG_SYMBOLS_CONVERSION ? stringifyWithSymbolsFix : $stringify, null, args);
|
||||
@@ -32130,7 +32130,7 @@ var $ = require('../internals/export');
|
||||
// https://tc39.es/ecma262/#sec-number.isnan
|
||||
$({ target: 'Number', stat: true }, {
|
||||
isNaN: function isNaN(number) {
|
||||
// eslint-disable-next-line no-self-compare -- NaN check
|
||||
|
||||
return number !== number;
|
||||
}
|
||||
});
|
||||
@@ -32480,7 +32480,7 @@ if (FORCED_PROMISE_CONSTRUCTOR) {
|
||||
|
||||
PromisePrototype = PromiseConstructor.prototype;
|
||||
|
||||
// eslint-disable-next-line no-unused-vars -- required for `.length`
|
||||
|
||||
Internal = function Promise(executor) {
|
||||
setInternalState(this, {
|
||||
type: PROMISE,
|
||||
@@ -32695,7 +32695,7 @@ var INCORRECT_LENGTH = !!$fromCodePoint && $fromCodePoint.length !== 1;
|
||||
// `String.fromCodePoint` method
|
||||
// https://tc39.es/ecma262/#sec-string.fromcodepoint
|
||||
$({ target: 'String', stat: true, arity: 1, forced: INCORRECT_LENGTH }, {
|
||||
// eslint-disable-next-line no-unused-vars -- required for `.length`
|
||||
|
||||
fromCodePoint: function fromCodePoint(x) {
|
||||
var elements = [];
|
||||
var length = arguments.length;
|
||||
@@ -33204,7 +33204,7 @@ function ECDH(curve) {
|
||||
name: curve
|
||||
};
|
||||
}
|
||||
this.curve = new elliptic.ec(this.curveType.name); // eslint-disable-line new-cap
|
||||
this.curve = new elliptic.ec(this.curveType.name);
|
||||
this.keys = void 0;
|
||||
}
|
||||
ECDH.prototype.generateKeys = function (enc, format) {
|
||||
@@ -37967,7 +37967,7 @@ function functionBindPolyfill(context) {
|
||||
var Buffer = require('safe-buffer').Buffer;
|
||||
var MD5 = require('md5.js');
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
function EVP_BytesToKey(password, salt, keyBits, ivLen) {
|
||||
if (!Buffer.isBuffer(password)) password = Buffer.from(password, 'binary');
|
||||
if (salt) {
|
||||
@@ -49298,7 +49298,7 @@ var crypto = global.crypto || global.msCrypto;
|
||||
var kMaxUint32 = Math.pow(2, 32) - 1;
|
||||
function assertOffset(offset, length) {
|
||||
if (typeof offset !== 'number' || offset !== offset) {
|
||||
// eslint-disable-line no-self-compare
|
||||
|
||||
throw new TypeError('offset must be a number');
|
||||
}
|
||||
if (offset > kMaxUint32 || offset < 0) {
|
||||
@@ -49310,7 +49310,7 @@ function assertOffset(offset, length) {
|
||||
}
|
||||
function assertSize(size, offset, length) {
|
||||
if (typeof size !== 'number' || size !== size) {
|
||||
// eslint-disable-line no-self-compare
|
||||
|
||||
throw new TypeError('size must be a number');
|
||||
}
|
||||
if (size > kMaxUint32 || size < 0) {
|
||||
@@ -53275,13 +53275,13 @@ var NC_NAME_RE = NSed3.NC_NAME_RE;
|
||||
const XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace";
|
||||
const XMLNS_NAMESPACE = "http://www.w3.org/2000/xmlns/";
|
||||
const rootNS = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
__proto__: null,
|
||||
xml: XML_NAMESPACE,
|
||||
xmlns: XMLNS_NAMESPACE
|
||||
};
|
||||
const XML_ENTITIES = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
__proto__: null,
|
||||
amp: "&",
|
||||
gt: ">",
|
||||
@@ -53446,11 +53446,11 @@ class SaxesParser {
|
||||
this.nameStartCheck = isNCNameStartChar;
|
||||
this.nameCheck = isNCNameChar;
|
||||
this.isName = isNCName;
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
|
||||
this.processAttribs = this.processAttribsNS;
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
|
||||
this.pushAttrib = this.pushAttribNS;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
this.ns = Object.assign({
|
||||
__proto__: null
|
||||
}, rootNS);
|
||||
@@ -53463,9 +53463,9 @@ class SaxesParser {
|
||||
this.nameStartCheck = isNameStartChar;
|
||||
this.nameCheck = isNameChar;
|
||||
this.isName = isName;
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
|
||||
this.processAttribs = this.processAttribsPlain;
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
|
||||
this.pushAttrib = this.pushAttribPlain;
|
||||
}
|
||||
//
|
||||
@@ -53473,7 +53473,7 @@ class SaxesParser {
|
||||
// numbers given to the states that correspond to the methods being recorded
|
||||
// here.
|
||||
//
|
||||
this.stateTable = [/* eslint-disable @typescript-eslint/unbound-method */
|
||||
this.stateTable = [
|
||||
this.sBegin, this.sBeginWhitespace, this.sDoctype, this.sDoctypeQuote, this.sDTD, this.sDTDQuoted, this.sDTDOpenWaka, this.sDTDOpenWakaBang, this.sDTDComment, this.sDTDCommentEnding, this.sDTDCommentEnded, this.sDTDPI, this.sDTDPIEnding, this.sText, this.sEntity, this.sOpenWaka, this.sOpenWakaBang, this.sComment, this.sCommentEnding, this.sCommentEnded, this.sCData, this.sCDataEnding, this.sCDataEnding2, this.sPIFirstChar, this.sPIRest, this.sPIBody, this.sPIEnding, this.sXMLDeclNameStart, this.sXMLDeclName, this.sXMLDeclEq, this.sXMLDeclValueStart, this.sXMLDeclValue, this.sXMLDeclSeparator, this.sXMLDeclEnding, this.sOpenTag, this.sOpenTagSlash, this.sAttrib, this.sAttribName, this.sAttribNameSawWhite, this.sAttribValue, this.sAttribValueQuoted, this.sAttribValueClosed, this.sAttribValueUnquoted, this.sCloseTag, this.sCloseTagSawWhite];
|
||||
this._init();
|
||||
}
|
||||
@@ -53536,7 +53536,7 @@ class SaxesParser {
|
||||
this.line = 1;
|
||||
this.column = 0;
|
||||
this.ENTITIES = Object.create(XML_ENTITIES);
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
|
||||
(_a = this.readyHandler) === null || _a === void 0 ? void 0 : _a.call(this);
|
||||
}
|
||||
/**
|
||||
@@ -53574,7 +53574,7 @@ class SaxesParser {
|
||||
* @param handler The handler to set.
|
||||
*/
|
||||
on(name, handler) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
this[EVENT_NAME_TO_HANDLER_NAME[name]] = handler;
|
||||
}
|
||||
/**
|
||||
@@ -53583,7 +53583,7 @@ class SaxesParser {
|
||||
* @parma name The event to stop listening to.
|
||||
*/
|
||||
off(name) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
this[EVENT_NAME_TO_HANDLER_NAME[name]] = undefined;
|
||||
}
|
||||
/**
|
||||
@@ -53678,7 +53678,7 @@ class SaxesParser {
|
||||
this.chunk = chunk;
|
||||
this.i = 0;
|
||||
while (this.i < limit) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
stateTable[this.state].call(this);
|
||||
}
|
||||
this.chunkPosition += limit;
|
||||
@@ -53875,7 +53875,7 @@ class SaxesParser {
|
||||
const {
|
||||
chunk
|
||||
} = this;
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
|
||||
while (true) {
|
||||
const c = this.getCode();
|
||||
const isNLLike = c === NL_LIKE;
|
||||
@@ -53906,7 +53906,7 @@ class SaxesParser {
|
||||
const {
|
||||
chunk
|
||||
} = this;
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
|
||||
while (true) {
|
||||
let c = this.getCode();
|
||||
switch (c) {
|
||||
@@ -53939,7 +53939,7 @@ class SaxesParser {
|
||||
chunk,
|
||||
i: start
|
||||
} = this;
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
|
||||
while (true) {
|
||||
const c = this.getCode();
|
||||
if (c === EOC) {
|
||||
@@ -53961,7 +53961,7 @@ class SaxesParser {
|
||||
* instead.
|
||||
*/
|
||||
skipSpaces() {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
|
||||
while (true) {
|
||||
const c = this.getCodeNorm();
|
||||
if (c === EOC || !isS(c)) {
|
||||
@@ -53971,7 +53971,7 @@ class SaxesParser {
|
||||
}
|
||||
setXMLVersion(version) {
|
||||
this.currentXMLVersion = version;
|
||||
/* eslint-disable @typescript-eslint/unbound-method */
|
||||
|
||||
if (version === "1.0") {
|
||||
this.isChar = isChar10;
|
||||
this.getCode = this.getCode10;
|
||||
@@ -54030,7 +54030,7 @@ class SaxesParser {
|
||||
switch (c) {
|
||||
case GREATER:
|
||||
{
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
|
||||
(_a = this.doctypeHandler) === null || _a === void 0 ? void 0 : _a.call(this, this.text);
|
||||
this.text = "";
|
||||
this.state = S_TEXT;
|
||||
@@ -54168,9 +54168,9 @@ class SaxesParser {
|
||||
const {
|
||||
chunk
|
||||
} = this;
|
||||
// eslint-disable-next-line no-labels, no-restricted-syntax
|
||||
|
||||
loop:
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
|
||||
while (true) {
|
||||
switch (this.getCode()) {
|
||||
case NL_LIKE:
|
||||
@@ -54195,12 +54195,12 @@ class SaxesParser {
|
||||
if (entityReturnState !== S_TEXT || this.textHandler !== undefined) {
|
||||
this.text += parsed;
|
||||
}
|
||||
// eslint-disable-next-line no-labels
|
||||
|
||||
break loop;
|
||||
}
|
||||
case EOC:
|
||||
this.entity += chunk.slice(start);
|
||||
// eslint-disable-next-line no-labels
|
||||
|
||||
break loop;
|
||||
default:
|
||||
}
|
||||
@@ -54282,7 +54282,7 @@ class SaxesParser {
|
||||
const c = this.getCodeNorm();
|
||||
if (c === MINUS) {
|
||||
this.state = S_COMMENT_ENDED;
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
|
||||
(_a = this.commentHandler) === null || _a === void 0 ? void 0 : _a.call(this, this.text);
|
||||
this.text = "";
|
||||
} else {
|
||||
@@ -54322,7 +54322,7 @@ class SaxesParser {
|
||||
switch (c) {
|
||||
case GREATER:
|
||||
{
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
|
||||
(_a = this.cdataHandler) === null || _a === void 0 ? void 0 : _a.call(this, this.text);
|
||||
this.text = "";
|
||||
this.state = S_TEXT;
|
||||
@@ -54361,7 +54361,7 @@ class SaxesParser {
|
||||
chunk,
|
||||
i: start
|
||||
} = this;
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
|
||||
while (true) {
|
||||
const c = this.getCodeNorm();
|
||||
if (c === EOC) {
|
||||
@@ -54415,7 +54415,7 @@ class SaxesParser {
|
||||
if (piTarget.toLowerCase() === "xml") {
|
||||
this.fail("the XML declaration must appear at the start of the document.");
|
||||
}
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
|
||||
(_a = this.piHandler) === null || _a === void 0 ? void 0 : _a.call(this, {
|
||||
target: piTarget,
|
||||
body: this.text
|
||||
@@ -54593,7 +54593,7 @@ class SaxesParser {
|
||||
} else if (this.name !== "version" && this.xmlDeclExpects.includes("version")) {
|
||||
this.fail("XML declaration must contain a version.");
|
||||
}
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
|
||||
(_a = this.xmldeclHandler) === null || _a === void 0 ? void 0 : _a.call(this, this.xmlDecl);
|
||||
this.name = "";
|
||||
this.piTarget = this.text = "";
|
||||
@@ -54620,7 +54620,7 @@ class SaxesParser {
|
||||
if (this.xmlnsOpt) {
|
||||
this.topNS = tag.ns = Object.create(null);
|
||||
}
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
|
||||
(_a = this.openTagStartHandler) === null || _a === void 0 ? void 0 : _a.call(this, tag);
|
||||
this.sawRoot = true;
|
||||
if (!this.fragmentOpt && this.closedRoot) {
|
||||
@@ -54725,7 +54725,7 @@ class SaxesParser {
|
||||
let {
|
||||
i: start
|
||||
} = this;
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
|
||||
while (true) {
|
||||
switch (this.getCode()) {
|
||||
case q:
|
||||
@@ -54841,9 +54841,9 @@ class SaxesParser {
|
||||
chunk,
|
||||
textHandler: handler
|
||||
} = this;
|
||||
// eslint-disable-next-line no-labels, no-restricted-syntax
|
||||
|
||||
scanLoop:
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
|
||||
while (true) {
|
||||
switch (this.getCode()) {
|
||||
case LESS:
|
||||
@@ -54862,7 +54862,7 @@ class SaxesParser {
|
||||
}
|
||||
}
|
||||
forbiddenState = FORBIDDEN_START;
|
||||
// eslint-disable-next-line no-labels
|
||||
|
||||
break scanLoop;
|
||||
}
|
||||
case AMP:
|
||||
@@ -54872,7 +54872,7 @@ class SaxesParser {
|
||||
this.text += chunk.slice(start, this.prevI);
|
||||
}
|
||||
forbiddenState = FORBIDDEN_START;
|
||||
// eslint-disable-next-line no-labels
|
||||
|
||||
break scanLoop;
|
||||
case CLOSE_BRACKET:
|
||||
switch (forbiddenState) {
|
||||
@@ -54905,7 +54905,7 @@ class SaxesParser {
|
||||
if (handler !== undefined) {
|
||||
this.text += chunk.slice(start);
|
||||
}
|
||||
// eslint-disable-next-line no-labels
|
||||
|
||||
break scanLoop;
|
||||
default:
|
||||
forbiddenState = FORBIDDEN_START;
|
||||
@@ -54926,9 +54926,9 @@ class SaxesParser {
|
||||
textHandler: handler
|
||||
} = this;
|
||||
let nonSpace = false;
|
||||
// eslint-disable-next-line no-labels, no-restricted-syntax
|
||||
|
||||
outRootLoop:
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
|
||||
while (true) {
|
||||
const code = this.getCode();
|
||||
switch (code) {
|
||||
@@ -54947,7 +54947,7 @@ class SaxesParser {
|
||||
handler(slice);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line no-labels
|
||||
|
||||
break outRootLoop;
|
||||
}
|
||||
case AMP:
|
||||
@@ -54957,7 +54957,7 @@ class SaxesParser {
|
||||
this.text += chunk.slice(start, this.prevI);
|
||||
}
|
||||
nonSpace = true;
|
||||
// eslint-disable-next-line no-labels
|
||||
|
||||
break outRootLoop;
|
||||
case NL_LIKE:
|
||||
if (handler !== undefined) {
|
||||
@@ -54969,7 +54969,7 @@ class SaxesParser {
|
||||
if (handler !== undefined) {
|
||||
this.text += chunk.slice(start);
|
||||
}
|
||||
// eslint-disable-next-line no-labels
|
||||
|
||||
break outRootLoop;
|
||||
default:
|
||||
if (!isS(code)) {
|
||||
@@ -55005,7 +55005,7 @@ class SaxesParser {
|
||||
value
|
||||
};
|
||||
this.attribList.push(attr);
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
|
||||
(_a = this.attributeHandler) === null || _a === void 0 ? void 0 : _a.call(this, attr);
|
||||
if (prefix === "xmlns") {
|
||||
const trimmed = value.trim();
|
||||
@@ -55027,7 +55027,7 @@ class SaxesParser {
|
||||
value
|
||||
};
|
||||
this.attribList.push(attr);
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
|
||||
(_a = this.attributeHandler) === null || _a === void 0 ? void 0 : _a.call(this, attr);
|
||||
}
|
||||
/**
|
||||
@@ -55055,12 +55055,12 @@ class SaxesParser {
|
||||
text
|
||||
} = this;
|
||||
if (text.length !== 0) {
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
|
||||
(_a = this.textHandler) === null || _a === void 0 ? void 0 : _a.call(this, text);
|
||||
this.text = "";
|
||||
}
|
||||
this._closed = true;
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
|
||||
(_b = this.endHandler) === null || _b === void 0 ? void 0 : _b.call(this);
|
||||
this._init();
|
||||
return this;
|
||||
@@ -55187,7 +55187,7 @@ class SaxesParser {
|
||||
const {
|
||||
attribList
|
||||
} = this;
|
||||
// eslint-disable-next-line prefer-destructuring
|
||||
|
||||
const attributes = this.tag.attributes;
|
||||
for (const {
|
||||
name,
|
||||
@@ -55215,7 +55215,7 @@ class SaxesParser {
|
||||
tag.isSelfClosing = false;
|
||||
// There cannot be any pending text here due to the onopentagstart that was
|
||||
// necessarily emitted before we get here. So we do not check text.
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
|
||||
(_a = this.openTagHandler) === null || _a === void 0 ? void 0 : _a.call(this, tag);
|
||||
tags.push(tag);
|
||||
this.state = S_TEXT;
|
||||
@@ -55236,9 +55236,9 @@ class SaxesParser {
|
||||
tag.isSelfClosing = true;
|
||||
// There cannot be any pending text here due to the onopentagstart that was
|
||||
// necessarily emitted before we get here. So we do not check text.
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
|
||||
(_a = this.openTagHandler) === null || _a === void 0 ? void 0 : _a.call(this, tag);
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
|
||||
(_b = this.closeTagHandler) === null || _b === void 0 ? void 0 : _b.call(this, tag);
|
||||
const top = this.tag = (_c = tags[tags.length - 1]) !== null && _c !== void 0 ? _c : null;
|
||||
if (top === null) {
|
||||
@@ -55271,7 +55271,7 @@ class SaxesParser {
|
||||
while (l-- > 0) {
|
||||
const tag = this.tag = tags.pop();
|
||||
this.topNS = tag.ns;
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
|
||||
handler === null || handler === void 0 ? void 0 : handler(tag);
|
||||
if (tag.name === name) {
|
||||
break;
|
||||
@@ -55294,7 +55294,7 @@ class SaxesParser {
|
||||
*/
|
||||
parseEntity(entity) {
|
||||
// startsWith would be significantly slower for this test.
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-string-starts-ends-with
|
||||
|
||||
if (entity[0] !== "#") {
|
||||
const defined = this.ENTITIES[entity];
|
||||
if (defined !== undefined) {
|
||||
@@ -59549,7 +59549,7 @@ function _default(name, version, hashfunc) {
|
||||
} // Function#name is not settable on some platforms (#270)
|
||||
|
||||
try {
|
||||
generateUUID.name = name; // eslint-disable-next-line no-empty
|
||||
generateUUID.name = name;
|
||||
} catch (err) {} // For CommonJS default export support
|
||||
|
||||
generateUUID.DNS = DNS;
|
||||
|
||||
@@ -160,7 +160,7 @@ function fromByteArray (uint8) {
|
||||
* @author Feross Aboukhadijeh <http://feross.org>
|
||||
* @license MIT
|
||||
*/
|
||||
/* eslint-disable no-proto */
|
||||
|
||||
|
||||
'use strict'
|
||||
|
||||
@@ -478,7 +478,7 @@ function checked (length) {
|
||||
}
|
||||
|
||||
function SlowBuffer (length) {
|
||||
if (+length != length) { // eslint-disable-line eqeqeq
|
||||
if (+length != length) {
|
||||
length = 0
|
||||
}
|
||||
return Buffer.alloc(+length)
|
||||
@@ -1941,7 +1941,7 @@ function blitBuffer (src, dst, offset, length) {
|
||||
}
|
||||
|
||||
function isnan (val) {
|
||||
return val !== val // eslint-disable-line no-self-compare
|
||||
return val !== val
|
||||
}
|
||||
|
||||
}).call(this)}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {},require("buffer").Buffer)
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -151,7 +151,7 @@ function proxyOnlyOfficeUpgrade(req, socket, head) {
|
||||
const onError = (err) => {
|
||||
try {
|
||||
const msg = err && err.message ? String(err.message) : String(err || "");
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.log("[dev-server][onlyoffice-ws] proxy error", msg);
|
||||
} catch {}
|
||||
try {
|
||||
@@ -201,7 +201,7 @@ function proxyConvexUpgrade(req, socket, head) {
|
||||
const onError = (err) => {
|
||||
try {
|
||||
const msg = err && err.message ? String(err.message) : String(err || "");
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.log("[dev-server][convex-ws] proxy error", msg);
|
||||
} catch {}
|
||||
try {
|
||||
@@ -264,7 +264,7 @@ function proxyConvexHttp(req, res) {
|
||||
|
||||
upstreamReq.on("error", (err) => {
|
||||
try {
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.log("[dev-server][convex-http] proxy error", err && err.message ? err.message : String(err || ""));
|
||||
} catch {}
|
||||
try {
|
||||
@@ -309,7 +309,7 @@ async function main() {
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
if (isConvexPath(req.url || "/")) {
|
||||
try {
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.log(
|
||||
"[dev-server][convex-ws] upgrade",
|
||||
req.url,
|
||||
@@ -326,7 +326,7 @@ async function main() {
|
||||
}
|
||||
if (isOnlyOfficePath(req.url || "/")) {
|
||||
try {
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.log(
|
||||
"[dev-server][onlyoffice-ws] upgrade",
|
||||
req.url,
|
||||
@@ -353,7 +353,7 @@ async function main() {
|
||||
});
|
||||
|
||||
server.listen(port, hostname, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.log(
|
||||
`[dev-server] ready http://${hostname}:${port} (ONLYOFFICE ws via ${ONLYOFFICE_PREFIX} -> ${process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081"}; Convex ws via ${CONVEX_PREFIX} -> ${process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210"})`,
|
||||
);
|
||||
@@ -361,7 +361,7 @@ async function main() {
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.error(err instanceof Error ? err.stack : String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function WolaiImportPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const parentId = useMemo(() => searchParams.get("parentId") ?? "", [searchParams]);
|
||||
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [zipPath, setZipPath] = useState("");
|
||||
const [rootMdPath, setRootMdPath] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!zipPath.trim() && !file) {
|
||||
setError("请填写本地 ZIP 路径,或选择 ZIP 文件");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setWarnings([]);
|
||||
try {
|
||||
const useLocalPath = Boolean(zipPath.trim());
|
||||
let res: Response;
|
||||
if (useLocalPath) {
|
||||
res = await fetch("/api/wolai-import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
zipPath: zipPath.trim(),
|
||||
parentId: parentId || undefined,
|
||||
rootMdPath: rootMdPath.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
if (!file) {
|
||||
setError("请选择 ZIP 文件");
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.set("file", file);
|
||||
if (parentId) formData.set("parentId", parentId);
|
||||
if (rootMdPath.trim()) formData.set("rootMdPath", rootMdPath.trim());
|
||||
res = await fetch("/api/wolai-import", { method: "POST", body: formData });
|
||||
}
|
||||
const payload = (await res.json().catch(() => null)) as any;
|
||||
if (!res.ok) {
|
||||
const msg = payload?.error ? String(payload.error) : `导入失败:${res.status}`;
|
||||
setError(msg);
|
||||
if (res.status === 409 && Array.isArray(payload?.candidates)) {
|
||||
setWarnings([`无法自动判断入口页面,请在“入口 rootMdPath”填入其中一个:`, ...payload.candidates.map(String)]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = Array.isArray(payload?.warnings) ? payload.warnings.map(String) : [];
|
||||
setWarnings(ws);
|
||||
|
||||
const rootDocumentId = String(payload?.rootDocumentId ?? "");
|
||||
if (rootDocumentId) {
|
||||
router.push(`/documents/${encodeURIComponent(rootDocumentId)}`);
|
||||
return;
|
||||
}
|
||||
setError("导入完成但缺少 rootDocumentId");
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "导入失败";
|
||||
setError(msg);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl p-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>导入 Wolai(Markdown ZIP)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">本地 ZIP 路径(大文件推荐)</div>
|
||||
<Input
|
||||
value={zipPath}
|
||||
onChange={(e) => setZipPath(e.target.value)}
|
||||
placeholder="例如:C:\\Users\\liaib\\Downloads\\个人.zip"
|
||||
disabled={busy}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
说明:你的 <span className="font-mono">个人.zip</span> 约 1.26GB,浏览器上传很容易 500。
|
||||
建议填本地路径并清空 ZIP 文件选择(或不选择文件)。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">ZIP 文件</div>
|
||||
<Input
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">说明:请使用 Wolai 导出的“Markdown(含附件/图片)”ZIP。</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">入口 rootMdPath(可选)</div>
|
||||
<Input
|
||||
value={rootMdPath}
|
||||
onChange={(e) => setRootMdPath(e.target.value)}
|
||||
placeholder="例如:dQeAax/个人.md(留空则自动判断)"
|
||||
disabled={busy}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
自动判断失败时会返回候选列表,你可以把其中一个填进来重试。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{parentId ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
将作为子页面导入到:<span className="font-mono">{parentId}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? <div className="text-sm text-red-600">{error}</div> : null}
|
||||
{warnings.length > 0 ? (
|
||||
<div className={cn("rounded-md border bg-muted/30 p-3 text-xs whitespace-pre-wrap")}>
|
||||
{warnings.join("\n")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => void handleSubmit()} disabled={busy || (!zipPath.trim() && !file)}>
|
||||
{busy ? "导入中..." : "开始导入"}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => router.back()} disabled={busy}>
|
||||
返回
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,18 @@ import { createOnlyOfficeServerTools, type OnlyOfficeSupabaseClient } from "@/li
|
||||
import { buildClientToolKey, registerClientToolCall } from "@/lib/ai-agent/runtime/clientToolBridge";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
import {
|
||||
DEFAULT_AGENT_MAX_STEPS,
|
||||
MAX_AGENT_STEPS,
|
||||
MIN_AGENT_STEPS,
|
||||
DEFAULT_CLIENT_TOOL_TIMEOUT_MS,
|
||||
MAX_MINDMAP_ATTACHMENTS,
|
||||
MAX_SELECTED_NODES,
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
} from "@/lib/constants";
|
||||
import { safeGetJsonBody, errorResponses, validateRequestBody } from "@/lib/api-utils";
|
||||
import { isPlainObject, hasProperty, toRecord } from "@/lib/type-guards";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -39,8 +51,46 @@ type RequestPayload = {
|
||||
options?: { searxng?: boolean; ai?: { provider?: "online" | "local"; model?: string } };
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_STEPS = 10;
|
||||
const DEFAULT_CLIENT_TOOL_TIMEOUT_MS = 60_000;
|
||||
/** 按作用域分组的工具集 ID 映射 */
|
||||
const SCOPE_TOOLSET_MAP: Record<AgentScope, string[]> = {
|
||||
mindmap: [
|
||||
"toolset.readonly",
|
||||
"toolset.rag_read",
|
||||
"toolset.media_read",
|
||||
"toolset.mindmap_read",
|
||||
"toolset.mindmap_write",
|
||||
],
|
||||
document: [
|
||||
"toolset.readonly",
|
||||
"toolset.rag_read",
|
||||
"toolset.media_read",
|
||||
"toolset.docs_read",
|
||||
"toolset.doc_read",
|
||||
"toolset.doc_write",
|
||||
"toolset.slash_write",
|
||||
],
|
||||
onlyoffice: [
|
||||
"toolset.readonly",
|
||||
"toolset.rag_read",
|
||||
"toolset.media_read",
|
||||
"toolset.docs_read",
|
||||
"toolset.onlyoffice_read",
|
||||
"toolset.onlyoffice_write",
|
||||
"toolset.onlyoffice_editor",
|
||||
],
|
||||
global: [
|
||||
"toolset.readonly",
|
||||
"toolset.rag_read",
|
||||
"toolset.media_read",
|
||||
"toolset.docs_read",
|
||||
"toolset.slash_write",
|
||||
],
|
||||
};
|
||||
|
||||
/** 获取指定作用域允许的工具集 ID */
|
||||
const getToolSetIdsForScope = (scope: AgentScope): string[] => {
|
||||
return SCOPE_TOOLSET_MAP[scope] ?? SCOPE_TOOLSET_MAP.global;
|
||||
};
|
||||
|
||||
const makeRunId = () => {
|
||||
try {
|
||||
@@ -67,22 +117,31 @@ const toSseFrame = (event: string, data: unknown) => {
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const payload = (await request.json().catch(() => null)) as RequestPayload | null;
|
||||
if (!payload || !Array.isArray(payload.messages) || payload.messages.length === 0) {
|
||||
return NextResponse.json({ error: "缺少 messages" }, { status: 400 });
|
||||
const payload = await safeGetJsonBody<RequestPayload>(request);
|
||||
if (!payload) {
|
||||
return errorResponses.badRequest("请求体不能为空");
|
||||
}
|
||||
const validationError = validateRequestBody(payload, ["messages"] as const);
|
||||
if (validationError) {
|
||||
return validationError;
|
||||
}
|
||||
if (!Array.isArray(payload.messages) || payload.messages.length === 0) {
|
||||
return errorResponses.badRequest("缺少 messages");
|
||||
}
|
||||
|
||||
// v1:鉴权(Convex 迁移阶段使用固定开发用户;非 Convex 模式仍走 Supabase session)
|
||||
const convexOn = isConvexEnabled();
|
||||
const { userId, supabase, convexClient } = await (async () => {
|
||||
if (convexOn) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
return { userId: auth.userId, supabase: null as any, convexClient: client };
|
||||
}
|
||||
return { userId: "", supabase: null as any, convexClient: null as any };
|
||||
})();
|
||||
const supabase = null as unknown;
|
||||
let userId = "";
|
||||
let convexClient: ConvexHttpClient | null = null;
|
||||
|
||||
if (convexOn) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
userId = auth.userId ?? "";
|
||||
convexClient = client;
|
||||
}
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
return errorResponses.unauthorized();
|
||||
}
|
||||
|
||||
const provider = payload.options?.ai?.provider === "local" ? "local" : "online";
|
||||
@@ -92,11 +151,7 @@ export async function POST(request: Request) {
|
||||
? await loadLocalAiConfig().catch(() => null)
|
||||
: await loadOnlineAiConfig().catch(() => null);
|
||||
if (!cfg) {
|
||||
const tip =
|
||||
provider === "local"
|
||||
? "未找到本地 AI 配置(LOCAL_AI_BASE_URL/LOCAL_AI_MODEL 或 ai.local.md / ai-local.md)"
|
||||
: "未找到在线 AI 配置(ai.md 或 ONLINE_AI_* 环境变量)";
|
||||
return NextResponse.json({ error: tip }, { status: 500 });
|
||||
return errorResponses.aiConfigError(provider);
|
||||
}
|
||||
|
||||
const registry = createToolRegistry({ tools: builtinTools, toolSets: builtinToolSets });
|
||||
@@ -116,15 +171,8 @@ export async function POST(request: Request) {
|
||||
return mindmapId ? "mindmap" : "global";
|
||||
})();
|
||||
|
||||
// v1:按“使用位置”隔离工具,避免工具混淆/误调用(即使用户手动传入,也会被过滤)
|
||||
const allowToolSetIds: string[] =
|
||||
scope === "mindmap"
|
||||
? ["toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.mindmap_read", "toolset.mindmap_write"]
|
||||
: scope === "document"
|
||||
? ["toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.docs_read", "toolset.doc_read", "toolset.doc_write", "toolset.slash_write"]
|
||||
: scope === "onlyoffice"
|
||||
? ["toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.docs_read", "toolset.onlyoffice_read", "toolset.onlyoffice_write", "toolset.onlyoffice_editor"]
|
||||
: ["toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.docs_read", "toolset.slash_write"];
|
||||
// v1:按"使用位置"隔离工具,避免工具混淆/误调用(即使用户手动传入,也会被过滤)
|
||||
const allowToolSetIds = getToolSetIdsForScope(scope);
|
||||
const allowlist = new Set<string>();
|
||||
for (const sid of allowToolSetIds) {
|
||||
const s = registry.toolSetsById.get(sid);
|
||||
@@ -139,12 +187,12 @@ export async function POST(request: Request) {
|
||||
|
||||
// v1:mindmap 工具必须在提供上下文时才允许,避免模型盲调导致误操作
|
||||
const selectedUids = Array.isArray(payload.context?.selectedUids)
|
||||
? payload.context!.selectedUids!.map((x) => String(x)).filter(Boolean).slice(0, 6)
|
||||
? payload.context!.selectedUids!.map((x) => String(x)).filter(Boolean).slice(0, MAX_SELECTED_NODES)
|
||||
: [];
|
||||
const hasMindmapContext = Boolean(documentId && mindmapId);
|
||||
const hasDocumentContext = Boolean(documentId);
|
||||
const documentBlocks = payload.context?.documentBlocks ?? null;
|
||||
const attachments = Array.isArray(payload.attachments) ? payload.attachments.slice(0, 12) : [];
|
||||
const attachments = Array.isArray(payload.attachments) ? payload.attachments.slice(0, MAX_MINDMAP_ATTACHMENTS) : [];
|
||||
const attachmentLines = attachments
|
||||
.map((a, idx) => `${idx + 1}. id=${String(a.id)} title=${String(a.title)} mime=${String(a.mimeType ?? "")} url=${String(a.fileUrl)}`)
|
||||
.join("\n");
|
||||
@@ -251,8 +299,8 @@ export async function POST(request: Request) {
|
||||
loadMindmap: async () => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
const [mm, meta] = await Promise.all([
|
||||
convexClient.query(api.mindmaps.get, { userId, docId: documentId, mindmapId }),
|
||||
convexClient.query(api.documents.getMeta, { userId, id: documentId }),
|
||||
convexClient.query(api.mindmaps.get, { docId: documentId, mindmapId }),
|
||||
convexClient.query(api.documents.getMeta, { id: documentId }),
|
||||
]);
|
||||
const title = meta?.title ?? null;
|
||||
const workspaceId = meta?.workspace_id ?? (mm as any)?.meta?.workspace_id ?? null;
|
||||
@@ -263,7 +311,7 @@ export async function POST(request: Request) {
|
||||
},
|
||||
saveMindmap: async ({ data }) => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
await convexClient.mutation(api.mindmaps.put, { userId, docId: documentId, mindmapId, data });
|
||||
await convexClient.mutation(api.mindmaps.put, { docId: documentId, mindmapId, data });
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
@@ -286,13 +334,13 @@ export async function POST(request: Request) {
|
||||
const base = normalizeBlocksForTools(documentBlocks);
|
||||
if (base.length > 0) return { blocks: base, source: "client" };
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
const res = await convexClient.query(api.documents.getContent, { userId, id: documentId });
|
||||
const res = await convexClient.query(api.documents.getContent, { id: documentId });
|
||||
const blocks = normalizeBlocksForTools(res?.content ?? null);
|
||||
return { blocks, source: "convex" };
|
||||
},
|
||||
saveBlocks: async (blocks: unknown[]) => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
await convexClient.mutation(api.documents.updateContent, { userId, id: documentId, content: blocks });
|
||||
await convexClient.mutation(api.documents.updateContent, { id: documentId, content: blocks });
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
@@ -318,15 +366,15 @@ export async function POST(request: Request) {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
const wsIds = workspaceId
|
||||
? [workspaceId]
|
||||
: ((await convexClient.query(api.workspaces.fetchWorkspaceSummaries, { userId }))?.workspaces ?? []).map((w: any) =>
|
||||
: ((await convexClient.query(api.workspaces.fetchWorkspaceSummaries, {}))?.workspaces ?? []).map((w: any) =>
|
||||
String(w?.id ?? ""),
|
||||
);
|
||||
const q = query.toLowerCase();
|
||||
const results: any[] = [];
|
||||
for (const wid of wsIds.filter(Boolean)) {
|
||||
const docs = await convexClient.query(api.documents.listByWorkspace, { userId, workspaceId: wid });
|
||||
const docs = await convexClient.query(api.documents.listByWorkspace, { workspaceId: wid });
|
||||
const extra = includeDeleted
|
||||
? await convexClient.query(api.documents.listTrashedByWorkspace, { userId, workspaceId: wid }).catch(() => [])
|
||||
? await convexClient.query(api.documents.listTrashedByWorkspace, { workspaceId: wid }).catch(() => [])
|
||||
: [];
|
||||
const all = [...(Array.isArray(docs) ? docs : []), ...(Array.isArray(extra) ? extra : [])];
|
||||
for (const d of all) {
|
||||
@@ -348,9 +396,9 @@ export async function POST(request: Request) {
|
||||
},
|
||||
readDoc: async ({ documentId: rid, maxChars, includeContent }) => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
const meta = await convexClient.query(api.documents.getMeta, { userId, id: rid });
|
||||
const meta = await convexClient.query(api.documents.getMeta, { id: rid });
|
||||
if (!meta) throw new Error("页面不存在");
|
||||
const contentRes = await convexClient.query(api.documents.getContent, { userId, id: rid });
|
||||
const contentRes = await convexClient.query(api.documents.getContent, { id: rid });
|
||||
const blocks = normalizeBlocksForTools(contentRes?.content ?? null);
|
||||
const rawText = extractPlainTextFromBlocks(blocks, maxChars);
|
||||
return {
|
||||
@@ -403,21 +451,20 @@ export async function POST(request: Request) {
|
||||
allowedToolIds,
|
||||
...(convexOn
|
||||
? {
|
||||
loadWorkspaceIds: async (uid: string) => {
|
||||
loadWorkspaceIds: async (_uid: string) => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
const res = await convexClient.query(api.workspaces.fetchWorkspaceSummaries, { userId: uid });
|
||||
const res = await convexClient.query(api.workspaces.fetchWorkspaceSummaries, {});
|
||||
return (res?.workspaces ?? []).map((w: any) => String(w?.id ?? "")).filter(Boolean);
|
||||
},
|
||||
inferWorkspaceIdFromDoc: async (docId: string) => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
const meta = await convexClient.query(api.documents.getMeta, { userId, id: docId });
|
||||
const meta = await convexClient.query(api.documents.getMeta, { id: docId });
|
||||
return meta ? String((meta as any).workspace_id ?? "") || null : null;
|
||||
},
|
||||
createDoc: async ({ workspaceId, parentId, title }) => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
const id = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `doc_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
|
||||
const created = await convexClient.mutation(api.documents.create, {
|
||||
userId,
|
||||
id,
|
||||
workspaceId,
|
||||
parentId,
|
||||
@@ -436,8 +483,8 @@ export async function POST(request: Request) {
|
||||
},
|
||||
renameDoc: async ({ documentId: did, title }) => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
await convexClient.mutation(api.documents.updateTitle, { userId, id: did, title });
|
||||
const meta = await convexClient.query(api.documents.getMeta, { userId, id: did });
|
||||
await convexClient.mutation(api.documents.updateTitle, { id: did, title });
|
||||
const meta = await convexClient.query(api.documents.getMeta, { id: did });
|
||||
if (!meta) throw new Error("页面不存在");
|
||||
return {
|
||||
id: String((meta as any).id ?? did),
|
||||
@@ -458,8 +505,8 @@ export async function POST(request: Request) {
|
||||
}
|
||||
if (toolId === "search_web") {
|
||||
const query = String(toolArgs.query ?? "").trim();
|
||||
const count = Number(toolArgs.count ?? 6);
|
||||
return await searchSearxng(query, Number.isFinite(count) ? count : 6);
|
||||
const count = Number(toolArgs.count ?? DEFAULT_SEARCH_COUNT);
|
||||
return await searchSearxng(query, Number.isFinite(count) ? count : DEFAULT_SEARCH_COUNT);
|
||||
}
|
||||
if (toolId.startsWith("rag_")) {
|
||||
if (!ragTools) throw new Error(`工具未初始化:${toolId}`);
|
||||
@@ -492,9 +539,9 @@ export async function POST(request: Request) {
|
||||
};
|
||||
|
||||
const maxSteps = (() => {
|
||||
const raw = Number(payload.maxSteps ?? DEFAULT_MAX_STEPS);
|
||||
if (!Number.isFinite(raw)) return DEFAULT_MAX_STEPS;
|
||||
return Math.max(1, Math.min(24, Math.floor(raw)));
|
||||
const raw = Number(payload.maxSteps ?? DEFAULT_AGENT_MAX_STEPS);
|
||||
if (!Number.isFinite(raw)) return DEFAULT_AGENT_MAX_STEPS;
|
||||
return Math.max(MIN_AGENT_STEPS, Math.min(MAX_AGENT_STEPS, Math.floor(raw)));
|
||||
})();
|
||||
|
||||
const stream = payload.stream !== false;
|
||||
|
||||
@@ -6,6 +6,41 @@ import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const base64UrlEncodeUtf8 = (input: string) => {
|
||||
return Buffer.from(input, "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
};
|
||||
|
||||
const getRequestOrigin = (request: Request) => {
|
||||
const xfProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
||||
const xfHost = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
|
||||
const host = xfHost || request.headers.get("host") || new URL(request.url).host;
|
||||
const protocol = (xfProto || new URL(request.url).protocol.replace(/:$/, "")) + ":";
|
||||
return `${protocol}//${host}`;
|
||||
};
|
||||
|
||||
const isLocalHostname = (hostname: string) =>
|
||||
hostname === "127.0.0.1" || hostname === "localhost" || hostname === "host.docker.internal";
|
||||
|
||||
const maybeProxyForBrowser = (request: Request, rawUrl: string) => {
|
||||
const input = String(rawUrl || "").trim();
|
||||
if (!input) return input;
|
||||
if (input.startsWith("/api/onlyoffice/proxy")) return input;
|
||||
try {
|
||||
const u = new URL(input);
|
||||
if (!isLocalHostname(u.hostname)) return input;
|
||||
const origin = getRequestOrigin(request);
|
||||
const proxy = new URL("/api/onlyoffice/proxy", origin);
|
||||
proxy.searchParams.set("u", base64UrlEncodeUtf8(input));
|
||||
return proxy.toString();
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
let auth;
|
||||
@@ -40,7 +75,7 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
signedUrl,
|
||||
signedUrl: maybeProxyForBrowser(request, signedUrl),
|
||||
asset: {
|
||||
id: asset.id,
|
||||
file_name: asset.file_name,
|
||||
|
||||
@@ -4,6 +4,41 @@ import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const base64UrlEncodeUtf8 = (input: string) => {
|
||||
return Buffer.from(input, "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
};
|
||||
|
||||
const getRequestOrigin = (request: Request) => {
|
||||
const xfProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
||||
const xfHost = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
|
||||
const host = xfHost || request.headers.get("host") || new URL(request.url).host;
|
||||
const protocol = (xfProto || new URL(request.url).protocol.replace(/:$/, "")) + ":";
|
||||
return `${protocol}//${host}`;
|
||||
};
|
||||
|
||||
const isLocalHostname = (hostname: string) =>
|
||||
hostname === "127.0.0.1" || hostname === "localhost" || hostname === "host.docker.internal";
|
||||
|
||||
const maybeProxyForBrowser = (request: Request, rawUrl: string) => {
|
||||
const input = String(rawUrl || "").trim();
|
||||
if (!input) return input;
|
||||
if (input.startsWith("/api/onlyoffice/proxy")) return input;
|
||||
try {
|
||||
const u = new URL(input);
|
||||
if (!isLocalHostname(u.hostname)) return input;
|
||||
const origin = getRequestOrigin(request);
|
||||
const proxy = new URL("/api/onlyoffice/proxy", origin);
|
||||
proxy.searchParams.set("u", base64UrlEncodeUtf8(input));
|
||||
return proxy.toString();
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
try {
|
||||
@@ -23,9 +58,8 @@ export async function GET(request: Request) {
|
||||
|
||||
// 说明:Convex + MinIO 模式下,这个接口目前主要用于“外链文件”走 ONLYOFFICE 的场景。
|
||||
// 由于外链本身已经是可访问的 URL,这里只做最小透传。
|
||||
return NextResponse.json({ signedUrl: fileUrl });
|
||||
return NextResponse.json({ signedUrl: maybeProxyForBrowser(request, fileUrl) });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ const handle = async (request: Request, method: "GET" | "HEAD") => {
|
||||
let targetUrl: string;
|
||||
try {
|
||||
targetUrl = decodeBase64UrlToUtf8(encoded);
|
||||
// eslint-disable-next-line no-new
|
||||
|
||||
new URL(targetUrl);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "u 不是有效的 base64url URL" }, { status: 400 });
|
||||
|
||||
@@ -0,0 +1,985 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomUUID } from "crypto";
|
||||
import path from "path";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { BlockNoteEditor, BlockNoteSchema, markdownToBlocks } from "@blocknote/core";
|
||||
import type { Block } from "@blocknote/core";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
||||
import { promises as fs } from "fs";
|
||||
import os from "os";
|
||||
import { spawn } from "child_process";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
let domShimReady = false;
|
||||
function ensureDomShim() {
|
||||
if (domShimReady) return;
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||
const g = globalThis as any;
|
||||
|
||||
// 说明:Node.js(以及 Next.js 运行时)可能已经提供了只读的 globalThis.navigator(getter-only)。
|
||||
// 这里仅在必要时注入 window/document 等基础对象,避免覆盖只读属性导致 500。
|
||||
if (!g.window) g.window = dom.window;
|
||||
if (!g.document) g.document = dom.window.document;
|
||||
if (!g.DOMParser) g.DOMParser = dom.window.DOMParser;
|
||||
if (!g.HTMLElement) g.HTMLElement = dom.window.HTMLElement;
|
||||
if (!g.Node) g.Node = dom.window.Node;
|
||||
domShimReady = true;
|
||||
}
|
||||
|
||||
const documentsBaseDir = getDocumentsBaseDir();
|
||||
|
||||
async function ensureDocumentScaffold(id: string, title: string) {
|
||||
const folder = path.join(documentsBaseDir, id);
|
||||
const indexFile = path.join(folder, "index.md");
|
||||
await fs.mkdir(folder, { recursive: true });
|
||||
try {
|
||||
await fs.access(indexFile);
|
||||
} catch {
|
||||
const safeTitle = title && title.trim() ? title.trim() : "无标题";
|
||||
await fs.writeFile(indexFile, `# ${safeTitle}\n`, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
type InputStore = {
|
||||
kind: "dir";
|
||||
baseDir: string;
|
||||
listFiles: () => Promise<string[]>;
|
||||
readText: (relPath: string) => Promise<string>;
|
||||
readBytes: (relPath: string) => Promise<Uint8Array>;
|
||||
exists: (relPath: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
type ImportNode =
|
||||
| { kind: "doc"; key: string; title: string; mdPath: string; parentKey: string | null }
|
||||
| { kind: "folder"; key: string; title: string; dirPath: string; parentKey: string | null };
|
||||
|
||||
type TokenRecord =
|
||||
| { kind: "image"; token: string; url: string; alt: string }
|
||||
| { kind: "file"; token: string; url: string; label: string }
|
||||
| { kind: "page"; token: string; targetMdRel: string; label: string };
|
||||
|
||||
function normalizeZipPath(value: string): string {
|
||||
return value.replace(/\\/g, "/").replace(/^\.\//, "");
|
||||
}
|
||||
|
||||
function isRemoteUrl(url: string): boolean {
|
||||
// 说明:Wolai 导出里可能包含 mailto/tel/#锚点等“非文件路径”链接,这里统一视为远端(不当作本地文件处理)。
|
||||
return /^(https?:\/\/|data:|mailto:|tel:)/i.test(url) || url.trim().startsWith("#");
|
||||
}
|
||||
|
||||
function stripAngleBrackets(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
if (trimmed.startsWith("<") && trimmed.endsWith(">")) {
|
||||
return trimmed.slice(1, -1).trim();
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function safeBasename(filePath: string): string {
|
||||
const base = path.posix.basename(filePath);
|
||||
return base || "未命名资源";
|
||||
}
|
||||
|
||||
function parseTopHeadingTitle(markdown: string): string | null {
|
||||
const lines = markdown.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
const m = /^#\s+(.+)$/.exec(t);
|
||||
if (!m) return null;
|
||||
const title = m[1]?.trim() ?? "";
|
||||
return title || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function stripTopHeadingIfMatches(markdown: string, title: string): string {
|
||||
const lines = markdown.split(/\r?\n/);
|
||||
let idx = 0;
|
||||
while (idx < lines.length && !lines[idx].trim()) idx += 1;
|
||||
const first = lines[idx] ?? "";
|
||||
const m = /^#\s+(.+)$/.exec(first.trim());
|
||||
if (!m) return markdown;
|
||||
const head = (m[1] ?? "").trim();
|
||||
if (!head || head !== title.trim()) return markdown;
|
||||
const rest = [...lines.slice(0, idx), ...lines.slice(idx + 1)];
|
||||
while (rest.length > 0 && !rest[0].trim()) rest.shift();
|
||||
return rest.join("\n");
|
||||
}
|
||||
|
||||
function resolveRelativePosix(baseDirRel: string, rel: string): string {
|
||||
const cleaned = normalizeZipPath(stripAngleBrackets(rel));
|
||||
if (!cleaned) return cleaned;
|
||||
if (cleaned.startsWith("/")) return cleaned.replace(/^\/+/, "");
|
||||
return path.posix.normalize(path.posix.join(baseDirRel, cleaned));
|
||||
}
|
||||
|
||||
function stripMarkdownLinkTitle(raw: string): string {
|
||||
// 说明:处理 Wolai 导出的 title 属性:path "title"
|
||||
return raw.split(/\s+\"/)[0]?.trim() ?? raw.trim();
|
||||
}
|
||||
|
||||
function stripLocalQueryAndHash(url: string): string {
|
||||
// 说明:本地文件路径不应包含 ?query/#hash;否则会导致资源无法匹配。
|
||||
const noHash = url.split("#")[0] ?? url;
|
||||
const noQuery = (noHash ?? url).split("?")[0] ?? noHash ?? url;
|
||||
return noQuery.trim();
|
||||
}
|
||||
|
||||
function guessMimeType(fileName: string): string {
|
||||
const ext = path.extname(fileName).toLowerCase();
|
||||
switch (ext) {
|
||||
case ".png":
|
||||
return "image/png";
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
return "image/jpeg";
|
||||
case ".gif":
|
||||
return "image/gif";
|
||||
case ".webp":
|
||||
return "image/webp";
|
||||
case ".svg":
|
||||
return "image/svg+xml";
|
||||
case ".pdf":
|
||||
return "application/pdf";
|
||||
case ".doc":
|
||||
return "application/msword";
|
||||
case ".docx":
|
||||
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
case ".xls":
|
||||
return "application/vnd.ms-excel";
|
||||
case ".xlsx":
|
||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
case ".ppt":
|
||||
return "application/vnd.ms-powerpoint";
|
||||
case ".pptx":
|
||||
return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
||||
case ".mp3":
|
||||
return "audio/mpeg";
|
||||
case ".wav":
|
||||
return "audio/wav";
|
||||
case ".m4a":
|
||||
return "audio/mp4";
|
||||
case ".mp4":
|
||||
return "video/mp4";
|
||||
case ".mov":
|
||||
return "video/quicktime";
|
||||
default:
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAssetTypeByMime(mime: string): "image" | "video" | "audio" | "file" {
|
||||
if (mime.startsWith("image/")) return "image";
|
||||
if (mime.startsWith("video/")) return "video";
|
||||
if (mime.startsWith("audio/")) return "audio";
|
||||
return "file";
|
||||
}
|
||||
|
||||
function toFsPath(baseDir: string, relPath: string): string {
|
||||
const normalized = normalizeZipPath(relPath);
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
return path.join(baseDir, ...parts);
|
||||
}
|
||||
|
||||
async function listFilesRecursive(rootDir: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const stack: Array<{ abs: string; rel: string }> = [{ abs: rootDir, rel: "" }];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()!;
|
||||
const entries = await fs.readdir(current.abs, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const abs = path.join(current.abs, entry.name);
|
||||
const rel = current.rel ? `${current.rel}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
stack.push({ abs, rel });
|
||||
} else if (entry.isFile()) {
|
||||
out.push(normalizeZipPath(rel));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function escapePwshSingleQuoted(value: string): string {
|
||||
return value.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
async function extractZipToTemp(zipPath: string): Promise<string> {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "wolai-import-"));
|
||||
const literalZip = escapePwshSingleQuoted(zipPath);
|
||||
const literalOut = escapePwshSingleQuoted(tmp);
|
||||
const command = `Expand-Archive -LiteralPath '${literalZip}' -DestinationPath '${literalOut}' -Force`;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("powershell", ["-NoProfile", "-Command", command], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on("error", (err) => reject(err));
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`解压失败(code=${code}):${stderr || "unknown"}`));
|
||||
});
|
||||
});
|
||||
|
||||
return tmp;
|
||||
}
|
||||
|
||||
async function createDirStore(baseDir: string): Promise<InputStore> {
|
||||
const abs = path.resolve(baseDir);
|
||||
return {
|
||||
kind: "dir",
|
||||
baseDir: abs,
|
||||
listFiles: async () => await listFilesRecursive(abs),
|
||||
readText: async (relPath) => await fs.readFile(toFsPath(abs, relPath), "utf8"),
|
||||
readBytes: async (relPath) => new Uint8Array(await fs.readFile(toFsPath(abs, relPath))),
|
||||
exists: async (relPath) => {
|
||||
try {
|
||||
await fs.access(toFsPath(abs, relPath));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_ASSET_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
function detectRootMdPath(mdPaths: string[]): { rootMdPath: string | null; candidates: string[] } {
|
||||
const normalized = mdPaths.map(normalizeZipPath);
|
||||
const topDirs = Array.from(new Set(normalized.map((p) => p.split("/")[0]).filter(Boolean)));
|
||||
if (topDirs.length === 1) {
|
||||
const top = topDirs[0]!;
|
||||
const candidates = normalized
|
||||
.filter((p) => p.startsWith(`${top}/`))
|
||||
.filter((p) => p.split("/").length === 2)
|
||||
.sort((a, b) => a.localeCompare(b, "zh-Hans-CN"));
|
||||
if (candidates.length === 1) return { rootMdPath: candidates[0]!, candidates };
|
||||
if (candidates.length > 1) return { rootMdPath: null, candidates };
|
||||
}
|
||||
|
||||
const sorted = normalized
|
||||
.slice()
|
||||
.sort((a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b, "zh-Hans-CN"));
|
||||
if (sorted.length === 0) return { rootMdPath: null, candidates: [] };
|
||||
return { rootMdPath: sorted[0]!, candidates: sorted.slice(0, 8) };
|
||||
}
|
||||
|
||||
function buildImportNodes(opts: {
|
||||
scopePrefix: string;
|
||||
rootMdPath: string;
|
||||
mdPaths: string[];
|
||||
}): { nodes: ImportNode[]; mdRelToKey: Map<string, string> } {
|
||||
const scope = opts.scopePrefix.endsWith("/") ? opts.scopePrefix : `${opts.scopePrefix}/`;
|
||||
const withinScope = opts.mdPaths.map(normalizeZipPath).filter((p) => p.startsWith(scope));
|
||||
const root = normalizeZipPath(opts.rootMdPath);
|
||||
const mdSet = new Set(withinScope);
|
||||
|
||||
const allDirs: Set<string> = new Set();
|
||||
for (const md of withinScope) {
|
||||
const dir = path.posix.dirname(md);
|
||||
const segments = dir.split("/").filter(Boolean);
|
||||
let accum = "";
|
||||
for (const seg of segments) {
|
||||
accum = accum ? `${accum}/${seg}` : seg;
|
||||
allDirs.add(`${accum}/`);
|
||||
}
|
||||
}
|
||||
|
||||
const representing: Map<string, string> = new Map();
|
||||
for (const dir of allDirs) {
|
||||
const dirName = dir.split("/").filter(Boolean).pop() ?? "";
|
||||
if (!dirName) continue;
|
||||
const prefer = `${dir}${dirName}.md`;
|
||||
const index = `${dir}index.md`;
|
||||
if (mdSet.has(prefer)) representing.set(dir, prefer);
|
||||
else if (mdSet.has(index)) representing.set(dir, index);
|
||||
}
|
||||
representing.set(scope, root);
|
||||
|
||||
// 说明:仅为“包含 markdown 的目录”创建合成文件夹页(避免 image/ 这类纯资源目录)。
|
||||
const folderNodes: Array<Extract<ImportNode, { kind: "folder" }>> = [];
|
||||
for (const dir of allDirs) {
|
||||
if (dir === scope) continue;
|
||||
if (representing.has(dir)) continue;
|
||||
folderNodes.push({
|
||||
kind: "folder",
|
||||
key: `__dir__${dir}`,
|
||||
title: (dir.split("/").filter(Boolean).pop() ?? "目录").trim() || "目录",
|
||||
dirPath: dir,
|
||||
parentKey: null,
|
||||
});
|
||||
}
|
||||
|
||||
const docNodes: Array<Extract<ImportNode, { kind: "doc" }>> = withinScope.map((md) => ({
|
||||
kind: "doc",
|
||||
key: md,
|
||||
title: path.posix.basename(md, ".md") || "无标题",
|
||||
mdPath: md,
|
||||
parentKey: null,
|
||||
}));
|
||||
|
||||
const nodesByKey = new Map<string, ImportNode>();
|
||||
for (const n of [...folderNodes, ...docNodes]) nodesByKey.set(n.key, n);
|
||||
|
||||
const getContainerKeyForDir = (dir: string): string => {
|
||||
const normalizedDir = dir.endsWith("/") ? dir : `${dir}/`;
|
||||
const rep = representing.get(normalizedDir);
|
||||
if (rep) return rep;
|
||||
return `__dir__${normalizedDir}`;
|
||||
};
|
||||
|
||||
for (const node of folderNodes) {
|
||||
const parentDir = path.posix.dirname(node.dirPath.endsWith("/") ? node.dirPath.slice(0, -1) : node.dirPath);
|
||||
const parentDirNorm = parentDir === "." ? "" : `${parentDir}/`;
|
||||
node.parentKey = parentDirNorm ? getContainerKeyForDir(parentDirNorm) : root;
|
||||
}
|
||||
|
||||
for (const node of docNodes) {
|
||||
if (node.mdPath === root) {
|
||||
node.parentKey = null;
|
||||
continue;
|
||||
}
|
||||
const dir = `${path.posix.dirname(node.mdPath)}/`;
|
||||
const rep = representing.get(dir);
|
||||
if (rep && rep !== node.mdPath) {
|
||||
node.parentKey = rep;
|
||||
continue;
|
||||
}
|
||||
if (rep && rep === node.mdPath) {
|
||||
const parentDir = path.posix.dirname(dir.endsWith("/") ? dir.slice(0, -1) : dir);
|
||||
const parentDirNorm = parentDir === "." ? "" : `${parentDir}/`;
|
||||
node.parentKey = parentDirNorm ? getContainerKeyForDir(parentDirNorm) : root;
|
||||
continue;
|
||||
}
|
||||
node.parentKey = getContainerKeyForDir(dir);
|
||||
}
|
||||
|
||||
for (const node of [...folderNodes, ...docNodes]) {
|
||||
if (node.key === root) continue;
|
||||
if (!node.parentKey) continue;
|
||||
if (!nodesByKey.has(node.parentKey)) node.parentKey = root;
|
||||
}
|
||||
|
||||
// 说明:构建 md 相对路径 -> key 的映射,用于内部链接替换
|
||||
const mdRelToKey = new Map<string, string>();
|
||||
for (const md of withinScope) {
|
||||
const rel = md.startsWith(scope) ? md.slice(scope.length) : md;
|
||||
mdRelToKey.set(rel, md);
|
||||
}
|
||||
|
||||
// 说明:输出需要按“父先子后”的顺序创建
|
||||
const all = [...folderNodes, ...docNodes];
|
||||
const depth = (key: string): number => {
|
||||
let d = 0;
|
||||
let cur = nodesByKey.get(key) ?? null;
|
||||
const seen = new Set<string>();
|
||||
while (cur && cur.parentKey) {
|
||||
if (seen.has(cur.parentKey)) break;
|
||||
seen.add(cur.parentKey);
|
||||
d += 1;
|
||||
cur = nodesByKey.get(cur.parentKey) ?? null;
|
||||
}
|
||||
return d;
|
||||
};
|
||||
|
||||
all.sort((a, b) => depth(a.key) - depth(b.key) || a.title.localeCompare(b.title, "zh-Hans-CN"));
|
||||
return { nodes: all, mdRelToKey };
|
||||
}
|
||||
|
||||
function extractStandaloneMarkdownLink(line: string): { label: string; href: string } | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("[") || !trimmed.includes("](") || !trimmed.endsWith(")")) return null;
|
||||
const m = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(trimmed);
|
||||
if (!m) return null;
|
||||
const label = (m[1] ?? "").trim();
|
||||
const raw = (m[2] ?? "").trim();
|
||||
const href = stripAngleBrackets(stripMarkdownLinkTitle(raw));
|
||||
return { label, href };
|
||||
}
|
||||
|
||||
function replaceImagesAndLinks(opts: {
|
||||
markdown: string;
|
||||
mdDirRel: string;
|
||||
mdRelToDocId: Map<string, string>;
|
||||
uploadedUrlByRelAssetPath: Map<
|
||||
string,
|
||||
{
|
||||
url: string;
|
||||
assetId: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
assetType: "image" | "video" | "audio" | "file";
|
||||
}
|
||||
>;
|
||||
}): { markdown: string; tokens: TokenRecord[]; warnings: string[] } {
|
||||
const warnings: string[] = [];
|
||||
const tokens: TokenRecord[] = [];
|
||||
|
||||
const lines = opts.markdown.split(/\r?\n/);
|
||||
const out: string[] = [];
|
||||
|
||||
const imageRe = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
const linkRe = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
let line = rawLine;
|
||||
|
||||
// 1) 先处理“整行的页面链接/附件链接”,便于转成块
|
||||
const standalone = extractStandaloneMarkdownLink(line);
|
||||
if (standalone) {
|
||||
const href0 = standalone.href;
|
||||
const href = isRemoteUrl(href0) ? href0 : stripLocalQueryAndHash(href0);
|
||||
if (!isRemoteUrl(href)) {
|
||||
const target = resolveRelativePosix(opts.mdDirRel, href);
|
||||
if (target.toLowerCase().endsWith(".md")) {
|
||||
const docId = opts.mdRelToDocId.get(target);
|
||||
if (docId) {
|
||||
const token = `[[[MNOTE_PAGE_REF:${randomUUID()}]]]`;
|
||||
tokens.push({ kind: "page", token, targetMdRel: target, label: standalone.label });
|
||||
out.push(token);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const asset = opts.uploadedUrlByRelAssetPath.get(target);
|
||||
if (asset) {
|
||||
const token = `[[[MNOTE_FILE:${randomUUID()}]]]`;
|
||||
tokens.push({ kind: "file", token, url: asset.url, label: standalone.label });
|
||||
out.push(token);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 图片语法替换为 token(避免 BlockNote 的图片解析在 Node 环境报错)
|
||||
line = line.replace(imageRe, (_full, altRaw, urlRaw) => {
|
||||
const alt = String(altRaw ?? "").trim();
|
||||
const raw0 = stripAngleBrackets(stripMarkdownLinkTitle(String(urlRaw ?? "").trim()));
|
||||
const url = isRemoteUrl(raw0) ? raw0 : stripLocalQueryAndHash(raw0);
|
||||
const token = `[[[MNOTE_IMAGE:${randomUUID()}]]]`;
|
||||
tokens.push({ kind: "image", token, url, alt });
|
||||
// 说明:用空行包起来,尽量让 markdownToBlocks 生成“单独段落”,便于后续替换为 media block。
|
||||
return `\n\n${token}\n\n`;
|
||||
});
|
||||
|
||||
// 3) 重写内联链接(页面链接 -> /documents/<id>,附件链接 -> Convex url)
|
||||
line = line.replace(linkRe, (full, labelRaw, hrefRaw) => {
|
||||
const label = String(labelRaw ?? "");
|
||||
const href0 = stripAngleBrackets(stripMarkdownLinkTitle(String(hrefRaw ?? "").trim()));
|
||||
const href = isRemoteUrl(href0) ? href0 : stripLocalQueryAndHash(href0);
|
||||
if (isRemoteUrl(href)) return full;
|
||||
|
||||
const target = resolveRelativePosix(opts.mdDirRel, href);
|
||||
if (target.toLowerCase().endsWith(".md")) {
|
||||
const docId = opts.mdRelToDocId.get(target);
|
||||
if (docId) return `[${label}](/documents/${encodeURIComponent(docId)})`;
|
||||
return full;
|
||||
}
|
||||
|
||||
const asset = opts.uploadedUrlByRelAssetPath.get(target);
|
||||
if (asset) return `[${label}](${asset.url})`;
|
||||
return full;
|
||||
});
|
||||
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
return { markdown: out.join("\n"), tokens, warnings };
|
||||
}
|
||||
|
||||
function isSingleTokenParagraph(block: any, token: string): boolean {
|
||||
if (!block || typeof block !== "object") return false;
|
||||
if (!Array.isArray(block.content)) return false;
|
||||
if (block.content.length !== 1) return false;
|
||||
const node = block.content[0];
|
||||
if (!node || typeof node !== "object") return false;
|
||||
if (node.type !== "text") return false;
|
||||
return String(node.text ?? "").trim() === token;
|
||||
}
|
||||
|
||||
function replaceTokensInBlocks(opts: {
|
||||
blocks: any[];
|
||||
tokens: TokenRecord[];
|
||||
pageRefResolver: (rel: string) => { pageId: string; title: string } | null;
|
||||
mediaResolver: (url: string) => { props: Record<string, unknown> } | null;
|
||||
}): { blocks: any[]; unresolved: TokenRecord[] } {
|
||||
const tokenMap = new Map<string, TokenRecord>(opts.tokens.map((t) => [t.token, t]));
|
||||
const unresolved = new Set(opts.tokens.map((t) => t.token));
|
||||
|
||||
const walk = (items: any[]): any[] => {
|
||||
const out: any[] = [];
|
||||
for (const block of items) {
|
||||
const maybe =
|
||||
block && Array.isArray(block.content) && block.content.length === 1 && block.content[0]?.type === "text"
|
||||
? String(block.content[0].text ?? "").trim()
|
||||
: null;
|
||||
|
||||
if (maybe && tokenMap.has(maybe) && isSingleTokenParagraph(block, maybe)) {
|
||||
const token = tokenMap.get(maybe)!;
|
||||
unresolved.delete(maybe);
|
||||
|
||||
if (token.kind === "page") {
|
||||
const resolved = opts.pageRefResolver(token.targetMdRel);
|
||||
if (!resolved) {
|
||||
out.push(block);
|
||||
continue;
|
||||
}
|
||||
out.push({
|
||||
id: randomUUID(),
|
||||
type: "pageReference",
|
||||
props: { pageId: resolved.pageId, title: token.label || resolved.title || "无标题" },
|
||||
content: [],
|
||||
children: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.kind === "image" || token.kind === "file") {
|
||||
const resolved = opts.mediaResolver(token.url);
|
||||
if (!resolved) {
|
||||
out.push(block);
|
||||
continue;
|
||||
}
|
||||
out.push({ id: randomUUID(), type: "media", props: resolved.props, content: [], children: [] });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const next = { ...block };
|
||||
if (Array.isArray(next.children) && next.children.length > 0) {
|
||||
next.children = walk(next.children);
|
||||
}
|
||||
out.push(next);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
return { blocks: walk(opts.blocks), unresolved: opts.tokens.filter((t) => unresolved.has(t.token)) };
|
||||
}
|
||||
|
||||
async function uploadToConvexMedia(opts: {
|
||||
client: any;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
documentId: string;
|
||||
fileName: string;
|
||||
bytes: Uint8Array;
|
||||
mimeType: string;
|
||||
}): Promise<MediaAsset> {
|
||||
const assetId = randomUUID();
|
||||
const assetType = resolveAssetTypeByMime(opts.mimeType);
|
||||
|
||||
const uploadUrl = await opts.client.mutation(api.mediaAssets.generateUploadUrl, { userId: opts.userId });
|
||||
if (!uploadUrl || typeof uploadUrl !== "string") throw new Error("获取上传地址失败");
|
||||
|
||||
const uploadRes = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": opts.mimeType || "application/octet-stream" },
|
||||
body: Buffer.from(opts.bytes),
|
||||
});
|
||||
if (!uploadRes.ok) {
|
||||
const text = await uploadRes.text().catch(() => "");
|
||||
throw new Error(`上传到 Convex 失败:${uploadRes.status} ${text}`);
|
||||
}
|
||||
const uploadJson = (await uploadRes.json().catch(() => null)) as { storageId?: string } | null;
|
||||
const storageId = uploadJson?.storageId ?? "";
|
||||
if (!storageId) throw new Error("上传到 Convex 失败:缺少 storageId");
|
||||
|
||||
const created = await opts.client.mutation(api.mediaAssets.createWithStorage, {
|
||||
userId: opts.userId,
|
||||
storageId: storageId as any,
|
||||
asset: {
|
||||
id: assetId,
|
||||
workspace_id: opts.workspaceId,
|
||||
document_id: opts.documentId,
|
||||
asset_type: assetType,
|
||||
file_name: opts.fileName || null,
|
||||
file_size: opts.bytes.length,
|
||||
mime_type: opts.mimeType || null,
|
||||
},
|
||||
});
|
||||
|
||||
return created as unknown as MediaAsset;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isConvexEnabled()) {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
let auth;
|
||||
try {
|
||||
auth = await requireAuthContext();
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||
}
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
ensureDomShim();
|
||||
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
const isJson = contentType.includes("application/json");
|
||||
|
||||
let parentId: string | null = null;
|
||||
let rootMdPathInput: string | null = null;
|
||||
let zipPath: string | null = null;
|
||||
let extractedDir: string | null = null;
|
||||
let store: InputStore | null = null;
|
||||
|
||||
if (isJson) {
|
||||
const payload = (await request.json().catch(() => null)) as any;
|
||||
parentId = String(payload?.parentId ?? "").trim() || null;
|
||||
rootMdPathInput = String(payload?.rootMdPath ?? "").trim() || null;
|
||||
zipPath = String(payload?.zipPath ?? "").trim() || null;
|
||||
extractedDir = String(payload?.extractedDir ?? "").trim() || null;
|
||||
} else {
|
||||
const formData = await request.formData();
|
||||
parentId = String(formData.get("parentId") ?? "").trim() || null;
|
||||
rootMdPathInput = String(formData.get("rootMdPath") ?? "").trim() || null;
|
||||
const localZipPath = String(formData.get("zipPath") ?? "").trim();
|
||||
zipPath = localZipPath || null;
|
||||
|
||||
const file = formData.get("file");
|
||||
if (file instanceof File) {
|
||||
// 说明:浏览器上传大 ZIP(比如 1GB)会导致内存/超时问题,这里直接给出提示。
|
||||
const size = Number((file as any).size ?? 0);
|
||||
if (size > 200 * 1024 * 1024) {
|
||||
return NextResponse.json(
|
||||
{ error: "ZIP 太大,请使用“本地 ZIP 路径”方式导入(不要上传文件)" },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "当前仅支持“本地 ZIP 路径”导入(大文件避免上传)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (extractedDir) {
|
||||
store = await createDirStore(extractedDir);
|
||||
} else if (zipPath) {
|
||||
const stat = await fs.stat(zipPath);
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: "zipPath 不是文件" }, { status: 400 });
|
||||
}
|
||||
const extractDir = await extractZipToTemp(zipPath);
|
||||
// 说明:使用解压输出目录作为 store 根目录,使相对路径保持 dQeAax/... 这种形态。
|
||||
store = await createDirStore(extractDir);
|
||||
extractedDir = extractDir;
|
||||
} else {
|
||||
return NextResponse.json({ error: "缺少 zipPath(本地 ZIP 路径)" }, { status: 400 });
|
||||
}
|
||||
|
||||
const allFilePaths = await store.listFiles();
|
||||
const mdPaths = allFilePaths.filter((p) => p.toLowerCase().endsWith(".md"));
|
||||
if (mdPaths.length === 0) return NextResponse.json({ error: "未发现 .md 文件" }, { status: 400 });
|
||||
|
||||
let rootMdPath = rootMdPathInput ? normalizeZipPath(rootMdPathInput) : null;
|
||||
if (rootMdPath && !(await store.exists(rootMdPath))) {
|
||||
return NextResponse.json({ error: "rootMdPath 不存在", rootMdPath }, { status: 400 });
|
||||
}
|
||||
if (!rootMdPath) {
|
||||
const detected = detectRootMdPath(mdPaths);
|
||||
if (!detected.rootMdPath) {
|
||||
return NextResponse.json(
|
||||
{ error: "无法自动判断入口页面,请指定 rootMdPath", candidates: detected.candidates },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
rootMdPath = detected.rootMdPath;
|
||||
}
|
||||
|
||||
const scopePrefix = `${path.posix.dirname(rootMdPath)}/`;
|
||||
const { nodes, mdRelToKey } = buildImportNodes({ scopePrefix, rootMdPath, mdPaths });
|
||||
|
||||
let client;
|
||||
try {
|
||||
client = await getConvexAuthedHttpClient();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "未登录";
|
||||
const status = msg.includes("未登录") ? 401 : 500;
|
||||
return NextResponse.json({ error: msg }, { status });
|
||||
}
|
||||
|
||||
// workspace / accessScope:有 parentId 则继承,否则走默认 workspace
|
||||
let workspaceId = "";
|
||||
let accessScope: "private" | "shared" | "public" = "private";
|
||||
if (parentId) {
|
||||
const parentMeta = await client.query(api.documents.getMeta, { id: parentId });
|
||||
if (!parentMeta) return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
|
||||
workspaceId = String((parentMeta as any).workspace_id ?? "");
|
||||
accessScope = ((parentMeta as any).access_scope ?? "private") as typeof accessScope;
|
||||
if (!workspaceId) return NextResponse.json({ error: "无法读取父页面 workspaceId" }, { status: 400 });
|
||||
} else {
|
||||
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||
fallbackName: auth.email ?? auth.name ?? "我的空间",
|
||||
workspaceIdIfCreate: randomUUID(),
|
||||
});
|
||||
workspaceId = String((workspaceBootstrap as any)?.activeWorkspaceId ?? "");
|
||||
if (!workspaceId) return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 读取每个 md 的标题(优先 # 顶部标题),并写回 node.title
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== "doc") continue;
|
||||
const text = await store.readText(node.mdPath).catch(() => "");
|
||||
if (!text) continue;
|
||||
node.title = parseTopHeadingTitle(text) ?? node.title;
|
||||
}
|
||||
|
||||
// 先创建全部节点(documents / 合成目录页)
|
||||
const keyToDocId = new Map<string, string>();
|
||||
for (const node of nodes) {
|
||||
const docId = randomUUID();
|
||||
const parentDocId =
|
||||
node.key === rootMdPath
|
||||
? parentId
|
||||
: node.parentKey
|
||||
? keyToDocId.get(node.parentKey) ?? parentId
|
||||
: parentId;
|
||||
|
||||
const created = await client.mutation(api.documents.create, {
|
||||
id: docId,
|
||||
workspaceId,
|
||||
parentId: parentDocId ?? null,
|
||||
title: node.title,
|
||||
accessScope,
|
||||
content: [],
|
||||
});
|
||||
|
||||
if (!created || (created as any).id !== docId) {
|
||||
return NextResponse.json({ error: "创建页面失败(Convex 返回异常)" }, { status: 500 });
|
||||
}
|
||||
keyToDocId.set(node.key, docId);
|
||||
await ensureDocumentScaffold(docId, node.title);
|
||||
}
|
||||
|
||||
// 逐页导入内容(markdown -> blocks;图片/附件 -> media block + media_assets)
|
||||
const schema = BlockNoteSchema.create();
|
||||
const editor = BlockNoteEditor.create({ schema });
|
||||
const warnings: string[] = [];
|
||||
let importedCount = 0;
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== "doc") continue;
|
||||
const docId = keyToDocId.get(node.key) ?? "";
|
||||
if (!docId) continue;
|
||||
let markdown = await store.readText(node.mdPath);
|
||||
markdown = stripTopHeadingIfMatches(markdown, node.title);
|
||||
|
||||
const mdDirFull = `${path.posix.dirname(node.mdPath)}/`;
|
||||
const mdDirRel = mdDirFull.startsWith(scopePrefix) ? mdDirFull.slice(scopePrefix.length) : mdDirFull;
|
||||
|
||||
// 预扫:把本页引用到的本地资源上传到 Convex,并建立 relPath -> url 映射
|
||||
const uploadedUrlByRelAssetPath = new Map<
|
||||
string,
|
||||
{
|
||||
url: string;
|
||||
assetId: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
assetType: "image" | "video" | "audio" | "file";
|
||||
}
|
||||
>();
|
||||
|
||||
const localAssetPaths = new Set<string>();
|
||||
{
|
||||
const imgRe = /!\[[^\]]*\]\(([^)]+)\)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = imgRe.exec(markdown))) {
|
||||
const raw0 = stripAngleBrackets(stripMarkdownLinkTitle(String(m[1] ?? "").trim()));
|
||||
const raw = isRemoteUrl(raw0) ? raw0 : stripLocalQueryAndHash(raw0);
|
||||
if (!raw || isRemoteUrl(raw)) continue;
|
||||
const resolved = resolveRelativePosix(mdDirRel, raw);
|
||||
if (resolved) localAssetPaths.add(resolved);
|
||||
}
|
||||
}
|
||||
{
|
||||
const linkRe = /\[[^\]]+\]\(([^)]+)\)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = linkRe.exec(markdown))) {
|
||||
const raw0 = stripAngleBrackets(stripMarkdownLinkTitle(String(m[1] ?? "").trim()));
|
||||
const raw = isRemoteUrl(raw0) ? raw0 : stripLocalQueryAndHash(raw0);
|
||||
if (!raw || isRemoteUrl(raw)) continue;
|
||||
const resolved = resolveRelativePosix(mdDirRel, raw);
|
||||
if (!resolved) continue;
|
||||
if (resolved.toLowerCase().endsWith(".md")) continue;
|
||||
localAssetPaths.add(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
for (const relAssetPath of Array.from(localAssetPaths)) {
|
||||
const zipPath = `${scopePrefix}${relAssetPath}`.replace(/\/{2,}/g, "/");
|
||||
if (!(await store.exists(zipPath))) {
|
||||
warnings.push(`[${node.title}] 未找到资源文件:${relAssetPath}`);
|
||||
continue;
|
||||
}
|
||||
const absAssetPath = toFsPath(store.baseDir, zipPath);
|
||||
const stat = await fs.stat(absAssetPath).catch(() => null);
|
||||
if (stat && typeof stat.size === "number" && stat.size > MAX_ASSET_BYTES) {
|
||||
const mb = (stat.size / 1024 / 1024).toFixed(1);
|
||||
warnings.push(`[${node.title}] 资源过大已跳过(${mb}MB):${relAssetPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const bytes = await store.readBytes(zipPath);
|
||||
const fileName = safeBasename(relAssetPath);
|
||||
const mimeType = guessMimeType(fileName);
|
||||
const assetType = resolveAssetTypeByMime(mimeType);
|
||||
const created = await uploadToConvexMedia({
|
||||
client,
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
documentId: docId,
|
||||
fileName,
|
||||
bytes,
|
||||
mimeType,
|
||||
});
|
||||
uploadedUrlByRelAssetPath.set(relAssetPath, {
|
||||
url: String((created as any).file_url ?? ""),
|
||||
assetId: String((created as any).id ?? ""),
|
||||
fileName: String((created as any).file_name ?? fileName),
|
||||
mimeType: String((created as any).mime_type ?? mimeType),
|
||||
fileSize: Number((created as any).file_size ?? bytes.length),
|
||||
assetType,
|
||||
});
|
||||
}
|
||||
|
||||
const mdRelToDocId = new Map<string, string>();
|
||||
for (const [rel, key] of mdRelToKey.entries()) {
|
||||
const id = keyToDocId.get(key);
|
||||
if (id) mdRelToDocId.set(rel, id);
|
||||
}
|
||||
|
||||
const replaced = replaceImagesAndLinks({
|
||||
markdown,
|
||||
mdDirRel,
|
||||
mdRelToDocId,
|
||||
uploadedUrlByRelAssetPath,
|
||||
});
|
||||
markdown = replaced.markdown;
|
||||
warnings.push(...replaced.warnings);
|
||||
|
||||
let blocks: Block<any, any, any>[] = [];
|
||||
try {
|
||||
blocks = markdownToBlocks(markdown, editor.pmSchema) as any;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
warnings.push(`[${node.title}] Markdown 解析失败,已降级为纯文本:${msg}`);
|
||||
blocks = [
|
||||
{
|
||||
id: randomUUID(),
|
||||
type: "paragraph",
|
||||
props: { backgroundColor: "default", textColor: "default", textAlignment: "left" },
|
||||
content: [{ type: "text", text: markdown, styles: {} }],
|
||||
children: [],
|
||||
} as any,
|
||||
];
|
||||
}
|
||||
|
||||
const replacedBlocks = replaceTokensInBlocks({
|
||||
blocks: blocks as any[],
|
||||
tokens: replaced.tokens,
|
||||
pageRefResolver: (rel) => {
|
||||
const id = mdRelToDocId.get(rel) ?? "";
|
||||
if (!id) return null;
|
||||
return { pageId: id, title: path.posix.basename(rel, ".md") || "无标题" };
|
||||
},
|
||||
mediaResolver: (url) => {
|
||||
if (isRemoteUrl(url)) {
|
||||
const fileName = safeBasename(url);
|
||||
const mimeType = guessMimeType(fileName);
|
||||
const assetType = resolveAssetTypeByMime(mimeType);
|
||||
return {
|
||||
props: {
|
||||
fileUrl: url,
|
||||
thumbnailUrl: url,
|
||||
caption: "",
|
||||
captionAlign: "left",
|
||||
hasBorder: true,
|
||||
linkUrl: "",
|
||||
assetId: "",
|
||||
assetType,
|
||||
fileName,
|
||||
fileSize: 0,
|
||||
mimeType,
|
||||
width: 0,
|
||||
ocrStatus: "idle",
|
||||
documentId: docId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const relAssetPath = resolveRelativePosix(mdDirRel, url);
|
||||
const asset = uploadedUrlByRelAssetPath.get(relAssetPath);
|
||||
if (!asset || !asset.url) return null;
|
||||
return {
|
||||
props: {
|
||||
fileUrl: asset.url,
|
||||
thumbnailUrl: asset.url,
|
||||
caption: "",
|
||||
captionAlign: "left",
|
||||
hasBorder: true,
|
||||
linkUrl: "",
|
||||
assetId: asset.assetId || "",
|
||||
assetType: asset.assetType,
|
||||
fileName: asset.fileName || safeBasename(relAssetPath),
|
||||
fileSize: asset.fileSize || 0,
|
||||
mimeType: asset.mimeType || guessMimeType(asset.fileName || safeBasename(relAssetPath)),
|
||||
width: 0,
|
||||
ocrStatus: "idle",
|
||||
documentId: docId,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
for (const token of replacedBlocks.unresolved) {
|
||||
warnings.push(`[${node.title}] 未能解析 token:${token.token}`);
|
||||
}
|
||||
|
||||
// 说明:Convex 的 Json 不允许出现 undefined(尤其是表格类 block 里可能带有 columnWidths: [undefined])。
|
||||
// 用 JSON 序列化做一次深度净化:对象属性的 undefined 会被移除,数组里的 undefined 会变成 null。
|
||||
const sanitizedBlocks = JSON.parse(JSON.stringify(replacedBlocks.blocks)) as Json;
|
||||
await client.mutation(api.documents.updateContent, { id: docId, content: sanitizedBlocks });
|
||||
importedCount += 1;
|
||||
}
|
||||
|
||||
const rootId = keyToDocId.get(rootMdPath) ?? "";
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
rootDocumentId: rootId,
|
||||
importedCount,
|
||||
warnings: warnings.slice(0, 200),
|
||||
documentsBaseDir,
|
||||
extractedDir,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Wolai 导入失败", error);
|
||||
const message = error instanceof Error ? error.message : "导入失败";
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
return NextResponse.json({ error: message, stack }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ const setupOnlyOfficeGlobalErrorCapture = () => {
|
||||
window.__MNOTE_ONLYOFFICE_DOMPATCHED__ = true;
|
||||
try {
|
||||
const orig = Node.prototype.removeChild;
|
||||
// eslint-disable-next-line no-extend-native
|
||||
|
||||
(Node.prototype as any).removeChild = function removeChildPatched<T extends Node>(child: T): T {
|
||||
try {
|
||||
return orig.call(this, child) as T;
|
||||
@@ -195,7 +195,7 @@ const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUr
|
||||
};
|
||||
const origOpen = (win as any).XMLHttpRequest?.prototype?.open;
|
||||
if (typeof origOpen !== "function") return;
|
||||
// eslint-disable-next-line no-extend-native
|
||||
|
||||
(win as any).XMLHttpRequest.prototype.open = function openPatched(
|
||||
method: string,
|
||||
url: string,
|
||||
@@ -204,7 +204,7 @@ const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUr
|
||||
password?: string | null,
|
||||
) {
|
||||
const nextUrl = typeof url === "string" ? rewriteUrl(url) : url;
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
|
||||
return origOpen.call(this, method, nextUrl, async, user as any, password as any);
|
||||
};
|
||||
(win as any).__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true;
|
||||
@@ -225,7 +225,7 @@ const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUr
|
||||
const w = (f as HTMLIFrameElement).contentWindow;
|
||||
if (!w) continue;
|
||||
// 说明:同源时才能访问 location;跨域会抛异常,直接跳过。
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
|
||||
w.location?.origin;
|
||||
patchWindow(w);
|
||||
} catch {
|
||||
@@ -259,7 +259,7 @@ const docTypeFromExt = (ext: string) => {
|
||||
|
||||
const waitForDocEditorReady = async (timeoutMs = 120_000) => {
|
||||
const start = Date.now();
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
|
||||
while (true) {
|
||||
// @ts-expect-error ONLYOFFICE 全局对象
|
||||
const ok = Boolean(window.DocsAPI && window.DocsAPI.DocEditor);
|
||||
@@ -267,7 +267,7 @@ const waitForDocEditorReady = async (timeoutMs = 120_000) => {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error("等待 ONLYOFFICE DocEditor 初始化超时");
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
};
|
||||
@@ -606,7 +606,7 @@ export default function OnlyOfficePage() {
|
||||
// 说明:api.js 的 onload 并不代表 DocsAPI/DocEditor 已完全就绪(在慢网/高负载时会出现空白页)。
|
||||
// 因此这里额外等待 DocEditor 挂载,避免偶发“白屏但无错误”的体验。
|
||||
await waitForDocEditorReady(120_000);
|
||||
// eslint-disable-next-line new-cap,@typescript-eslint/no-explicit-any
|
||||
|
||||
const pluginConfigUrl = `${window.location.origin}/onlyoffice/plugins/agent-tools/config.json`;
|
||||
|
||||
const config: any = {
|
||||
@@ -708,7 +708,7 @@ export default function OnlyOfficePage() {
|
||||
config.editorConfig.token = editorConfigToken;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line new-cap,@typescript-eslint/no-explicit-any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
new (window as any).DocsAPI.DocEditor("onlyoffice-frame", config);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { clamp, MIN_AGENT_STEPS, MAX_AGENT_STEPS } from "@/lib/constants";
|
||||
|
||||
type ChatMsg = { role: "user" | "assistant"; content: string };
|
||||
type ToolLog =
|
||||
@@ -201,14 +202,14 @@ export function AiAgentPanel() {
|
||||
<input
|
||||
className="w-[72px] rounded border px-2 py-1 text-xs"
|
||||
type="number"
|
||||
min={1}
|
||||
max={24}
|
||||
min={MIN_AGENT_STEPS}
|
||||
max={MAX_AGENT_STEPS}
|
||||
step={1}
|
||||
value={maxSteps}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (!Number.isFinite(v)) return;
|
||||
setMaxSteps(Math.max(1, Math.min(24, Math.floor(v))));
|
||||
setMaxSteps(clamp(Math.floor(v), MIN_AGENT_STEPS, MAX_AGENT_STEPS));
|
||||
}}
|
||||
disabled={running}
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { clamp, MIN_AGENT_STEPS, MAX_AGENT_STEPS } from "@/lib/constants";
|
||||
|
||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||
|
||||
@@ -79,8 +80,6 @@ type ChatSession = {
|
||||
|
||||
type PanelPage = "chat" | "tools" | "history" | "account" | "settings";
|
||||
|
||||
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n));
|
||||
|
||||
const generateId = () => {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
|
||||
return `sess_${Math.random().toString(16).slice(2, 10)}`;
|
||||
@@ -194,8 +193,8 @@ export function DocumentAiAgentPanel({
|
||||
const p = (window.localStorage.getItem("doc_ai_provider") || "").trim();
|
||||
const m = window.localStorage.getItem("doc_ai_model") || "";
|
||||
const parsed = Number(stepsRaw);
|
||||
if (Number.isFinite(parsed) && parsed >= 1) {
|
||||
setMaxSteps(Math.max(1, Math.min(24, Math.floor(parsed))));
|
||||
if (Number.isFinite(parsed) && parsed >= MIN_AGENT_STEPS) {
|
||||
setMaxSteps(clamp(Math.floor(parsed), MIN_AGENT_STEPS, MAX_AGENT_STEPS));
|
||||
}
|
||||
if (p === "local" || p === "online") setAiProvider(p);
|
||||
if (typeof m === "string") setAiModel(m);
|
||||
@@ -270,7 +269,7 @@ export function DocumentAiAgentPanel({
|
||||
// ignore
|
||||
}
|
||||
// 只在 documentId 变化时读取一次
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
}, [documentId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -688,14 +687,14 @@ export function DocumentAiAgentPanel({
|
||||
<input
|
||||
className="w-[72px] rounded border px-2 py-1 text-xs"
|
||||
type="number"
|
||||
min={1}
|
||||
max={24}
|
||||
min={MIN_AGENT_STEPS}
|
||||
max={MAX_AGENT_STEPS}
|
||||
step={1}
|
||||
value={maxSteps}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (!Number.isFinite(v)) return;
|
||||
setMaxSteps(Math.max(1, Math.min(24, Math.floor(v))));
|
||||
setMaxSteps(clamp(Math.floor(v), MIN_AGENT_STEPS, MAX_AGENT_STEPS));
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
@@ -23,11 +23,12 @@ import { CustomSideMenu } from "./menus/CustomSideMenu";
|
||||
import { CustomSlashMenu } from "./menus/CustomSlashMenu";
|
||||
import { MoveEmbedPickerHost } from "@/components/documents/move-embed-picker-host";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { DocumentToc, type TocEntry } from "@/components/editor/document-toc";
|
||||
import { DocumentToc, type TocEntry } from "@/components/editor/document-toc";
|
||||
import { useSearchPaletteStore } from "@/store/search-palette";
|
||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
import type { ReferenceTarget } from "@/types/search";
|
||||
import FullScreenTableEditor from "@/components/online-table/FullScreenTableEditor";
|
||||
import { clamp, MIN_BLOCK_LEVEL, MAX_BLOCK_LEVEL } from "@/lib/constants";
|
||||
import { ASSETS_CHANGED_EVENT, emitAssetsChanged } from "@/lib/events";
|
||||
|
||||
interface BlockNoteEditorProps {
|
||||
@@ -64,7 +65,7 @@ const buildHeadingToc = (blocks: Block<CustomBlockSchema>[]): TocEntry[] => {
|
||||
const walk = (targetBlocks: Block<CustomBlockSchema>[]) => {
|
||||
targetBlocks.forEach((block) => {
|
||||
if (block.type === "heading") {
|
||||
const level = Math.min(5, Math.max(1, Number(block.props.level) || 1));
|
||||
const level = clamp(Number(block.props.level) || 1, MIN_BLOCK_LEVEL, MAX_BLOCK_LEVEL);
|
||||
counters[level - 1] += 1;
|
||||
for (let i = level; i < counters.length; i += 1) {
|
||||
counters[i] = 0;
|
||||
|
||||
@@ -656,9 +656,6 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
>
|
||||
查看原文件
|
||||
</DropdownMenuItem>
|
||||
{assetType === "file" && isOfficeDoc && (
|
||||
<DropdownMenuItem onClick={() => void openWithOnlyOffice()}>使用 ONLYOFFICE 打开</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
void downloadAsset();
|
||||
|
||||
@@ -273,7 +273,7 @@ export function MindmapAiAgentPanel({
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
}, [mindmapId]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2502,8 +2502,8 @@ const MindmapBlockView = ({
|
||||
const handleImageConfirm = async () => {
|
||||
const url = imageUrl.trim();
|
||||
// 获取原始图片尺寸,如果没有则使用默认值
|
||||
let width = Number(imageWidth) || 0;
|
||||
let height = Number(imageHeight) || 0;
|
||||
const width = Number(imageWidth) || 0;
|
||||
const height = Number(imageHeight) || 0;
|
||||
|
||||
if (!url) {
|
||||
window.alert("请输入图片链接");
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { ImagePickerProvider } from "@/components/media/image-picker-context";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DocumentAiAgentPanel } from "./DocumentAiAgentPanel";
|
||||
import { CONTENT_LOADING_DELAY_MS } from "@/lib/constants";
|
||||
|
||||
const BlockNoteEditor = dynamic(
|
||||
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
|
||||
@@ -96,7 +97,7 @@ export function DocumentContent({
|
||||
}
|
||||
}, [documentId, editorBridge, openTableId, router]);
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
useEffect(() => {
|
||||
setPageTitle(title ?? "无标题");
|
||||
}, [title]);
|
||||
@@ -108,7 +109,7 @@ export function DocumentContent({
|
||||
useEffect(() => {
|
||||
setStats(initialStats ?? defaultStats);
|
||||
}, [initialStats]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
@@ -128,12 +129,12 @@ export function DocumentContent({
|
||||
clearTimeout(contentLoadingTimerRef.current);
|
||||
contentLoadingTimerRef.current = null;
|
||||
}
|
||||
// 避免“秒闪”的加载提示:只有当加载超过短阈值时才显示提示
|
||||
// 避免"秒闪"的加载提示:只有当加载超过短阈值时才显示提示
|
||||
contentLoadingTimerRef.current = setTimeout(() => {
|
||||
if (!canceled) {
|
||||
setShowContentLoadingIndicator(true);
|
||||
}
|
||||
}, 200);
|
||||
}, CONTENT_LOADING_DELAY_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/documents/content?documentId=${encodeURIComponent(documentId)}`, {
|
||||
|
||||
@@ -503,7 +503,7 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
};
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.log("[FullScreenTableEditor] init luckysheet", { tableId, sheets: options.data });
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Bot, Settings, Wrench, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { clamp } from "@/lib/constants";
|
||||
|
||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||
|
||||
@@ -35,8 +36,6 @@ const ONLINE_MODELS = [
|
||||
"gemini-3-flash-preview",
|
||||
] as const;
|
||||
|
||||
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n));
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
|
||||
import { useAuthActions } from "@convex-dev/auth/react";
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
ArrowUpRight,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
LayoutGrid,
|
||||
Library,
|
||||
Link as LinkIcon,
|
||||
LogOut,
|
||||
MoreHorizontal,
|
||||
PanelRightOpen,
|
||||
Plus,
|
||||
@@ -178,6 +180,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
const isLoading = sidebarQuery.isLoading;
|
||||
const segments = useSelectedLayoutSegments();
|
||||
const router = useRouter();
|
||||
const { signOut } = useAuthActions();
|
||||
const activeId = segments?.[1] ?? "";
|
||||
const editorBridge = useEditorBridgeStore((state) => state.bridge);
|
||||
|
||||
@@ -186,6 +189,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => collectNodeIds(tree));
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false);
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
const [trashOpen, setTrashOpen] = useState(false);
|
||||
const [trashSearch, setTrashSearch] = useState("");
|
||||
const [trashTab, setTrashTab] = useState<"documents" | "assets">("documents");
|
||||
@@ -1729,6 +1733,26 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
[sidebarData.activeWorkspaceId, sidebarQuery],
|
||||
);
|
||||
|
||||
const handleSignOut = useCallback(async () => {
|
||||
if (signingOut) {
|
||||
return;
|
||||
}
|
||||
if (!window.confirm("确认退出登录吗?")) {
|
||||
return;
|
||||
}
|
||||
setSigningOut(true);
|
||||
try {
|
||||
await signOut();
|
||||
setWorkspaceMenuOpen(false);
|
||||
router.replace("/auth");
|
||||
router.refresh();
|
||||
} catch (error: any) {
|
||||
window.alert(`退出登录失败:${error?.message ?? "请稍后再试"}`);
|
||||
} finally {
|
||||
setSigningOut(false);
|
||||
}
|
||||
}, [router, signOut, signingOut]);
|
||||
|
||||
const openContextMenu = useCallback((event: React.MouseEvent, node: DocumentNode) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -1826,6 +1850,17 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<div className="border-t border-[#f1f1f1] p-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={() => void handleSignOut()}
|
||||
disabled={signingOut}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>{signingOut ? "正在退出..." : "退出登录"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery, useConvexAuth } from "convex/react";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { toBrowserAccessibleUrl } from "@/lib/url/browser-file-url";
|
||||
|
||||
/**
|
||||
* Convex 模式下的侧边栏数据 hook
|
||||
@@ -61,6 +62,12 @@ export function useConvexSidebarData(workspaceId: string): {
|
||||
shouldFetch ? undefined : "skip"
|
||||
);
|
||||
|
||||
const normalizeAssetUrls = (asset: MediaAsset): MediaAsset => {
|
||||
const fileUrl = toBrowserAccessibleUrl(asset.file_url) ?? asset.file_url;
|
||||
const thumbUrl = toBrowserAccessibleUrl(asset.thumbnail_url) ?? asset.thumbnail_url;
|
||||
return { ...asset, file_url: fileUrl, thumbnail_url: thumbUrl };
|
||||
};
|
||||
|
||||
// 组合数据,格式与 SidebarInitialData 一致
|
||||
const data: SidebarInitialData | null = useMemo(() => {
|
||||
// 当 skip 时,返回值是 undefined
|
||||
@@ -135,13 +142,13 @@ export function useConvexSidebarData(workspaceId: string): {
|
||||
workspaces: workspacesResult.workspaces,
|
||||
documents,
|
||||
trashedDocuments,
|
||||
trashedMediaAssets: (trashedMediaAssets ?? []) as MediaAsset[],
|
||||
trashedMediaAssets: ((trashedMediaAssets ?? []) as MediaAsset[]).map(normalizeAssetUrls),
|
||||
trashedMindmapAssets,
|
||||
mindmapDocs,
|
||||
mindmapAssets,
|
||||
mindmapAssetChildren: {},
|
||||
tableAssets: [],
|
||||
mediaAssets: (mediaAssets ?? []) as MediaAsset[],
|
||||
mediaAssets: ((mediaAssets ?? []) as MediaAsset[]).map(normalizeAssetUrls),
|
||||
};
|
||||
}, [
|
||||
currentUser,
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
import {
|
||||
MIN_API_TIMEOUT_MS,
|
||||
MAX_API_TIMEOUT_MS,
|
||||
DEFAULT_API_TIMEOUT_MS,
|
||||
MIN_COMPLETION_TOKENS,
|
||||
MAX_COMPLETION_TOKENS,
|
||||
} from "@/lib/constants";
|
||||
|
||||
export type OpenAiCompatibleChatMessage = {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
@@ -34,18 +42,18 @@ export const openAiCompatibleChat = async (
|
||||
messages: OpenAiCompatibleChatMessage[],
|
||||
opts: OpenAiCompatibleChatOptions,
|
||||
): Promise<{ text: string; raw: unknown }> => {
|
||||
const timeoutMs = Math.max(500, Math.min(120_000, opts.timeoutMs ?? 20_000));
|
||||
const timeoutMs = Math.max(MIN_API_TIMEOUT_MS, Math.min(MAX_API_TIMEOUT_MS, opts.timeoutMs ?? DEFAULT_API_TIMEOUT_MS));
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const url = `${opts.baseUrl.replace(/\/+$/, "")}/chat/completions`;
|
||||
const maxCompletionTokens =
|
||||
typeof opts.maxCompletionTokens === "number" && Number.isFinite(opts.maxCompletionTokens)
|
||||
? Math.max(64, Math.min(16_000, Math.floor(opts.maxCompletionTokens)))
|
||||
? Math.max(MIN_COMPLETION_TOKENS, Math.min(MAX_COMPLETION_TOKENS, Math.floor(opts.maxCompletionTokens)))
|
||||
: undefined;
|
||||
const maxTokens =
|
||||
typeof opts.maxTokens === "number" && Number.isFinite(opts.maxTokens)
|
||||
? Math.max(64, Math.min(16_000, Math.floor(opts.maxTokens)))
|
||||
? Math.max(MIN_COMPLETION_TOKENS, Math.min(MAX_COMPLETION_TOKENS, Math.floor(opts.maxTokens)))
|
||||
: undefined;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* API 工具函数
|
||||
* 提供统一的错误处理和响应解析
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* API 错误类
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
public details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的 API 错误响应格式
|
||||
*/
|
||||
export interface ApiErrorResponse {
|
||||
error: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 API 错误响应
|
||||
* @param message 错误消息
|
||||
* @param status HTTP 状态码
|
||||
* @param details 额外的错误详情
|
||||
* @returns NextResponse
|
||||
*/
|
||||
export function apiErrorResponse(
|
||||
message: string,
|
||||
status = 500,
|
||||
details?: unknown,
|
||||
): NextResponse<ApiErrorResponse> {
|
||||
const payload: ApiErrorResponse =
|
||||
typeof details === "undefined" ? { error: message } : { error: message, details };
|
||||
return NextResponse.json(payload, { status });
|
||||
}
|
||||
|
||||
/**
|
||||
* 常用错误响应的快捷方法
|
||||
*/
|
||||
export const errorResponses = {
|
||||
/** 400 - 请求参数错误 */
|
||||
badRequest: (message: string = "请求参数错误") => apiErrorResponse(message, 400),
|
||||
|
||||
/** 401 - 未登录 */
|
||||
unauthorized: (message: string = "未登录") => apiErrorResponse(message, 401),
|
||||
|
||||
/** 403 - 无权限 */
|
||||
forbidden: (message: string = "无权限访问") => apiErrorResponse(message, 403),
|
||||
|
||||
/** 404 - 资源不存在 */
|
||||
notFound: (message: string = "资源不存在") => apiErrorResponse(message, 404),
|
||||
|
||||
/** 500 - 服务器错误 */
|
||||
internalError: (message: string = "服务器错误") => apiErrorResponse(message, 500),
|
||||
|
||||
/** AI 配置错误 */
|
||||
aiConfigError: (provider: "online" | "local") =>
|
||||
apiErrorResponse(
|
||||
provider === "local"
|
||||
? "未找到本地 AI 配置(LOCAL_AI_BASE_URL/LOCAL_AI_MODEL 或 ai.local.md / ai-local.md)"
|
||||
: "未找到在线 AI 配置(ai.md 或 ONLINE_AI_* 环境变量)",
|
||||
500,
|
||||
),
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 处理 fetch 响应并解析 JSON
|
||||
* 如果响应不成功,抛出 ApiError
|
||||
* @param response Fetch Response 对象
|
||||
* @param defaultErrorMessage 默认错误消息
|
||||
* @returns 解析后的 JSON 数据
|
||||
*/
|
||||
export async function handleApiResponse<T>(
|
||||
response: Response,
|
||||
defaultErrorMessage: string = "请求失败",
|
||||
): Promise<T> {
|
||||
if (!response.ok) {
|
||||
let message = defaultErrorMessage;
|
||||
let details: unknown;
|
||||
|
||||
try {
|
||||
const payload = await response.json();
|
||||
if (payload && typeof payload === "object") {
|
||||
if ("error" in payload && typeof payload.error === "string") {
|
||||
message = payload.error;
|
||||
}
|
||||
if ("details" in payload) {
|
||||
details = payload.details;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// JSON 解析失败,使用默认消息
|
||||
}
|
||||
|
||||
throw new ApiError(message, response.status, details);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地解析 JSON,失败时返回 null
|
||||
* @param raw JSON 字符串
|
||||
* @returns 解析后的对象或 null
|
||||
*/
|
||||
export function safeParseJson<T = unknown>(raw: string | null | undefined): T | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证请求体是否包含必需字段
|
||||
* @param body 请求体对象
|
||||
* @param requiredFields 必需字段列表
|
||||
* @returns 如果验证失败,返回错误响应;否则返回 null
|
||||
*/
|
||||
export function validateRequestBody<T extends Record<string, unknown>>(
|
||||
body: T | null,
|
||||
requiredFields: (keyof T)[],
|
||||
): NextResponse<ApiErrorResponse> | null {
|
||||
if (!body) {
|
||||
return apiErrorResponse("请求体为空", 400);
|
||||
}
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (!(field in body) || body[field] === null || body[field] === undefined) {
|
||||
return apiErrorResponse(`缺少必需字段: ${String(field)}`, 400);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求中安全地获取 JSON
|
||||
* @param request Next.js Request 对象
|
||||
* @returns 解析后的 JSON 或 null
|
||||
*/
|
||||
export async function safeGetJsonBody<T = unknown>(
|
||||
request: Request,
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
return (await request.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 全局常量定义
|
||||
* 集中管理项目中的魔法数字和硬编码值
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// AI Agent 相关常量
|
||||
// ============================================
|
||||
|
||||
/** AI Agent 默认最大步数 */
|
||||
export const DEFAULT_AGENT_MAX_STEPS = 10;
|
||||
|
||||
/** AI Agent 最大步数限制 */
|
||||
export const MAX_AGENT_STEPS = 24;
|
||||
|
||||
/** AI Agent 最小步数 */
|
||||
export const MIN_AGENT_STEPS = 1;
|
||||
|
||||
/** 客户端工具默认超时时间(毫秒) */
|
||||
export const DEFAULT_CLIENT_TOOL_TIMEOUT_MS = 60_000;
|
||||
|
||||
// ============================================
|
||||
// 网络请求相关常量
|
||||
// ============================================
|
||||
|
||||
/** OpenAI 兼容 API 最小超时(毫秒) */
|
||||
export const MIN_API_TIMEOUT_MS = 500;
|
||||
|
||||
/** OpenAI 兼容 API 默认超时(毫秒) */
|
||||
export const DEFAULT_API_TIMEOUT_MS = 20_000;
|
||||
|
||||
/** OpenAI 兼容 API 最大超时(毫秒) */
|
||||
export const MAX_API_TIMEOUT_MS = 120_000;
|
||||
|
||||
/** OpenAI 最小输出 token 数 */
|
||||
export const MIN_COMPLETION_TOKENS = 64;
|
||||
|
||||
/** OpenAI 最大输出 token 数 */
|
||||
export const MAX_COMPLETION_TOKENS = 16_000;
|
||||
|
||||
// ============================================
|
||||
// 编辑器相关常量
|
||||
// ============================================
|
||||
|
||||
/** 内容加载延迟时间(毫秒) */
|
||||
export const CONTENT_LOADING_DELAY_MS = 200;
|
||||
|
||||
/** 编辑器块标题最小级别 */
|
||||
export const MIN_BLOCK_LEVEL = 1;
|
||||
|
||||
/** 编辑器块标题最大级别 */
|
||||
export const MAX_BLOCK_LEVEL = 5;
|
||||
|
||||
/** 零延迟 setTimeout(用于将任务推入事件循环) */
|
||||
export const ZERO_DELAY_MS = 0;
|
||||
|
||||
// ============================================
|
||||
// 思维导图相关常量
|
||||
// ============================================
|
||||
|
||||
/** 思维导图最大附件数量 */
|
||||
export const MAX_MINDMAP_ATTACHMENTS = 12;
|
||||
|
||||
/** 思维导图最大选中节点数 */
|
||||
export const MAX_SELECTED_NODES = 6;
|
||||
|
||||
// ============================================
|
||||
// RAG 搜索相关常量
|
||||
// ============================================
|
||||
|
||||
/** RAG 默认搜索结果数量 */
|
||||
export const DEFAULT_RAG_TOP_K = 12;
|
||||
|
||||
/** RAG 默认 chunk 结果数量 */
|
||||
export const DEFAULT_RAG_CHUNK_TOP_K = 12;
|
||||
|
||||
/** 文档搜索默认结果数量 */
|
||||
export const DEFAULT_DOCS_SEARCH_LIMIT = 12;
|
||||
|
||||
/** 文档读取默认最大字符数 */
|
||||
export const DEFAULT_DOCS_READ_MAX_CHARS = 2500;
|
||||
|
||||
/** 文档获取默认最大块数 */
|
||||
export const DEFAULT_DOC_GET_MAX_BLOCKS = 80;
|
||||
|
||||
/** 文档查找默认最大结果数 */
|
||||
export const DEFAULT_DOC_FIND_MAX_RESULTS = 8;
|
||||
|
||||
// ============================================
|
||||
// 资产/附件相关常量
|
||||
// ============================================
|
||||
|
||||
/** 思维导图从资产转换最大项目数 */
|
||||
export const DEFAULT_ASSET_TO_MINDMAP_MAX_ITEMS = 120;
|
||||
|
||||
/** 搜索默认结果数量 */
|
||||
export const DEFAULT_SEARCH_COUNT = 6;
|
||||
|
||||
// ============================================
|
||||
// UI 相关常量
|
||||
// ============================================
|
||||
|
||||
/** 表格嵌入预览最小高度(像素) */
|
||||
export const MIN_EMBED_HEIGHT = 120;
|
||||
|
||||
/** 表格嵌入预览最大高度(像素) */
|
||||
export const MAX_EMBED_HEIGHT = 600;
|
||||
|
||||
/** 表格嵌入预览默认高度(像素) */
|
||||
export const DEFAULT_EMBED_HEIGHT = 300;
|
||||
|
||||
/** 上下文菜单距离窗口边缘的最小内边距(像素) */
|
||||
export const CONTEXT_MENU_PADDING = 12;
|
||||
|
||||
// ============================================
|
||||
// 工具函数
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 将数值限制在指定范围内
|
||||
* @param n 输入值
|
||||
* @param min 最小值
|
||||
* @param max 最大值
|
||||
* @returns 限制后的值
|
||||
*/
|
||||
export const clamp = (n: number, min: number, max: number): number =>
|
||||
Math.max(min, Math.min(max, n));
|
||||
@@ -0,0 +1,130 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildDocumentTree, type DocumentRecord } from "./documents";
|
||||
|
||||
type TreeLike = { id: string; children: TreeLike[] };
|
||||
|
||||
function collectIds(nodes: TreeLike[]): string[] {
|
||||
const ids: string[] = [];
|
||||
const walk = (list: TreeLike[]) => {
|
||||
list.forEach((node) => {
|
||||
ids.push(node.id);
|
||||
if (node.children.length > 0) {
|
||||
walk(node.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
walk(nodes);
|
||||
return ids;
|
||||
}
|
||||
|
||||
describe("buildDocumentTree", () => {
|
||||
let warnSpy: ReturnType<typeof vi.spyOn> | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy?.mockRestore();
|
||||
warnSpy = null;
|
||||
});
|
||||
|
||||
it("应当去重重复的记录 id(根节点)", () => {
|
||||
const base: DocumentRecord = {
|
||||
access_scope: "private",
|
||||
id: "doc-1",
|
||||
workspace_id: "ws-1",
|
||||
title: "A",
|
||||
parent_id: null,
|
||||
sort_order: null,
|
||||
is_starred: null,
|
||||
is_template: false,
|
||||
created_at: "2025-01-01T00:00:00.000Z",
|
||||
updated_at: null,
|
||||
};
|
||||
|
||||
const tree = buildDocumentTree([base, { ...base, title: "B" }]);
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0].id).toBe("doc-1");
|
||||
expect(tree[0].title).toBe("B");
|
||||
});
|
||||
|
||||
it("应当去重重复的记录 id(子节点)", () => {
|
||||
const parent: DocumentRecord = {
|
||||
access_scope: "private",
|
||||
id: "doc-p",
|
||||
workspace_id: "ws-1",
|
||||
title: "P",
|
||||
parent_id: null,
|
||||
sort_order: null,
|
||||
is_starred: null,
|
||||
is_template: false,
|
||||
created_at: "2025-01-01T00:00:00.000Z",
|
||||
updated_at: null,
|
||||
};
|
||||
const child: DocumentRecord = {
|
||||
access_scope: "private",
|
||||
id: "doc-c",
|
||||
workspace_id: "ws-1",
|
||||
title: "C",
|
||||
parent_id: "doc-p",
|
||||
sort_order: null,
|
||||
is_starred: null,
|
||||
is_template: false,
|
||||
created_at: "2025-01-01T00:00:01.000Z",
|
||||
updated_at: null,
|
||||
};
|
||||
|
||||
const tree = buildDocumentTree([parent, child, { ...child, title: "C2" }]);
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0].children).toHaveLength(1);
|
||||
expect(tree[0].children[0].id).toBe("doc-c");
|
||||
expect(tree[0].children[0].title).toBe("C2");
|
||||
});
|
||||
|
||||
it("生成的树中不应出现重复 id", () => {
|
||||
const records: DocumentRecord[] = [
|
||||
{
|
||||
access_scope: "private",
|
||||
id: "a",
|
||||
workspace_id: "ws-1",
|
||||
title: "A",
|
||||
parent_id: null,
|
||||
sort_order: null,
|
||||
is_starred: null,
|
||||
is_template: false,
|
||||
created_at: "2025-01-01T00:00:00.000Z",
|
||||
updated_at: null,
|
||||
},
|
||||
{
|
||||
access_scope: "private",
|
||||
id: "b",
|
||||
workspace_id: "ws-1",
|
||||
title: "B",
|
||||
parent_id: "a",
|
||||
sort_order: null,
|
||||
is_starred: null,
|
||||
is_template: false,
|
||||
created_at: "2025-01-01T00:00:01.000Z",
|
||||
updated_at: null,
|
||||
},
|
||||
{
|
||||
access_scope: "private",
|
||||
id: "b",
|
||||
workspace_id: "ws-1",
|
||||
title: "B2",
|
||||
parent_id: "a",
|
||||
sort_order: null,
|
||||
is_starred: null,
|
||||
is_template: false,
|
||||
created_at: "2025-01-01T00:00:01.000Z",
|
||||
updated_at: "2025-01-01T00:00:02.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const tree = buildDocumentTree(records);
|
||||
const ids = collectIds(tree);
|
||||
const unique = new Set(ids);
|
||||
expect(unique.size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
@@ -16,13 +16,36 @@ export interface DocumentNode extends DocumentRecord {
|
||||
}
|
||||
|
||||
export function buildDocumentTree(records: DocumentRecord[]): DocumentNode[] {
|
||||
// 防御性处理:当上游数据意外包含重复 id 时,避免生成重复节点导致渲染 key 冲突。
|
||||
// 以“最后一次出现”为准(与原先 nodeMap.set 的覆盖行为保持一致)。
|
||||
const seen = new Set<string>();
|
||||
const duplicatedIds = new Set<string>();
|
||||
const uniqueRecords: DocumentRecord[] = [];
|
||||
for (let i = records.length - 1; i >= 0; i--) {
|
||||
const record = records[i];
|
||||
if (seen.has(record.id)) {
|
||||
duplicatedIds.add(record.id);
|
||||
continue;
|
||||
}
|
||||
seen.add(record.id);
|
||||
uniqueRecords.push(record);
|
||||
}
|
||||
uniqueRecords.reverse();
|
||||
|
||||
if (duplicatedIds.size > 0 && process.env.NODE_ENV !== "production") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[buildDocumentTree] 检测到重复文档 id(已自动去重):${Array.from(duplicatedIds).slice(0, 10).join(", ")}${duplicatedIds.size > 10 ? "…" : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
const nodeMap = new Map<string, DocumentNode>();
|
||||
records.forEach((record) => {
|
||||
uniqueRecords.forEach((record) => {
|
||||
nodeMap.set(record.id, { ...record, children: [] });
|
||||
});
|
||||
|
||||
const roots: DocumentNode[] = [];
|
||||
records.forEach((record) => {
|
||||
uniqueRecords.forEach((record) => {
|
||||
const node = nodeMap.get(record.id);
|
||||
if (!node) return;
|
||||
if (record.parent_id && nodeMap.has(record.parent_id)) {
|
||||
|
||||
@@ -91,6 +91,31 @@ describe("buildVisibleRows", () => {
|
||||
|
||||
expect(new Set(rows.map((r) => r.rowId)).size).toBe(rows.length);
|
||||
});
|
||||
|
||||
it("输入树包含重复 docId 时应自动去重", () => {
|
||||
const a = {
|
||||
access_scope: "private" as const,
|
||||
id: "a",
|
||||
workspace_id: "w",
|
||||
title: "A",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
is_starred: null,
|
||||
is_template: false,
|
||||
created_at: "",
|
||||
updated_at: null,
|
||||
children: [],
|
||||
};
|
||||
|
||||
const rows = buildVisibleRows({
|
||||
nodes: [a, a],
|
||||
expanded: new Set(["a"]),
|
||||
assetsByDoc: {},
|
||||
});
|
||||
|
||||
expect(rows.filter((r) => r.rowId === "doc:a").length).toBe(1);
|
||||
expect(new Set(rows.map((r) => r.rowId)).size).toBe(rows.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFileTreeRowId", () => {
|
||||
@@ -102,4 +127,3 @@ describe("parseFileTreeRowId", () => {
|
||||
expect(parseFileTreeRowId("doc:")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,8 +19,14 @@ export function buildVisibleRows({
|
||||
expandedAssetFolderIds?: Set<string>;
|
||||
}): FileTreeRow[] {
|
||||
const rows: FileTreeRow[] = [];
|
||||
const visitedDocIds = new Set<string>();
|
||||
|
||||
const walk = (node: DocumentNode, depth: number) => {
|
||||
// 防御性处理:上游数据异常时(例如同一 docId 在树中重复出现),避免生成重复 rowId 导致 React key 冲突。
|
||||
// 同时也能避免潜在的“循环引用/重复引用”导致的递归问题。
|
||||
if (visitedDocIds.has(node.id)) return;
|
||||
visitedDocIds.add(node.id);
|
||||
|
||||
const assets = assetsByDoc[node.id] ?? [];
|
||||
const hasChildren = node.children.length > 0 || assets.length > 0;
|
||||
const isExpanded = expanded.has(node.id);
|
||||
|
||||
@@ -62,9 +62,9 @@ const readFromPublicJson = (): Partial<MnoteRuntimeConfig> => {
|
||||
// 说明:桌面端与网页端共用 public/mnote-env.json 作为“公共环境文件”。
|
||||
// 桌面端运行时 process.cwd() 会被 Electron 切到 desktop-next 根目录。
|
||||
// 网页端运行时 process.cwd() 通常为 wolai-frontend。
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
|
||||
const fs = require("fs") as typeof import("fs");
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
|
||||
const path = require("path") as typeof import("path");
|
||||
|
||||
// 说明:Next standalone 产物的 server.js 会执行 `process.chdir(__dirname)`,
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 类型守卫和类型断言工具
|
||||
* 用于替代 `any` 类型,提供更安全的类型检查
|
||||
*/
|
||||
|
||||
/**
|
||||
* 检查值是否为普通对象(非 null、非数组)
|
||||
*/
|
||||
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查值是否为字符串
|
||||
*/
|
||||
export function isString(value: unknown): value is string {
|
||||
return typeof value === "string";
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查值是否为数字(有限)
|
||||
*/
|
||||
export function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查值是否为数组
|
||||
*/
|
||||
export function isArray<T = unknown>(value: unknown, itemGuard?: (item: unknown) => item is T): value is T[] {
|
||||
if (!Array.isArray(value)) return false;
|
||||
if (itemGuard) {
|
||||
return value.every(itemGuard);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查对象是否包含指定的属性
|
||||
*/
|
||||
export function hasProperty<K extends string>(
|
||||
obj: unknown,
|
||||
key: K,
|
||||
): obj is Record<K, unknown> {
|
||||
return isPlainObject(obj) && key in obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查对象是否包含多个指定的属性
|
||||
*/
|
||||
export function hasProperties<K extends string>(
|
||||
obj: unknown,
|
||||
keys: K[],
|
||||
): obj is Record<K, unknown> {
|
||||
if (!isPlainObject(obj)) return false;
|
||||
return keys.every(key => key in obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从对象中安全地提取字符串属性
|
||||
*/
|
||||
export function getStringProperty(obj: unknown, key: string, defaultValue: string = ""): string {
|
||||
if (!isPlainObject(obj)) return defaultValue;
|
||||
const value = obj[key];
|
||||
return isString(value) ? value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从对象中安全地提取数字属性
|
||||
*/
|
||||
export function getNumberProperty(obj: unknown, key: string, defaultValue: number = 0): number {
|
||||
if (!isPlainObject(obj)) return defaultValue;
|
||||
const value = obj[key];
|
||||
return isFiniteNumber(value) ? value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从对象中安全地提取布尔属性
|
||||
*/
|
||||
export function getBooleanProperty(obj: unknown, key: string, defaultValue: boolean = false): boolean {
|
||||
if (!isPlainObject(obj)) return defaultValue;
|
||||
const value = obj[key];
|
||||
return typeof value === "boolean" ? value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从对象中安全地提取数组属性
|
||||
*/
|
||||
export function getArrayProperty<T = unknown>(
|
||||
obj: unknown,
|
||||
key: string,
|
||||
defaultValue: T[] = [],
|
||||
): T[] {
|
||||
if (!isPlainObject(obj)) return defaultValue;
|
||||
const value = obj[key];
|
||||
return Array.isArray(value) ? value as T[] : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为 Supabase 行对象(包含 id 属性)
|
||||
*/
|
||||
export function isDatabaseRow(value: unknown): value is { id: string | number; [key: string]: unknown } {
|
||||
return isPlainObject(value) && ("id" in value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为 Supabase 行数组
|
||||
*/
|
||||
export function isDatabaseRowArray(value: unknown): value is Array<{ id: string | number; [key: string]: unknown }> {
|
||||
return isArray(value) && value.every(isDatabaseRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型断言:确保值不为 null/undefined
|
||||
*/
|
||||
export function assertNotNullOrUndefined<T>(value: T | null | undefined, message?: string): T {
|
||||
if (value === null || value === undefined) {
|
||||
throw new Error(message ?? "值不能为 null 或 undefined");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 unknown 转换为 Record<string, unknown>,如果类型不匹配则返回空对象
|
||||
*/
|
||||
export function toRecord(value: unknown): Record<string, unknown> {
|
||||
return isPlainObject(value) ? value : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地访问嵌套对象属性
|
||||
* @example getNestedValue(obj, 'a.b.c') === obj?.a?.b?.c
|
||||
*/
|
||||
export function getNestedValue<T = unknown>(
|
||||
obj: unknown,
|
||||
path: string,
|
||||
defaultValue?: T,
|
||||
): T | undefined {
|
||||
const keys = path.split(".");
|
||||
let current: unknown = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (!isPlainObject(current)) {
|
||||
return defaultValue;
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
|
||||
return current as T ?? defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查响应是否为错误响应
|
||||
*/
|
||||
export function isErrorResponse(value: unknown): value is { error: string; details?: unknown } {
|
||||
return isPlainObject(value) && isString(value.error);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 unknown 类型中提取工具参数
|
||||
* 用于 AI agent 工具调用时的类型安全
|
||||
*/
|
||||
export function getToolArgs(args: unknown): Record<string, unknown> {
|
||||
if (isPlainObject(args)) {
|
||||
return args;
|
||||
}
|
||||
// 如果是数组或其他类型,返回空对象
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为 AI Agent 消息
|
||||
*/
|
||||
export function isAgentMessage(value: unknown): value is { role: "user" | "assistant"; content: string } {
|
||||
return (
|
||||
isPlainObject(value) &&
|
||||
(value.role === "user" || value.role === "assistant") &&
|
||||
isString(value.content)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为 AI Agent 消息数组
|
||||
*/
|
||||
export function isAgentMessageArray(value: unknown): value is Array<{ role: "user" | "assistant"; content: string }> {
|
||||
return isArray(value) && value.every(isAgentMessage);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
const isLocalHostname = (hostname: string) =>
|
||||
hostname === "127.0.0.1" || hostname === "localhost" || hostname === "host.docker.internal";
|
||||
|
||||
const base64UrlEncodeUtf8 = (input: string) => {
|
||||
const bytes = new TextEncoder().encode(input);
|
||||
let binary = "";
|
||||
bytes.forEach((b) => {
|
||||
binary += String.fromCharCode(b);
|
||||
});
|
||||
const b64 = btoa(binary);
|
||||
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
};
|
||||
|
||||
/**
|
||||
* 将“仅本机可达”的 URL(例如 http://127.0.0.1:3210/api/storage/...)转换为浏览器可访问的地址。
|
||||
*
|
||||
* - 本地访问(页面本身是 localhost/127)时不改写
|
||||
* - 外网访问时,若目标是 localhost/127/host.docker.internal,则改为走 /api/onlyoffice/proxy 由 Next 服务端回源
|
||||
*/
|
||||
export const toBrowserAccessibleUrl = (rawUrl: string | null | undefined): string | null => {
|
||||
const input = String(rawUrl ?? "").trim();
|
||||
if (!input) return rawUrl ?? null;
|
||||
if (typeof window === "undefined") return input;
|
||||
|
||||
try {
|
||||
if (input.startsWith("/api/onlyoffice/proxy")) return input;
|
||||
const pageHost = window.location.hostname;
|
||||
if (isLocalHostname(pageHost)) return input;
|
||||
|
||||
const u = new URL(input);
|
||||
if (!isLocalHostname(u.hostname)) return input;
|
||||
|
||||
const proxy = new URL("/api/onlyoffice/proxy", window.location.origin);
|
||||
proxy.searchParams.set("u", base64UrlEncodeUtf8(input));
|
||||
return proxy.toString();
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
};
|
||||
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
declare module "jsdom" {
|
||||
export class JSDOM {
|
||||
constructor(html?: string, options?: unknown);
|
||||
// 说明:仅用于服务端导入时的最小类型声明(用于 window/document/DOMParser 等 DOM shim)。
|
||||
// 若需更完整的类型能力,可改为使用 jsdom 自带的类型定义。
|
||||
window: Window & typeof globalThis;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user