From c7b0cfb92b2ca68d4181669d0334930249fdda2f Mon Sep 17 00:00:00 2001 From: gavindiaz Date: Sat, 24 Oct 2026 03:12:20 +0800 Subject: [PATCH] feat: add BookMate AI reading workspace and full EPUB reader --- .gitignore | 68 +- README.md | 53 +- backend/.env.example | 15 + AGENTS.md => backend/AGENTS.md | 0 backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/books.py | 146 + backend/app/api/chat.py | 356 ++ backend/app/api/models.py | 183 + backend/app/core/__init__.py | 0 backend/app/core/config.py | 43 + backend/app/core/database.py | 33 + backend/app/epub/__init__.py | 0 backend/app/epub/cfi.py | 37 + backend/app/epub/chunker.py | 97 + backend/app/epub/cleaner.py | 187 + backend/app/epub/parser.py | 207 + backend/app/epub/toc.py | 216 + backend/app/llm/__init__.py | 0 backend/app/llm/hub.py | 277 ++ backend/app/main.py | 42 + backend/app/models/__init__.py | 0 backend/app/models/ai_model.py | 28 + backend/app/models/book.py | 60 + backend/app/models/chat.py | 43 + backend/app/rag/__init__.py | 0 backend/app/rag/engine.py | 286 ++ backend/app/rag/vector.py | 218 + backend/app/schemas/__init__.py | 0 backend/app/schemas/book.py | 56 + backend/requirements.txt | 16 + backend/test_book_file.py | 57 + frontend/.gitignore | 41 + frontend/AGENTS.md | 9 + frontend/README.md | 36 + .../eslint.config.mjs | 0 next.config.ts => frontend/next.config.ts | 4 +- .../package-lock.json | 3510 ++++++++++++++++- package.json => frontend/package.json | 17 +- {public => frontend/public}/file.svg | 0 {public => frontend/public}/globe.svg | 0 {public => frontend/public}/next.svg | 0 {public => frontend/public}/vercel.svg | 0 {public => frontend/public}/window.svg | 0 {src => frontend/src}/app/favicon.ico | Bin frontend/src/app/globals.css | 128 + frontend/src/app/layout.tsx | 15 + frontend/src/app/page.tsx | 899 +++++ .../src/components/CitationReaderDialog.tsx | 112 + frontend/src/components/CodeBlock.tsx | 42 + frontend/src/components/EpubReader.tsx | 195 + frontend/src/components/MarkdownRenderer.tsx | 130 + frontend/src/components/MermaidDiagram.tsx | 55 + frontend/src/components/MindMap.tsx | 494 +++ .../src/components/ModelManagerDialog.tsx | 469 +++ frontend/src/lib/api.ts | 183 + tsconfig.json => frontend/tsconfig.json | 0 src/app/globals.css | 26 - src/app/layout.tsx | 29 - src/app/page.tsx | 69 - 60 files changed, 8996 insertions(+), 191 deletions(-) create mode 100644 backend/.env.example rename AGENTS.md => backend/AGENTS.md (100%) create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/books.py create mode 100644 backend/app/api/chat.py create mode 100644 backend/app/api/models.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/database.py create mode 100644 backend/app/epub/__init__.py create mode 100644 backend/app/epub/cfi.py create mode 100644 backend/app/epub/chunker.py create mode 100644 backend/app/epub/cleaner.py create mode 100644 backend/app/epub/parser.py create mode 100644 backend/app/epub/toc.py create mode 100644 backend/app/llm/__init__.py create mode 100644 backend/app/llm/hub.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/ai_model.py create mode 100644 backend/app/models/book.py create mode 100644 backend/app/models/chat.py create mode 100644 backend/app/rag/__init__.py create mode 100644 backend/app/rag/engine.py create mode 100644 backend/app/rag/vector.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/book.py create mode 100644 backend/requirements.txt create mode 100644 backend/test_book_file.py create mode 100644 frontend/.gitignore create mode 100644 frontend/AGENTS.md create mode 100644 frontend/README.md rename eslint.config.mjs => frontend/eslint.config.mjs (100%) rename next.config.ts => frontend/next.config.ts (82%) rename package-lock.json => frontend/package-lock.json (63%) rename package.json => frontend/package.json (56%) rename {public => frontend/public}/file.svg (100%) rename {public => frontend/public}/globe.svg (100%) rename {public => frontend/public}/next.svg (100%) rename {public => frontend/public}/vercel.svg (100%) rename {public => frontend/public}/window.svg (100%) rename {src => frontend/src}/app/favicon.ico (100%) create mode 100644 frontend/src/app/globals.css create mode 100644 frontend/src/app/layout.tsx create mode 100644 frontend/src/app/page.tsx create mode 100644 frontend/src/components/CitationReaderDialog.tsx create mode 100644 frontend/src/components/CodeBlock.tsx create mode 100644 frontend/src/components/EpubReader.tsx create mode 100644 frontend/src/components/MarkdownRenderer.tsx create mode 100644 frontend/src/components/MermaidDiagram.tsx create mode 100644 frontend/src/components/MindMap.tsx create mode 100644 frontend/src/components/ModelManagerDialog.tsx create mode 100644 frontend/src/lib/api.ts rename tsconfig.json => frontend/tsconfig.json (100%) delete mode 100644 src/app/globals.css delete mode 100644 src/app/layout.tsx delete mode 100644 src/app/page.tsx diff --git a/.gitignore b/.gitignore index 5ef6a52..1beff95 100644 --- a/.gitignore +++ b/.gitignore @@ -1,41 +1,37 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/versions - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store +# Local configuration and credentials +.env +.env.* +!.env.example *.pem +*.key -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* +# Python +.venv/ +venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ -# env files (can opt-in for committing if needed) -.env* - -# vercel -.vercel - -# typescript +# Dependencies and generated output +node_modules/ +.next/ +out/ +build/ +coverage/ *.tsbuildinfo next-env.d.ts + +# Local application data (includes book files and saved model credentials) +backend/data/ +backend/uploads/ +*.db +*.db-* +*.sqlite* +*.epub + +# Local tooling +.idea/ +.vscode/ +.DS_Store +Thumbs.db +*.log diff --git a/README.md b/README.md index e215bc4..e94e674 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,45 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# BookMate -## Getting Started +基于 FastAPI 和 Next.js 的 AI 读书助手。支持 EPUB 导入、围绕书籍内容对话、原文引用和完整 EPUB 网页阅读。 -First, run the development server: +## 本地启动 -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +后端(Python 3.12): + +```powershell +cd backend +python -m venv .venv +.venv/Scripts/python.exe -m pip install -r requirements.txt +Copy-Item .env.example .env +.venv/Scripts/python.exe -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +在 `.env` 配置模型 API,或启动后在网页的模型管理中添加模型。数据库和上传目录会自动创建。未配置向量服务时使用关键词检索。 -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +另开终端启动前端(Node.js 20.9+): -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +```powershell +cd frontend +npm ci +npm run dev +``` -## Learn More +访问 http://localhost:3000。前端默认连接 http://localhost:8000/api。 -To learn more about Next.js, take a look at the following resources: +## 阅读与对话 -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +导入 EPUB 后,在书架或聊天页面点击「阅读全书」。阅读器支持目录、翻页、字号调整,以及在当前浏览器保存阅读位置。选中文字后可将原文带入 AI 聊天输入框。 -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## 验证 -## Deploy on Vercel +```powershell +cd backend +.venv/Scripts/python.exe -B -m unittest test_book_file +``` -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +```powershell +cd frontend +npm run build +``` -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +本地 `.env`、模型凭据、数据库、上传书籍和构建产物不纳入版本控制。 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..fd79e01 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,15 @@ +# Optional: configure providers here, or add a model from the web UI. +OPENAI_API_KEY= +OPENAI_BASE_URL=https://api.openai.com/v1 +OPENAI_MODEL=gpt-4o + +ANTHROPIC_API_KEY= +ANTHROPIC_MODEL=claude-sonnet-4-20250514 + +DEEPSEEK_API_KEY= +DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 +DEEPSEEK_MODEL=deepseek-chat + +# Optional embedding/reranking; empty keys use keyword retrieval. +EMBEDDING_API_KEY= +RERANK_API_KEY= diff --git a/AGENTS.md b/backend/AGENTS.md similarity index 100% rename from AGENTS.md rename to backend/AGENTS.md diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/books.py b/backend/app/api/books.py new file mode 100644 index 0000000..379a764 --- /dev/null +++ b/backend/app/api/books.py @@ -0,0 +1,146 @@ +import os +import uuid +from pathlib import Path +import aiofiles +from fastapi import APIRouter, UploadFile, File, Depends, HTTPException, BackgroundTasks +from fastapi.responses import FileResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func + +from app.core.database import get_db, async_session +from app.core.config import settings +from app.models.book import Book, Chapter, Segment +from app.schemas.book import BookOut, BookDetailOut, ChapterOut, SegmentOut +from app.epub.parser import parse_epub +from app.rag.vector import ensure_embeddings + +router = APIRouter(prefix="/books", tags=["books"]) + + +async def _embed_book_in_background(book_id: str): + """上传后后台批量生成向量;失败时状态记 failed,首次对话会懒补。""" + async with async_session() as session: + try: + await ensure_embeddings(session, book_id) + except Exception: + pass + + +@router.post("", response_model=BookOut) +async def upload_book( + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + background_tasks: BackgroundTasks = BackgroundTasks(), +): + if not file.filename or not file.filename.lower().endswith(".epub"): + raise HTTPException(400, "仅支持 .epub 文件") + + upload_dir = settings.upload_path + ext = ".epub" + filename = f"{uuid.uuid4().hex}{ext}" + file_path = upload_dir / filename + async with aiofiles.open(file_path, "wb") as f: + content = await file.read() + await f.write(content) + + try: + book = await parse_epub(str(file_path), db) + await db.commit() + await db.refresh(book) + background_tasks.add_task(_embed_book_in_background, book.id) + return book + except ValueError as e: + os.remove(file_path) + raise HTTPException(409, str(e)) + except Exception as e: + os.remove(file_path) + raise HTTPException(500, f"解析失败: {str(e)}") + + +@router.get("", response_model=list[BookOut]) +async def list_books(db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Book).order_by(Book.created_at.desc())) + return result.scalars().all() + + +@router.get("/{book_id}", response_model=BookDetailOut) +async def get_book(book_id: str, db: AsyncSession = Depends(get_db)): + book = await db.get(Book, book_id) + if not book: + raise HTTPException(404, "书不存在") + + ch_count = await db.scalar( + select(func.count()).select_from(Chapter).where(Chapter.book_id == book_id) + ) + seg_count = await db.scalar( + select(func.count()).select_from(Segment).where(Segment.book_id == book_id) + ) + + return BookDetailOut( + id=book.id, + filename=book.filename, + title=book.title, + author=book.author, + language=book.language, + char_count=book.char_count, + status=book.status, + created_at=book.created_at, + chapter_count=ch_count or 0, + segment_count=seg_count or 0, + ) + + +@router.get("/{book_id}/chapters", response_model=list[ChapterOut]) +async def get_chapters(book_id: str, db: AsyncSession = Depends(get_db)): + book = await db.get(Book, book_id) + if not book: + raise HTTPException(404, "书不存在") + result = await db.execute( + select(Chapter).where(Chapter.book_id == book_id).order_by(Chapter.order_idx) + ) + return result.scalars().all() + + +@router.get("/{book_id}/file") +async def read_book_file(book_id: str, db: AsyncSession = Depends(get_db)): + """供浏览器阅读原始 EPUB,保留正文、图片和出版排版。""" + book = await db.get(Book, book_id) + if not book: + raise HTTPException(404, "书不存在") + file_path = Path(book.file_path).resolve() + upload_root = Path(settings.upload_dir).resolve() + if not file_path.is_relative_to(upload_root) or file_path.suffix.lower() != ".epub": + raise HTTPException(404, "书籍文件不可用") + if not file_path.is_file(): + raise HTTPException(404, "原始 EPUB 文件已丢失,请重新导入") + return FileResponse( + file_path, + media_type="application/epub+zip", + headers={"Cache-Control": "private, no-cache", "X-Content-Type-Options": "nosniff"}, + ) + + +@router.get("/{book_id}/segments", response_model=list[SegmentOut]) +async def get_segments(book_id: str, chapter_id: str | None = None, db: AsyncSession = Depends(get_db)): + book = await db.get(Book, book_id) + if not book: + raise HTTPException(404, "书不存在") + q = select(Segment).where(Segment.book_id == book_id) + if chapter_id: + q = q.where(Segment.chapter_id == chapter_id) + q = q.order_by(Segment.order_idx) + result = await db.execute(q) + return result.scalars().all() + + +@router.delete("/{book_id}") +async def delete_book(book_id: str, db: AsyncSession = Depends(get_db)): + book = await db.get(Book, book_id) + if not book: + raise HTTPException(404, "书不存在") + file_path = book.file_path + await db.delete(book) + await db.commit() + if file_path and os.path.exists(file_path): + os.remove(file_path) + return {"deleted": book_id} diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py new file mode 100644 index 0000000..2606788 --- /dev/null +++ b/backend/app/api/chat.py @@ -0,0 +1,356 @@ +import json +import logging +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from pydantic import BaseModel + +from app.core.database import get_db +from app.models.book import Book, Chapter, Segment +from app.models.chat import Conversation, Message +from app.llm.hub import resolve_provider +from app.rag.engine import ( + search_segments, build_chat_prompt, parse_citations, + estimate_tokens, match_chapters, _trim_messages, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/chat", tags=["chat"]) + + +class CreateConversationRequest(BaseModel): + book_id: str + title: str | None = None + model: str = "claude" + + +class SendMessageRequest(BaseModel): + content: str + model: str | None = None + # 被重试的用户消息 id:提供时不新建 user 消息,新 AI 回答作为该问题的新版本 + # (parent_message_id 指向它),历史上下文只取该问题之前的对话 + reply_to: str | None = None + + +class ConversationOut(BaseModel): + id: str + book_id: str + title: str | None + model: str + created_at: str + + class Config: + from_attributes = True + + +class MessageOut(BaseModel): + id: str + conversation_id: str + parent_message_id: str | None + role: str + content: str + citations: list | None + model: str | None + created_at: str + + class Config: + from_attributes = True + + +@router.get("/conversations", response_model=list[ConversationOut]) +async def list_conversations(book_id: str | None = None, db: AsyncSession = Depends(get_db)): + q = select(Conversation).order_by(Conversation.updated_at.desc()) + if book_id: + q = q.where(Conversation.book_id == book_id) + result = await db.execute(q) + convs = result.scalars().all() + return [ConversationOut( + id=c.id, book_id=c.book_id, title=c.title, model=c.model, + created_at=c.created_at.isoformat() if c.created_at else "", + ) for c in convs] + + +@router.post("/conversations", response_model=ConversationOut) +async def create_conversation(req: CreateConversationRequest, db: AsyncSession = Depends(get_db)): + book = await db.get(Book, req.book_id) + if not book: + raise HTTPException(404, "书不存在") + conv = Conversation(book_id=req.book_id, title=req.title or f"与《{book.title}》的对话", model=req.model) + db.add(conv) + await db.commit() + await db.refresh(conv) + return ConversationOut( + id=conv.id, book_id=conv.book_id, title=conv.title, model=conv.model, + created_at=conv.created_at.isoformat() if conv.created_at else "", + ) + + +@router.get("/conversations/{conv_id}/messages", response_model=list[MessageOut]) +async def get_messages(conv_id: str, db: AsyncSession = Depends(get_db)): + conv = await db.get(Conversation, conv_id) + if not conv: + raise HTTPException(404, "会话不存在") + result = await db.execute( + select(Message).where(Message.conversation_id == conv_id).order_by(Message.created_at) + ) + msgs = result.scalars().all() + return [_msg_to_out(m) for m in msgs] + + +@router.post("/conversations/{conv_id}/messages") +async def send_message(conv_id: str, req: SendMessageRequest, db: AsyncSession = Depends(get_db)): + conv = await db.get(Conversation, conv_id) + if not conv: + raise HTTPException(404, "会话不存在") + + book = await db.get(Book, conv.book_id) + if not book: + raise HTTPException(404, "书不存在") + + model_name = req.model or conv.model + + # 重试:复用被重试的用户消息(不新建),新回答作为它的新版本 + reply_user: Message | None = None + if req.reply_to: + reply_user = await db.get(Message, req.reply_to) + if not reply_user or reply_user.role != "user" or reply_user.conversation_id != conv_id: + raise HTTPException(400, "reply_to 必须是本会话内的一条用户消息") + user_msg = reply_user + else: + user_msg = Message(conversation_id=conv_id, role="user", content=req.content, model=model_name) + db.add(user_msg) + await db.commit() + + chapters = (await db.execute( + select(Chapter).where(Chapter.book_id == book.id).order_by(Chapter.order_idx) + )).scalars().all() + + # 章节标题匹配:命中章节时把整章原文注入 prompt(解决「第X章讲了什么」这类问题) + matched = match_chapters(list(chapters), req.content) + chapter_segments = [] + if matched: + result = await db.execute( + select(Segment) + .where(Segment.chapter_id.in_([c.id for c in matched])) + .order_by(Segment.order_idx) + ) + chapter_segments = list(result.scalars().all()) + + segments = await search_segments( + db, book.id, req.content, top_k=15, chapters=list(chapters) + ) + + history_rows = (await db.execute( + select(Message).where(Message.conversation_id == conv_id).order_by(Message.created_at) + )).scalars().all() + + # 历史上下文:重试时只取被重试问题之前的对话(旧版本回答不进 prompt); + # 正常发送时排除当前这轮 user 消息即可。 + if reply_user is not None: + row_ids = {m.id for m in history_rows} + if reply_user.id in row_ids: + idx = next(i for i, m in enumerate(history_rows) if m.id == reply_user.id) + history = [{"role": m.role, "content": m.content} for m in history_rows[:idx]] + else: + # 数据异常(如 SRU),退回为全量历史 + history = [{"role": m.role, "content": m.content} for m in history_rows] + else: + history = [{"role": m.role, "content": m.content} for m in history_rows[:-1]] + + messages = build_chat_prompt( + book, list(chapters), segments, history, req.content, + chapter_segments=chapter_segments, + ) + + total_tokens = estimate_tokens("".join(m["content"] for m in messages)) + provider = await resolve_provider(db, model_name) + if provider is None: + raise HTTPException(400, f"模型 {model_name} 未配置,请先在页面「管理模型」中添加或选择可用模型") + if total_tokens > provider.max_context * 0.8: + # 内容感知裁剪:保留 system(含检索原文 + 整章注入),从最旧历史开始丢 + messages = _trim_messages(messages, int(provider.max_context * 0.8)) + + async def stream_response(): + full_content = "" + try: + async for token in provider.chat_stream(messages, temperature=0.7): + full_content += token + yield f"data: {json.dumps({'type': 'token', 'content': token}, ensure_ascii=False)}\n\n" + except Exception as e: + logger.exception("流式生成失败(conversation=%s)", conv_id) + yield f"data: {json.dumps({'type': 'error', 'content': str(e)}, ensure_ascii=False)}\n\n" + return + + parsed = parse_citations(full_content, segments) + + assistant_msg = Message( + conversation_id=conv_id, + parent_message_id=user_msg.id, + role="assistant", + content=parsed["text"], + citations=json.dumps(parsed["citations"], ensure_ascii=False), + model=model_name, + ) + db.add(assistant_msg) + await db.commit() + await db.refresh(assistant_msg) + + yield f"data: {json.dumps({'type': 'done', 'message_id': assistant_msg.id, 'citations': parsed['citations']}, ensure_ascii=False)}\n\n" + + return StreamingResponse(stream_response(), media_type="text/event-stream") + + +@router.post("/messages/{msg_id}/retry") +async def retry_message(msg_id: str, db: AsyncSession = Depends(get_db)): + msg = await db.get(Message, msg_id) + if not msg or msg.role != "user": + raise HTTPException(400, "只能对用户消息重试") + # 走 reply_to 语义:不新建 user 消息,而是给该问题追加一个新版本回答 + return await send_message( + msg.conversation_id, + SendMessageRequest(content=msg.content, model=msg.model, reply_to=msg.id), + db, + ) + + +@router.get("/segments/{segment_id}/context") +async def segment_context( + segment_id: str, + before: int = Query(2, ge=0, le=6), + after: int = Query(2, ge=0, le=6), + db: AsyncSession = Depends(get_db), +): + """引用段落的阅读上下文:章节标题 + 前后段落,供前端「阅读原文」弹层。""" + seg = await db.get(Segment, segment_id) + if not seg: + raise HTTPException(404, "段落不存在") + ch = await db.get(Chapter, seg.chapter_id) + + prev_rows = await db.execute( + select(Segment.id, Segment.text, Segment.order_idx, Segment.cfi) + .where(Segment.chapter_id == seg.chapter_id, Segment.order_idx < seg.order_idx) + .order_by(Segment.order_idx.desc()) + .limit(before) + ) + next_rows = await db.execute( + select(Segment.id, Segment.text, Segment.order_idx, Segment.cfi) + .where(Segment.chapter_id == seg.chapter_id, Segment.order_idx > seg.order_idx) + .order_by(Segment.order_idx) + .limit(after) + ) + prev_list = [{"id": r[0], "text": r[1], "order_idx": r[2], "cfi": r[3]} for r in prev_rows.all()] + next_list = [{"id": r[0], "text": r[1], "order_idx": r[2], "cfi": r[3]} for r in next_rows.all()] + prev_list.reverse() # 保持阅读顺序 + + return { + "segment": {"id": seg.id, "text": seg.text, "order_idx": seg.order_idx, "cfi": seg.cfi}, + "chapter": {"id": ch.id, "title": ch.title, "level": ch.level} if ch else None, + "before": prev_list, + "after": next_list, + } + + +@router.get("/search") +async def search_book(book_id: str, q: str = Query(min_length=1), db: AsyncSession = Depends(get_db)): + segments = await search_segments(db, book_id, q, top_k=10) + return [ + {"id": s.id, "text": s.text[:300], "cfi": s.cfi, "char_count": s.char_count} + for s in segments + ] + + +@router.delete("/messages/{msg_id}") +async def delete_message(msg_id: str, db: AsyncSession = Depends(get_db)): + msg = await db.get(Message, msg_id) + if not msg: + raise HTTPException(404, "消息不存在") + await db.delete(msg) + await db.commit() + return {"deleted": msg_id} + + +@router.delete("/conversations/{conv_id}") +async def delete_conversation(conv_id: str, db: AsyncSession = Depends(get_db)): + conv = await db.get(Conversation, conv_id) + if not conv: + raise HTTPException(404, "会话不存在") + # ORM cascade="all, delete-orphan" 会一并删除该会话下的所有消息 + await db.delete(conv) + await db.commit() + return {"deleted": conv_id} + + +@router.post("/conversations/{conv_id}/branch") +async def branch_conversation(conv_id: str, from_message_id: str = Query(...), db: AsyncSession = Depends(get_db)): + conv = await db.get(Conversation, conv_id) + if not conv: + raise HTTPException(404, "会话不存在") + branch = Conversation(book_id=conv.book_id, title=f"{conv.title or '对话'} (分支)", model=conv.model) + db.add(branch) + await db.flush() + + result = await db.execute( + select(Message).where(Message.conversation_id == conv_id).order_by(Message.created_at) + ) + msgs = result.scalars().all() + for m in msgs: + new_msg = Message( + conversation_id=branch.id, + role=m.role, + content=m.content, + citations=m.citations, + model=m.model, + ) + db.add(new_msg) + if m.id == from_message_id: + break + await db.commit() + await db.refresh(branch) + return { + "id": branch.id, + "book_id": branch.book_id, + "title": branch.title, + "model": branch.model, + "created_at": branch.created_at.isoformat() if branch.created_at else "", + } + + +@router.get("/conversations/{conv_id}/export") +async def export_conversation(conv_id: str, fmt: str = "markdown", db: AsyncSession = Depends(get_db)): + conv = await db.get(Conversation, conv_id) + if not conv: + raise HTTPException(404, "会话不存在") + result = await db.execute( + select(Message).where(Message.conversation_id == conv_id).order_by(Message.created_at) + ) + msgs = result.scalars().all() + book = await db.get(Book, conv.book_id) + + lines = [f"# {conv.title or '对话'}", f"书籍: {book.title if book else '未知'}\n"] + for m in msgs: + role_label = "🧑 用户" if m.role == "user" else "🤖 AI" + lines.append(f"## {role_label}\n\n{m.content}\n") + if m.citations: + try: + citations = json.loads(m.citations) + for i, c in enumerate(citations): + lines.append(f"> 引用 {i+1}: {c.get('excerpt', '')}\n") + except (json.JSONDecodeError, TypeError): + pass + return {"content": "\n".join(lines), "format": fmt, "title": conv.title} + + +def _msg_to_out(m: Message) -> MessageOut: + citations = None + if m.citations: + try: + citations = json.loads(m.citations) + except (json.JSONDecodeError, TypeError): + citations = None + return MessageOut( + id=m.id, conversation_id=m.conversation_id, parent_message_id=m.parent_message_id, + role=m.role, content=m.content, citations=citations, model=m.model, + created_at=m.created_at.isoformat() if m.created_at else "", + ) \ No newline at end of file diff --git a/backend/app/api/models.py b/backend/app/api/models.py new file mode 100644 index 0000000..df80777 --- /dev/null +++ b/backend/app/api/models.py @@ -0,0 +1,183 @@ +import httpx +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.models.ai_model import AiModel +from app.llm.hub import builtin_models, resolve_provider, _mask_key + +router = APIRouter(prefix="/models", tags=["models"]) + + +class AddModelRequest(BaseModel): + label: str + kind: str = "openai_compatible" # openai_compatible | anthropic + base_url: str = "" + api_key: str = "" + model_id: str + + +class UpdateModelRequest(BaseModel): + label: str | None = None + kind: str | None = None + base_url: str | None = None + api_key: str | None = None + model_id: str | None = None + + +class ImportModelsRequest(BaseModel): + api_key: str + base_url: str = "https://openrouter.ai/api/v1" + + +class ImportOpenRouterRequest(BaseModel): + api_key: str + base_url: str = "https://openrouter.ai/api/v1" + + +OPENROUTER_BLOCKLIST = { + # 一些非文本/量级过大的模型 id 特征,导入时快速排除 + "image", "tts", "whisper", "embed", "rerank", "moderation", "dall-e", "flux", "playground", +} + + +def _to_out(m: AiModel) -> dict: + return { + "name": m.name, + "label": m.label, + "kind": m.kind, + "base_url": m.base_url, + "api_key_masked": _mask_key(m.api_key), + "model_id": m.model_id, + "builtin": False, + } + + +@router.get("") +async def list_models(db: AsyncSession = Depends(get_db)): + """内置(.env)+ 用户自定义(DB)模型列表。api_key 只回显掩码。""" + result = await db.execute(select(AiModel).order_by(AiModel.sort_order, AiModel.created_at)) + custom = [_to_out(m) for m in result.scalars().all()] + return builtin_models() + custom + + +@router.post("") +async def add_model(req: AddModelRequest, db: AsyncSession = Depends(get_db)): + label = (req.label or "").strip() or req.model_id + # name = 请求模型 id;冲突时追加序号(-2, -3 ...) + base_name = req.model_id.strip() or "custom-model" + name = base_name + suffix = 1 + while True: + exists = (await db.execute(select(AiModel.id).where(AiModel.name == name))).scalar() + if exists is None: + break + suffix += 1 + name = f"{base_name}-{suffix}" + row = AiModel( + name=name, label=label, kind=req.kind, + base_url=(req.base_url or "").strip(), api_key=(req.api_key or "").strip(), + model_id=req.model_id.strip(), sort_order=100, + ) + db.add(row) + await db.commit() + await db.refresh(row) + return _to_out(row) + + +@router.put("/{name}") +async def update_model(name: str, req: UpdateModelRequest, db: AsyncSession = Depends(get_db)): + for m in builtin_models(): + if m["name"] == name: + raise HTTPException(400, "内置模型不可修改,请在页面添加同名模型") + row = (await db.execute(select(AiModel).where(AiModel.name == name))).scalar() + if not row: + raise HTTPException(404, "模型不存在") + if req.label is not None: + row.label = req.label.strip() or row.label + if req.kind is not None: + row.kind = req.kind + if req.base_url is not None: + row.base_url = req.base_url.strip() + if req.api_key is not None: + row.api_key = req.api_key.strip() + if req.model_id is not None: + row.model_id = req.model_id.strip() + await db.commit() + await db.refresh(row) + return _to_out(row) + + +@router.delete("/{name}") +async def delete_model(name: str, db: AsyncSession = Depends(get_db)): + for m in builtin_models(): + if m["name"] == name: + raise HTTPException(400, "内置模型不可删除") + row = (await db.execute(select(AiModel).where(AiModel.name == name))).scalar() + if not row: + raise HTTPException(404, "模型不存在") + await db.delete(row) + await db.commit() + return {"deleted": name} + + +@router.post("/{name}/test") +async def test_model(name: str, db: AsyncSession = Depends(get_db)): + """用该模型的配置发一条极短请求,验证 base_url/api_key/model_id 是否可用。""" + provider = await resolve_provider(db, name) + if provider is None: + raise HTTPException(404, "模型不存在") + try: + resp = await provider.chat( + [{"role": "user", "content": "hi"}], + temperature=0, + ) + return {"ok": True, "model": resp.model, "reply": resp.content[:80]} + except Exception as e: + return {"ok": False, "error": f"{type(e).__name__}: {e}"} + + +@router.post("/import") +async def import_models(req: ImportModelsRequest): + """通用 OpenAI 兼容 /models 拉取(OpenRouter / 硅基流动 / SenseNova / 任取 base_url), + 供前端挑选后批量添加(不落库)。""" + key = (req.api_key or "").strip() + if not key: + raise HTTPException(400, "需要 API Key") + base = (req.base_url or "").strip().rstrip("/") + if not base: + raise HTTPException(400, "需要 Base URL") + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{base}/models", + headers={"Authorization": f"Bearer {key}"}, + ) + if resp.status_code >= 400: + raise HTTPException(resp.status_code, f"OpenRouter 返回 {resp.status_code}: {resp.text[:200]}") + data = resp.json() + except httpx.HTTPError as e: + raise HTTPException(502, f"请求模型列表失败: {type(e).__name__}: {e}") + except HTTPException: + raise + except Exception as e: + raise HTTPException(502, f"解析模型列表失败: {e}") + + models = [] + for m in data.get("data", []): + mid = (m.get("id") or "").strip() + if not mid: + continue + low = mid.lower() + if any(b in low for b in OPENROUTER_BLOCKLIST): + continue + models.append({"id": mid, "name": (m.get("name") or mid)}) + return {"models": models, "count": len(models)} + + +@router.post("/import-openrouter") +async def import_openrouter_models(req: ImportOpenRouterRequest, db: AsyncSession = Depends(get_db)): + """兼容旧调用:等价于通用 /import(默认 base_url = OpenRouter)。""" + return await import_models(ImportModelsRequest(api_key=req.api_key, base_url=req.base_url)) \ No newline at end of file diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..22beff1 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,43 @@ +from pydantic_settings import BaseSettings +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent.parent + + +class Settings(BaseSettings): + database_url: str = f"sqlite+aiosqlite:///{BASE_DIR / 'data' / 'bookmate.db'}" + upload_dir: str = str(BASE_DIR / "uploads") + data_dir: str = str(BASE_DIR / "data") + api_host: str = "0.0.0.0" + api_port: int = 8000 + + # ===== 向量检索 & 重排(OpenAI 兼容 API,如硅基流动 SiliconFlow)===== + # embedding_base_url: 支持 OpenAI 兼容 /embeddings 的端点前缀 + embedding_base_url: str = "https://api.siliconflow.cn/v1" + embedding_api_key: str = "" # 留空则自动降级为纯关键词检索 + embedding_model: str = "BAAI/bge-m3" # 1024 维 + # rerank: 硅基流动使用 /v1/rerank,OpenAI 不提供;留空则跳过重排 + rerank_base_url: str = "https://api.siliconflow.cn/v1" + rerank_api_key: str = "" + rerank_model: str = "BAAI/bge-reranker-v2-m3" + embedding_batch_size: int = 64 + + @property + def upload_path(self) -> Path: + p = Path(self.upload_dir) + p.mkdir(parents=True, exist_ok=True) + return p + + @property + def data_path(self) -> Path: + p = Path(self.data_dir) + p.mkdir(parents=True, exist_ok=True) + return p + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + extra = "ignore" + + +settings = Settings() \ No newline at end of file diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..6cc464d --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,33 @@ +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase +from app.core.config import settings + +settings.data_path.mkdir(parents=True, exist_ok=True) +settings.upload_path.mkdir(parents=True, exist_ok=True) + +engine = create_async_engine(settings.database_url, echo=False) +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +async def init_db(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + await _migrate() + + +async def _migrate(): + """轻量迁移:SQLite 下给已有 segments 表补 embedding 列(create_all 不会改老表)。""" + async with engine.begin() as conn: + rows = (await conn.exec_driver_sql("PRAGMA table_info(segments)")).all() + cols = {row[1] for row in rows} + if rows and "embedding" not in cols: + await conn.exec_driver_sql("ALTER TABLE segments ADD COLUMN embedding TEXT") + + +async def get_db(): + async with async_session() as session: + yield session \ No newline at end of file diff --git a/backend/app/epub/__init__.py b/backend/app/epub/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/epub/cfi.py b/backend/app/epub/cfi.py new file mode 100644 index 0000000..45eaf65 --- /dev/null +++ b/backend/app/epub/cfi.py @@ -0,0 +1,37 @@ +import hashlib + + +def make_cfi(spine_index: int, file_href: str, element_id: str = "") -> str: + """生成 CFI(Canonical Fragment Identifier) + 格式: epubi://title/spine_index/file_href!element_id + """ + clean_href = file_href.split("#")[0] + cfi = f"epubi://book/{spine_index}/{clean_href}" + if element_id: + cfi += f"!{element_id}" + return cfi + + +def make_element_id(text: str, index: int) -> str: + """基于文本内容和索引用哈希生成稳定 ID""" + short_hash = hashlib.md5(f"{text[:100]}{index}".encode()).hexdigest()[:8] + return f"seg-{index:04d}-{short_hash}" + + +def parse_cfi(cfi: str) -> dict | None: + """解析 CFI 字符串,返回各组成部分""" + if not cfi.startswith("epubi://"): + return None + try: + rest = cfi[len("epubi://book/"):] + parts = rest.split("/", 2) + spine_index = int(parts[0]) + file_href = parts[1] if len(parts) > 1 else "" + fragment = parts[2] if len(parts) > 2 else "" + return { + "spine_index": spine_index, + "file_href": file_href, + "fragment": fragment.lstrip("!"), + } + except (ValueError, IndexError): + return None \ No newline at end of file diff --git a/backend/app/epub/chunker.py b/backend/app/epub/chunker.py new file mode 100644 index 0000000..5060852 --- /dev/null +++ b/backend/app/epub/chunker.py @@ -0,0 +1,97 @@ +import re + + +def chunk_paragraphs( + paragraphs: list[dict], + heading_path: str, + cfi_base: str, + target_chars: int = 1500, + max_chars: int = 4000, + min_chars: int = 200, +) -> list[dict]: + """将段落列表按语义边界聚类,不按固定字符数切。 + + 策略: + - 章节总字数 < target_chars: 整章一块 + - otherwise: 按段落聚类,目标 target_chars/块,允许±50%浮动 + """ + total_chars = sum(len(p["text"]) for p in paragraphs) + if total_chars <= target_chars: + merged_text = "\n\n".join(p["text"] for p in paragraphs) + prefix = f"《heading_path》" if heading_path else "" + return [{ + "text": f"{prefix}\n{merged_text}" if prefix else merged_text, + "char_count": len(merged_text), + "cfi": cfi_base, + "heading_path": heading_path, + }] + + chunks = [] + buf = [] + buf_chars = 0 + + def flush(): + nonlocal buf_chars + if not buf: + return + merged = "\n\n".join(p["text"] for p in buf) + prefix = f"《heading_path》" if heading_path else "" + text = f"{prefix}\n{merged}" if prefix else merged + chunks.append({ + "text": text, + "char_count": len(merged), + "cfi": cfi_base, + "heading_path": heading_path, + }) + buf.clear() + buf_chars = 0 + + for i, p in enumerate(paragraphs): + p_chars = len(p["text"]) + + if p["tag"] in ("h1", "h2", "h3", "h4"): + flush() + prefix = f"《heading_path》" if heading_path else "" + text = f"{prefix}\n{p['text']}" if prefix else p["text"] + chunks.append({ + "text": text, + "char_count": p_chars, + "cfi": cfi_base, + "heading_path": heading_path, + }) + continue + + if buf_chars + p_chars > target_chars * 1.5: + flush() + + buf.append(p) + buf_chars += p_chars + + flush() + + for i in range(len(chunks)): + chunks[i]["cfi"] = f"{cfi_base}!chunk-{i:04d}" + + return chunks + + +def chapter_to_chunks( + paragraphs: list[dict], + heading_path: str, + cfi_start: str, + book_char_total: int, +) -> list[dict]: + """根据书的总规模决定切块策略""" + if book_char_total < 50_000: + target = 2000 + elif book_char_total < 500_000: + target = 1500 + else: + target = 1000 + + return chunk_paragraphs( + paragraphs, + heading_path=heading_path, + cfi_base=cfi_start, + target_chars=target, + ) \ No newline at end of file diff --git a/backend/app/epub/cleaner.py b/backend/app/epub/cleaner.py new file mode 100644 index 0000000..858345c --- /dev/null +++ b/backend/app/epub/cleaner.py @@ -0,0 +1,187 @@ +import re +from bs4 import BeautifulSoup, Tag + + +BLACKLIST_PATTERNS = [ + (re.compile(r"^\s*目\s*录\s*$"), True), + (re.compile(r"^\s*Contents\b", re.I), True), + (re.compile(r"^\s*Table of Contents", re.I), True), + (re.compile(r"版权所有"), True), + (re.compile(r"Copyright\s*\d{4}", re.I), True), + (re.compile(r"ISBN[\s:\-]*\d"), True), + (re.compile(r"版\s*权\s*页"), True), + (re.compile(r"^\s*[·\-\—\*_=]{3,}\s*$"), True), + (re.compile(r"^\s*序\s*言\s*$"), True), + (re.compile(r"^\s*前\s*言\s*$"), True), + (re.compile(r"^\s*致\s*谢\s*$"), True), + (re.compile(r"^\s*后\s*记\s*$"), True), + (re.compile(r"^\s*译者序\s*$"), True), +] + +SKIP_TAGS = {"script", "style", "nav", "head", "meta", "link", "br"} + +SENTENCE_ENDERS = re.compile(r"[。!?;…)」』】>)\)\"\"]$") +ENGLISH_SENTENCE_ENDERS = re.compile(r"[.!?;\"]$") + + +def clean_html(html_bytes: bytes, fix_wraps: bool = True) -> str: + soup = BeautifulSoup(html_bytes, "lxml") + + for tag_name in SKIP_TAGS: + for tag in soup.find_all(tag_name): + tag.decompose() + + body = soup.find("body") or soup + text = body.get_text("\n", strip=False) + lines = [line.strip() for line in text.split("\n") if line.strip()] + + lines = _filter_boilerplate(lines) + if fix_wraps: + lines = _fix_chinese_wraps(lines) + + return "\n\n".join(lines) + + +def extract_paragraphs(html_bytes: bytes, fix_wraps: bool = True) -> list[dict]: + """从 HTML 中提取段落列表,返回 [{text, tag, has_children}]""" + soup = BeautifulSoup(html_bytes, "lxml") + for tag_name in SKIP_TAGS: + for tag in soup.find_all(tag_name): + tag.decompose() + + body = soup.find("body") or soup + paragraphs = [] + + for el in body.descendants: + if not isinstance(el, Tag): + continue + if el.name in SKIP_TAGS: + continue + if not el.name or el.name in ("html", "head", "body", "div", "span", "section", "article", "main"): + continue + + text = el.get_text(" ", strip=True) + if not text or len(text) < 2: + continue + + if el.name in ("table",): + text = _table_to_markdown(el) + elif el.name in ("blockquote",): + text = "> " + text.replace("\n", "\n> ") + + paragraphs.append({ + "text": text, + "tag": el.name, + "has_children": bool(el.find_all()), + }) + + if fix_wraps: + paragraphs = _fix_paragraph_wraps(paragraphs) + + return paragraphs + + +def _filter_boilerplate(lines: list[str]) -> list[str]: + result = [] + for line in lines: + stripped = re.sub(r"\s+", "", line) + if len(stripped) < 15: + continue + if any(pattern.search(stripped) for pattern, _ in BLACKLIST_PATTERNS): + continue + result.append(line) + return result + + +def _fix_chinese_wraps(lines: list[str]) -> list[str]: + """中文断行修复:末字非句末标点时,与下一行合并""" + if not lines: + return lines + result = [] + buf = "" + for line in lines: + line = line.strip() + if not line: + if buf: + result.append(buf) + buf = "" + continue + if not buf: + buf = line + continue + if SENTENCE_ENDERS.search(buf[-1]): + result.append(buf) + buf = line + else: + buf += line + if buf: + result.append(buf) + return [r for r in result if r] + + +def _fix_paragraph_wraps(paragraphs: list[dict]) -> list[dict]: + """段落级别的断行修复,合并被拆分的段落""" + if not paragraphs: + return paragraphs + merged = [] + buf = paragraphs[0] + for p in paragraphs[1:]: + if not buf["text"]: + buf = p + continue + if SENTENCE_ENDERS.search(buf["text"][-1:]): + merged.append(buf) + buf = p + elif len(buf["text"]) < 50 and not SENTENCE_ENDERS.search(buf["text"][-1:]): + buf["text"] = buf["text"] + p["text"] + else: + merged.append(buf) + buf = p + if buf["text"]: + merged.append(buf) + return merged + + +def _table_to_markdown(table_tag) -> str: + rows = [] + for tr in table_tag.find_all("tr"): + cells = [c.get_text(" ", strip=True) for c in tr.find_all(["td", "th"])] + rows.append(cells) + if not rows: + return "" + col_count = max(len(r) for r in rows) + for r in rows: + while len(r) < col_count: + r.append("") + col_widths = [max(len(r[i]) for r in rows) for i in range(col_count)] + lines = [] + for i, row in enumerate(rows): + line = "| " + " | ".join(cell.ljust(w) for cell, w in zip(row, col_widths)) + " |" + lines.append(line) + if i == 0: + sep = "|" + "|".join("-" * (w + 2) for w in col_widths) + "|" + lines.append(sep) + return "\n".join(lines) + + +def detect_quality(text: str) -> tuple[int, list[str]]: + """检测文本提取质量,返回 (score 0-100, issues)""" + score = 100 + issues = [] + lines = [l for l in text.split("\n") if l.strip()] + short_count = sum(1 for l in lines if len(l) < 10 and len(l) > 0) + if short_count > len(lines) * 0.3: + score -= 30 + issues.append("大量短行,可能是伪 EPUB") + + fullwidth = len(re.findall(r"[\u4e00-\u9fff]", text)) + ascii_chars = len(re.findall(r"[a-zA-Z]", text)) + if fullwidth + ascii_chars == 0: + score -= 50 + issues.append("无有效文本内容") + + if len(text) < 100 and fullwidth == 0: + score -= 40 + issues.append("文本过短") + + return max(score, 0), issues \ No newline at end of file diff --git a/backend/app/epub/parser.py b/backend/app/epub/parser.py new file mode 100644 index 0000000..50b13ae --- /dev/null +++ b/backend/app/epub/parser.py @@ -0,0 +1,207 @@ +import zipfile +import hashlib +import os +from lxml import etree +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.book import Book, Chapter, Segment +from app.epub.toc import ( + parse_toc_tree, flatten_headings, build_heading_paths, + href_to_fragment, NS, _read_opf, _resolve_path, _dirname, +) +from app.epub.cleaner import extract_paragraphs, clean_html, detect_quality +from app.epub.chunker import chapter_to_chunks +from app.epub.cfi import make_cfi, make_element_id + + +async def parse_epub(file_path: str, db: AsyncSession) -> Book: + """完整解析 EPUB,写入数据库,返回 Book 对象""" + file_hash = _hash_file(file_path) + + existing = await db.execute( + __import__("sqlalchemy").select(Book).where(Book.file_hash == file_hash) + ) + if existing.scalar_one_or_none(): + raise ValueError("EPUB 已存在(根据文件哈希去重)") + + filename = os.path.basename(file_path) + + with zipfile.ZipFile(file_path) as zf: + opf_path, opf_root = _read_opf(zf) + opf_dir = _dirname(opf_path) + + title, author, language = _extract_metadata(opf_root) + spine_items = _get_spine_items(zf, opf_root, opf_dir) + + headings = parse_toc_tree(file_path) + flat = flatten_headings(headings) + flat_with_paths = build_heading_paths(flat, 0) + + book = Book( + filename=filename, + title=title, + author=author, + language=language, + file_path=file_path, + file_hash=file_hash, + status="parsing", + ) + db.add(book) + await db.flush() + + total_chars = 0 + + spine_index_map = {} + with zipfile.ZipFile(file_path) as zf: + _, opf_root = _read_opf(zf) + manifest = {item.attrib.get("id"): item.attrib.get("href", "") for item in opf_root.findall("opf:manifest/opf:item", NS)} + spine_ids = [itemref.attrib.get("idref") for itemref in opf_root.findall("opf:spine/opf:itemref", NS)] + for idx, idref in enumerate(spine_ids): + href = manifest.get(idref, "") + if href: + spine_index_map[href] = idx + + with zipfile.ZipFile(file_path) as zf: + chapters_to_insert = _match_chapters_to_spine( + flat_with_paths, spine_items, zf, opf_dir, + ) + + chapter_order = 0 + for ch_info in chapters_to_insert: + paragraphs = extract_paragraphs(ch_info["html"], fix_wraps=True) + chapter_text = "\n\n".join(p["text"] for p in paragraphs) + chapter_chars = len(chapter_text) + total_chars += chapter_chars + + href = ch_info["href"] + spine_idx = spine_index_map.get(href, chapter_order) + cfi_start = make_cfi(spine_idx, href, ch_info.get("fragment", "")) + + chapter = Chapter( + book_id=book.id, + order_idx=chapter_order, + title=ch_info["title"], + level=ch_info.get("level", 1), + heading_path=ch_info.get("heading_path", ""), + cfi_start=cfi_start, + cfi_end="", + char_count=chapter_chars, + ) + db.add(chapter) + await db.flush() + + chunks = chapter_to_chunks( + paragraphs, + heading_path=ch_info.get("heading_path", ""), + cfi_start=cfi_start, + book_char_total=total_chars, + ) + + for seg_order, chunk in enumerate(chunks): + segment = Segment( + chapter_id=chapter.id, + book_id=book.id, + order_idx=seg_order, + text=chunk["text"], + char_count=chunk["char_count"], + cfi=chunk["cfi"], + ) + db.add(segment) + + chapter_order += 1 + + book.char_count = total_chars + book.status = "ready" + quality_score, quality_issues = _assess_quality(zf=None, chapters=chapters_to_insert, file_path=file_path) + await db.flush() + + return book + + +def _hash_file(file_path: str) -> str: + h = hashlib.sha256() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + h.update(chunk) + return h.hexdigest() + + +def _extract_metadata(opf: etree._Element) -> tuple[str, str, str]: + ns = {"dc": "http://purl.org/dc/elements/1.1/"} + title_el = opf.find(".//dc:title", ns) + creator_el = opf.find(".//dc:creator", ns) + lang_el = opf.find(".//dc:language", ns) + title = title_el.text.strip() if title_el is not None and title_el.text else "未知书名" + author = creator_el.text.strip() if creator_el is not None and creator_el.text else "未知作者" + language = lang_el.text.strip() if lang_el is not None and lang_el.text else "zh" + return title, author, language + + +def _get_spine_items(zf: zipfile.ZipFile, opf: etree._Element, opf_dir: str) -> list[dict]: + """获取 spine 中所有文件的 HTML 内容""" + manifest = {item.attrib.get("id"): item.attrib.get("href", "") for item in opf.findall("opf:manifest/opf:item", NS)} + spine_ids = [itemref.attrib.get("idref") for itemref in opf.findall("opf:spine/opf:itemref", NS)] + items = [] + for idref in spine_ids: + href = manifest.get(idref, "") + if not href: + continue + path = _resolve_path(opf_dir, href) + try: + html = zf.read(path) + items.append({"href": href, "html": html, "path": path}) + except KeyError: + continue + return items + + +def _match_chapters_to_spine( + flat_headings: list[dict], + spine_items: list[dict], + zf: zipfile.ZipFile, + opf_dir: str, +) -> list[dict]: + """将目录条目与 spine 文件匹配,按 href 对齐""" + chapter_map = {} + for h in flat_headings: + href = h.get("href", "") + base = href.split("#")[0] if "#" in href else href + frag = href.split("#", 1)[1] if "#" in href else "" + if base not in chapter_map: + chapter_map[base] = [] + chapter_map[base].append({**h, "fragment": frag}) + + result = [] + seen = set() + for item in spine_items: + href = item["href"] + base = href.split("#")[0] if "#" in href else href + if base in chapter_map: + for ch in chapter_map[base]: + if ch.get("title", "") not in seen: + seen.add(ch.get("title", "")) + result.append({**ch, "html": item["html"]}) + else: + result.append({ + "title": f"Section {len(result) + 1}", + "level": 1, + "href": href, + "heading_path": "", + "fragment": "", + "html": item["html"], + }) + return result + + +def _assess_quality(zf, chapters: list[dict], file_path: str) -> tuple[int, list[str]]: + """评估解析质量""" + if not chapters: + return 0, ["无有效章节"] + sample_text = "\n".join( + clean_html(ch["html"], fix_wraps=False) + for ch in chapters[:5] + if ch.get("html") + ) + if not sample_text: + return 0, ["无法提取文本"] + return detect_quality(sample_text) \ No newline at end of file diff --git a/backend/app/epub/toc.py b/backend/app/epub/toc.py new file mode 100644 index 0000000..28c60b6 --- /dev/null +++ b/backend/app/epub/toc.py @@ -0,0 +1,216 @@ +import zipfile +import re +from lxml import etree +from dataclasses import dataclass, field + + +@dataclass +class Heading: + title: str + level: int + href: str + cfi: str = "" + children: list["Heading"] = field(default_factory=list) + + +NS = { + "opf": "http://www.idpf.org/2007/opf", + "ncx": "http://www.daisy.org/z3986/2005/ncx/", + "xhtml": "http://www.w3.org/1999/xhtml", +} + + +def parse_toc_tree(epub_path: str) -> list[Heading]: + """从 EPUB 中提取完整目录树,4 级降级:nav.xhtml -> ncx -> spine -> h 标签""" + with zipfile.ZipFile(epub_path) as zf: + opf_path, opf_root = _read_opf(zf) + opf_dir = _dirname(opf_path) + + headings = _try_nav(zf, opf_root, opf_dir) + if headings: + return headings + headings = _try_ncx(zf, opf_root, opf_dir) + if headings: + return headings + headings = _try_spine(zf, opf_root, opf_dir) + if headings: + return headings + return _fallback_spine(zf, opf_root, opf_dir) + + +def _read_opf(zf: zipfile.ZipFile) -> tuple[str, etree._Element]: + container = etree.fromstring(zf.read("META-INF/container.xml")) + rootfile = container.find(".//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile") + opf_path = rootfile.attrib["full-path"] if rootfile is not None else "OEBPS/content.opf" + opf_root = etree.fromstring(zf.read(opf_path)) + return opf_path, opf_root + + +def _try_nav(zf: zipfile.ZipFile, opf: etree._Element, opf_dir: str) -> list[Heading] | None: + """EPUB3: nav.xhtml(最优先,有原生层级)""" + manifest = {item.attrib.get("id"): item.attrib.get("href", "") for item in opf.findall("opf:manifest/opf:item", NS)} + spine_ids = [itemref.attrib.get("idref") for itemref in opf.findall("opf:spine/opf:itemref", NS)] + + for idref in spine_ids: + href = manifest.get(idref, "") + if "nav" in href.lower() or "toc" in href.lower(): + nav_path = _resolve_path(opf_dir, href) + try: + return _parse_nav_xhtml(zf, nav_path, opf_dir) + except Exception: + continue + return None + + +def _parse_nav_xhtml(zf: zipfile.ZipFile, nav_path: str, opf_dir: str) -> list[Heading]: + html = etree.fromstring(zf.read(nav_path)) + ol = html.find(".//{http://www.w3.org/1999/xhtml}nav[@{http://www.w3.org/1999/xhtml}type='toc']//{http://www.w3.org/1999/xhtml}ol") + if ol is None: + ol = html.find(".//{http://www.w3.org/1999/xhtml}nav//{http://www.w3.org/1999/xhtml}ol") + if ol is None: + return [] + return _parse_ol(ol, opf_dir, 1) + + +def _parse_ol(ol, opf_dir: str, base_level: int) -> list[Heading]: + result = [] + for li in ol.findall("{http://www.w3.org/1999/xhtml}li"): + a = li.find("{http://www.w3.org/1999/xhtml}a") + if a is None: + continue + title = (a.text or "").strip() + href = a.attrib.get("href", "") + heading = Heading(title=title, level=base_level, href=href) + + sub_ol = li.find("{http://www.w3.org/1999/xhtml}ol") + if sub_ol is not None: + heading.children = _parse_ol(sub_ol, opf_dir, base_level + 1) + + result.append(heading) + return result + + +def _try_ncx(zf: zipfile.ZipFile, opf: etree._Element, opf_dir: str) -> list[Heading] | None: + """EPUB2: toc.ncx""" + spine = opf.find("opf:spine", NS) + if spine is None: + return None + ncx_id = spine.attrib.get("toc", "") + if not ncx_id: + return None + + manifest = {item.attrib.get("id"): item.attrib.get("href", "") for item in opf.findall("opf:manifest/opf:item", NS)} + ncx_href = manifest.get(ncx_id) + if not ncx_href: + return None + + ncx_path = _resolve_path(opf_dir, ncx_href) + try: + ncx_root = etree.fromstring(zf.read(ncx_path)) + return _parse_ncx_navmap(ncx_root, opf_dir) + except Exception: + return None + + +def _parse_ncx_navmap(ncx: etree._Element, opf_dir: str) -> list[Heading]: + navmap = ncx.find("ncx:navMap", NS) + if navmap is None: + return [] + return _parse_ncx_points(navmap, opf_dir, 1) + + +def _parse_ncx_points(parent, opf_dir: str, base_level: int) -> list[Heading]: + result = [] + for np in parent.findall("ncx:navPoint", NS): + label = np.find("ncx:navLabel/ncx:text", NS) + content = np.find("ncx:content", NS) + title = _text(label) if label is not None else "" + href = content.attrib.get("src", "") if content is not None else "" + heading = Heading(title=title, level=base_level, href=href) + heading.children = _parse_ncx_points(np, opf_dir, base_level + 1) + result.append(heading) + return result + + +def _try_spine(zf: zipfile.ZipFile, opf: etree._Element, opf_dir: str) -> list[Heading] | None: + """退化方案:每个 spine 文件当作一章""" + manifest = {item.attrib.get("id"): item.attrib.get("href", "") for item in opf.findall("opf:manifest/opf:item", NS)} + spine_ids = [itemref.attrib.get("idref") for itemref in opf.findall("opf:spine/opf:itemref", NS)] + headings = [] + for i, idref in enumerate(spine_ids): + href = manifest.get(idref, "") + if not href: + continue + try: + path = _resolve_path(opf_dir, href) + html = zf.read(path) + soup = etree.fromstring(html) + title_tag = soup.find(".//{http://www.w3.org/1999/xhtml}title") + title = _text(title_tag) if title_tag is not None else f"Section {i + 1}" + headings.append(Heading(title=title, level=1, href=href)) + except Exception: + continue + return headings if headings else None + + +def _fallback_spine(zf: zipfile.ZipFile, opf: etree._Element, opf_dir: str) -> list[Heading]: + """最后兜底:直接读所有 xhtml 的 h1-h3""" + return _try_spine(zf, opf, opf_dir) or [] + + +def flatten_headings(headings: list[Heading], level_offset: int = 0) -> list[dict]: + """将树形目录展平为列表,包含 heading_path""" + result = [] + for h in headings: + result.append({ + "title": h.title, + "level": h.level + level_offset, + "href": h.href, + }) + result.extend(flatten_headings(h.children, level_offset)) + return result + + +def build_heading_paths(headings: list[dict], order_idx: int) -> list[dict]: + """为每个扁平条目构建完整 heading_path(如 '第3章 > 3.2 前向传播')""" + stack = [] + result = [] + for h in headings: + while stack and stack[-1]["level"] >= h["level"]: + stack.pop() + stack.append(h) + path = " > ".join(s["title"] for s in stack) + result.append({**h, "heading_path": path, "order_idx": order_idx}) + order_idx += 1 + return result + + +def _resolve_path(opf_dir: str, href: str) -> str: + if href.startswith("/"): + return href.lstrip("/") + parts = [p for p in opf_dir.split("/") if p] if opf_dir else [] + parts.append(href) + while ".." in parts: + idx = parts.index("..") + if idx > 0: + parts.pop(idx) + parts.pop(idx - 1) + else: + parts.pop(idx) + return "/".join(parts) + + +def _dirname(path: str) -> str: + parts = path.rsplit("/", 1) + return parts[0] if len(parts) > 1 else "" + + +def _text(el) -> str: + return "".join(el.itertext()).strip() if el is not None else "" + + +def href_to_fragment(href: str) -> str: + """从 href 中提取 file 和 fragment 部分""" + if "#" in href: + return href.split("#", 1)[1] + return "" \ No newline at end of file diff --git a/backend/app/llm/__init__.py b/backend/app/llm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/llm/hub.py b/backend/app/llm/hub.py new file mode 100644 index 0000000..4640802 --- /dev/null +++ b/backend/app/llm/hub.py @@ -0,0 +1,277 @@ +import json +import os +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncSession + + +@dataclass +class ChatResponse: + content: str + model: str + prompt_tokens: int = 0 + completion_tokens: int = 0 + + +class LLMProvider(ABC): + name: str = "base" + max_context: int = 128_000 + + @abstractmethod + async def chat(self, messages: list[dict], temperature: float = 0.7) -> ChatResponse: + ... + + @abstractmethod + async def chat_stream(self, messages: list[dict], temperature: float = 0.7) -> AsyncGenerator[str, None]: + ... + + +class OpenAIProvider(LLMProvider): + name = "openai" + max_context = 128_000 + + def __init__(self, api_key: str | None = None, base_url: str | None = None, model: str | None = None): + self.api_key = api_key if api_key is not None else os.getenv("OPENAI_API_KEY", "") + self.base_url = base_url if base_url is not None else os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") + self.model = model if model is not None else os.getenv("OPENAI_MODEL", "gpt-4o") + + async def chat(self, messages: list[dict], temperature: float = 0.7) -> ChatResponse: + import httpx + async with httpx.AsyncClient(timeout=120) as client: + resp = await client.post( + f"{self.base_url}/chat/completions", + headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}, + json={"model": self.model, "messages": messages, "temperature": temperature}, + ) + data = resp.json() + if "error" in data: + raise RuntimeError(f"OpenAI error: {data['error']}") + choice = data["choices"][0] + return ChatResponse( + content=choice["message"]["content"], + model=data["model"], + prompt_tokens=data.get("usage", {}).get("prompt_tokens", 0), + completion_tokens=data.get("usage", {}).get("completion_tokens", 0), + ) + + async def chat_stream(self, messages: list[dict], temperature: float = 0.7) -> AsyncGenerator[str, None]: + import httpx + async with httpx.AsyncClient(timeout=300) as client: + async with client.stream( + "POST", + f"{self.base_url}/chat/completions", + headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}, + json={"model": self.model, "messages": messages, "temperature": temperature, "stream": True}, + ) as resp: + async for line in resp.aiter_lines(): + if line.startswith("data: "): + data_str = line[6:] + if data_str.strip() == "[DONE]": + break + try: + data = json.loads(data_str) + # 流末尾可能来一个 usage-only chunk(choices 为空),直接跳过 + choices = data.get("choices") or [] + if not choices: + continue + delta = choices[0].get("delta", {}) + content = delta.get("content", "") + if content: + yield content + except json.JSONDecodeError: + continue + + +class AnthropicProvider(LLMProvider): + name = "claude" + max_context = 200_000 + + def __init__(self, api_key: str | None = None, model: str | None = None): + self.api_key = api_key if api_key is not None else os.getenv("ANTHROPIC_API_KEY", "") + self.model = model if model is not None else os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-20250514") + + def _to_anthropic_messages(self, messages: list[dict]) -> tuple[str | None, list[dict]]: + system_msg = None + converted = [] + for m in messages: + if m["role"] == "system": + system_msg = m["content"] + else: + converted.append({"role": m["role"], "content": m["content"]}) + return system_msg, converted + + async def chat(self, messages: list[dict], temperature: float = 0.7) -> ChatResponse: + import httpx + system_msg, anthropic_msgs = self._to_anthropic_messages(messages) + body = { + "model": self.model, + "max_tokens": 4096, + "messages": anthropic_msgs, + "temperature": temperature, + } + if system_msg: + body["system"] = system_msg + async with httpx.AsyncClient(timeout=120) as client: + resp = await client.post( + "https://api.anthropic.com/v1/messages", + headers={ + "x-api-key": self.api_key, + "anthropic-version": "2023-06-01", + "Content-Type": "application/json", + }, + json=body, + ) + data = resp.json() + if "error" in data: + raise RuntimeError(f"Anthropic error: {data['error']}") + content_blocks = data.get("content", []) + text = "".join(b.get("text", "") for b in content_blocks if b.get("type") == "text") + return ChatResponse( + content=text, + model=data["model"], + prompt_tokens=data.get("usage", {}).get("input_tokens", 0), + completion_tokens=data.get("usage", {}).get("output_tokens", 0), + ) + + async def chat_stream(self, messages: list[dict], temperature: float = 0.7) -> AsyncGenerator[str, None]: + import httpx + system_msg, anthropic_msgs = self._to_anthropic_messages(messages) + body = { + "model": self.model, + "max_tokens": 4096, + "messages": anthropic_msgs, + "temperature": temperature, + "stream": True, + } + if system_msg: + body["system"] = system_msg + async with httpx.AsyncClient(timeout=300) as client: + async with client.stream( + "POST", + "https://api.anthropic.com/v1/messages", + headers={ + "x-api-key": self.api_key, + "anthropic-version": "2023-06-01", + "Content-Type": "application/json", + }, + json=body, + ) as resp: + async for line in resp.aiter_lines(): + if line.startswith("data: "): + data_str = line[6:] + try: + data = json.loads(data_str) + if data.get("type") == "content_block_delta": + delta = data.get("delta", {}) + text = delta.get("text", "") + if text: + yield text + except json.JSONDecodeError: + continue + + +class DeepSeekProvider(OpenAIProvider): + name = "deepseek" + max_context = 128_000 + + def __init__(self, api_key: str | None = None, model: str | None = None): + super().__init__( + api_key=api_key if api_key is not None else os.getenv("DEEPSEEK_API_KEY", ""), + base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1"), + model=model if model is not None else os.getenv("DEEPSEEK_MODEL", "deepseek-chat"), + ) + + +# ---- 内置模型(.env)与用户自定义模型(ai_models 表)的统一解析 ---- + +def builtin_models() -> list[dict]: + """扫描 .env 里已配置 key 的内置模型(无 key 的不返回,前端不会展示废选项)。 + + label 跟随 .env 的 model_id 动态生成,不硬编码(如 DEEPSEEK_MODEL=deepseek-flash + 就显示「DeepSeek deepseek-flash」,避免与实际请求的模型对不上)。 + """ + out = [] + if os.getenv("DEEPSEEK_API_KEY"): + mid = os.getenv("DEEPSEEK_MODEL", "deepseek-chat") + out.append({ + "name": "deepseek", "label": f"DeepSeek {mid}", + "kind": "openai_compatible", "base_url": os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1"), + "api_key_masked": _mask_key(os.getenv("DEEPSEEK_API_KEY")), + "model_id": mid, "builtin": True, + }) + if os.getenv("OPENAI_API_KEY"): + mid = os.getenv("OPENAI_MODEL", "gpt-4o") + out.append({ + "name": "openai", "label": f"GPT {mid}", + "kind": "openai_compatible", "base_url": os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"), + "api_key_masked": _mask_key(os.getenv("OPENAI_API_KEY")), + "model_id": mid, "builtin": True, + }) + if os.getenv("ANTHROPIC_API_KEY"): + mid = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-20250514") + out.append({ + "name": "claude", "label": f"Claude {mid}", + "kind": "anthropic", "base_url": "https://api.anthropic.com/v1", + "api_key_masked": _mask_key(os.getenv("ANTHROPIC_API_KEY")), + "model_id": mid, "builtin": True, + }) + if os.getenv("OPENROUTER_API_KEY"): + mid = os.getenv("OPENROUTER_MODEL", "openrouter/auto") + out.append({ + "name": "openrouter", "label": f"OpenRouter {mid}", + "kind": "openai_compatible", "base_url": "https://openrouter.ai/api/v1", + "api_key_masked": _mask_key(os.getenv("OPENROUTER_API_KEY")), + "model_id": mid, "builtin": True, + }) + return out + + +def _mask_key(key: str | None) -> str: + if not key: + return "" + if len(key) <= 8: + return "****" + return f"{key[:4]}****{key[-4:]}" + + +def _builtin_provider(entry: dict) -> LLMProvider: + """内置模型:key 从对应 .env 变量读取(builtin_models 只暴露掩码,不回传明文)。""" + name = entry["name"] + if name == "deepseek": + return DeepSeekProvider() + if name == "claude": + return AnthropicProvider() + if name == "openrouter": + return OpenAIProvider( + api_key=os.getenv("OPENROUTER_API_KEY", ""), + base_url="https://openrouter.ai/api/v1", + model=os.getenv("OPENROUTER_MODEL", "openrouter/auto"), + ) + return OpenAIProvider() # openai + + +def get_provider(name: str) -> LLMProvider | None: + """兼容旧的同步入口(仅内置模型)。""" + for m in builtin_models(): + if m["name"] == name: + return _builtin_provider(m) + return None + + +async def resolve_provider(db: AsyncSession, name: str) -> LLMProvider | None: + """按模型名解析 provider:内置名走 .env 默认构造,自定义走 ai_models 表;找不到返回 None。""" + from sqlalchemy import select + from app.models.ai_model import AiModel + + for m in builtin_models(): + if m["name"] == name: + return _builtin_provider(m) + result = await db.execute(select(AiModel).where(AiModel.name == name)) + row = result.scalars().first() + if row is None: + return None + if row.kind == "anthropic": + return AnthropicProvider(api_key=row.api_key, model=row.model_id) + return OpenAIProvider(api_key=row.api_key, base_url=row.base_url, model=row.model_id) \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..b7f9c75 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,42 @@ +from contextlib import asynccontextmanager +from dotenv import load_dotenv + +load_dotenv() + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.core.database import init_db, Base +from app.api.books import router as books_router +from app.api.chat import router as chat_router +from app.api.models import router as models_router + +from app.models.book import Book, Chapter, Segment +from app.models.chat import Conversation, Message +from app.models.ai_model import AiModel + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + yield + + +app = FastAPI(title="BookMate", version="0.1.0", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(books_router, prefix="/api") +app.include_router(chat_router, prefix="/api") +app.include_router(models_router, prefix="/api") + + +@app.get("/api/health") +async def health(): + return {"status": "ok"} \ No newline at end of file diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/ai_model.py b/backend/app/models/ai_model.py new file mode 100644 index 0000000..ff9516b --- /dev/null +++ b/backend/app/models/ai_model.py @@ -0,0 +1,28 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, Text, Integer, DateTime, func +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +def gen_uuid(): + return str(uuid.uuid4()) + + +class AiModel(Base): + """用户从页面配置的 AI 模型(OpenAI 兼容 / Anthropic)。 + + .env 内置模型不落这张表,由 hub.builtin_models() 动态读取。 + """ + + __tablename__ = "ai_models" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid) + name: Mapped[str] = mapped_column(String(200), unique=True) # 解析标识(请求模型 id) + label: Mapped[str] = mapped_column(String(200)) # 前端显示名 + kind: Mapped[str] = mapped_column(String(50), default="openai_compatible") # openai_compatible | anthropic + base_url: Mapped[str] = mapped_column(String(500), default="") + api_key: Mapped[str] = mapped_column(Text, default="") # 明文本地存储(与 .env 同级信任) + model_id: Mapped[str] = mapped_column(String(200)) # 实际请求模型 id + sort_order: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) \ No newline at end of file diff --git a/backend/app/models/book.py b/backend/app/models/book.py new file mode 100644 index 0000000..95a4831 --- /dev/null +++ b/backend/app/models/book.py @@ -0,0 +1,60 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, Integer, Float, Text, DateTime, ForeignKey, JSON, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.core.database import Base + + +def gen_uuid(): + return str(uuid.uuid4()) + + +class Book(Base): + __tablename__ = "books" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid) + filename: Mapped[str] = mapped_column(String(500), nullable=False) + title: Mapped[str | None] = mapped_column(String(500)) + author: Mapped[str | None] = mapped_column(String(500)) + language: Mapped[str] = mapped_column(String(20), default="zh") + file_path: Mapped[str] = mapped_column(String(1000), nullable=False) + file_hash: Mapped[str | None] = mapped_column(String(64), unique=True) + char_count: Mapped[int] = mapped_column(Integer, default=0) + status: Mapped[str] = mapped_column(String(20), default="uploaded") + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + chapters: Mapped[list["Chapter"]] = relationship(back_populates="book", cascade="all, delete-orphan") + + +class Chapter(Base): + __tablename__ = "chapters" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid) + book_id: Mapped[str] = mapped_column(String(36), ForeignKey("books.id", ondelete="CASCADE")) + order_idx: Mapped[int] = mapped_column(Integer, default=0) + title: Mapped[str | None] = mapped_column(String(500)) + level: Mapped[int] = mapped_column(Integer, default=1) + heading_path: Mapped[str | None] = mapped_column(Text) + cfi_start: Mapped[str | None] = mapped_column(String(500)) + cfi_end: Mapped[str | None] = mapped_column(String(500)) + char_count: Mapped[int] = mapped_column(Integer, default=0) + + book: Mapped["Book"] = relationship(back_populates="chapters") + segments: Mapped[list["Segment"]] = relationship(back_populates="chapter", cascade="all, delete-orphan") + + +class Segment(Base): + __tablename__ = "segments" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid) + chapter_id: Mapped[str] = mapped_column(String(36), ForeignKey("chapters.id", ondelete="CASCADE")) + book_id: Mapped[str] = mapped_column(String(36), ForeignKey("books.id", ondelete="CASCADE")) + order_idx: Mapped[int] = mapped_column(Integer, default=0) + text: Mapped[str] = mapped_column(Text, nullable=False) + char_count: Mapped[int] = mapped_column(Integer, default=0) + cfi: Mapped[str | None] = mapped_column(String(500)) + page_estimate: Mapped[float | None] = mapped_column(Float) + metadata_: Mapped[str | None] = mapped_column("metadata", Text) + embedding: Mapped[str | None] = mapped_column(Text) # JSON 浮点数组(向量检索用);NULL = 尚未生成向量 + + chapter: Mapped["Chapter"] = relationship(back_populates="segments") \ No newline at end of file diff --git a/backend/app/models/chat.py b/backend/app/models/chat.py new file mode 100644 index 0000000..51b9303 --- /dev/null +++ b/backend/app/models/chat.py @@ -0,0 +1,43 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, Text, DateTime, ForeignKey, JSON, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.core.database import Base + + +def gen_uuid(): + return str(uuid.uuid4()) + + +class Conversation(Base): + __tablename__ = "conversations" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid) + book_id: Mapped[str] = mapped_column(String(36), ForeignKey("books.id", ondelete="CASCADE")) + title: Mapped[str | None] = mapped_column(String(500)) + model: Mapped[str] = mapped_column(String(50), default="claude") + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) + + messages: Mapped[list["Message"]] = relationship( + back_populates="conversation", cascade="all, delete-orphan", + order_by="Message.created_at" + ) + + +class Message(Base): + __tablename__ = "messages" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid) + conversation_id: Mapped[str] = mapped_column(String(36), ForeignKey("conversations.id", ondelete="CASCADE")) + parent_message_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("messages.id", ondelete="SET NULL")) + role: Mapped[str] = mapped_column(String(20)) + content: Mapped[str] = mapped_column(Text, nullable=False) + citations: Mapped[str | None] = mapped_column("citations_json", Text) + model: Mapped[str | None] = mapped_column(String(50)) + prompt_tokens: Mapped[int | None] = mapped_column() + completion_tokens: Mapped[int | None] = mapped_column() + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + conversation: Mapped["Conversation"] = relationship(back_populates="messages") + parent: Mapped["Message | None"] = relationship("Message", remote_side="Message.id") \ No newline at end of file diff --git a/backend/app/rag/__init__.py b/backend/app/rag/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/rag/engine.py b/backend/app/rag/engine.py new file mode 100644 index 0000000..d8d59da --- /dev/null +++ b/backend/app/rag/engine.py @@ -0,0 +1,286 @@ +import re + +import jieba +from sqlalchemy import select, or_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import async_session +from app.models.book import Book, Chapter, Segment +from app.rag.vector import ( + embed_texts, rerank_documents, load_vectors, vector_search, + rrf_fuse, ensure_embeddings, get_status, RERANK_ENABLED, +) + +CITATION_RE = re.compile(r"\[\[ref:(\d+)\]\]") + +# 保守停用词:只过滤纯功能词,避免误删有检索价值的实词 +STOPWORDS = set( + "的 了 呢 吗 吧 啊 呀 嘛 哦 啦 哈 么 这 那 是 在 和 与 或 之 对 从 有 就 都 也 很 还 又 而 却 但 但是 然而 因此 所以 因为 如果 我们 你们 他们 它们 她们 什么 怎么 为什么 如何 这个 那个 一些 一个 本书 这本书 内容 主题 大概 比如 例如 应该 可能 可以 时候 里面 当中 其中 一些 部分 相关 关于 请问 告诉 解释 介绍 总结 分析".split() +) + + +def _tokenize(query: str) -> list[str]: + """分词:jieba 词 + 整句(精确子串)+ 双字 bigram,过滤停用词与纯符号。""" + tokens: set[str] = set() + for w in jieba.cut(query): + w = w.strip().lower() + if not w or w in STOPWORDS or re.fullmatch(r"[\s\W_]+", w): + continue + if len(w) >= 2: + tokens.add(w) + q = query.strip().lower() + if len(q) >= 4 and q not in STOPWORDS: + tokens.add(q) + # 中文连续串生成 bigram,增强长句召回(SQL LIKE 无法分词匹配) + for part in re.findall(r"[一-鿿]+", q): + if len(part) >= 4: + tokens.update(part[i:i + 2] for i in range(len(part) - 1)) + # 过滤 bigram 中出现的停用字组合(如「的」开头的) + tokens = {t for t in tokens if not any(c in STOPWORDS for c in t) or len(t) >= 4} + return list(tokens) + + +def match_chapters(chapters: list[Chapter], query: str, tokens: list[str] | None = None) -> list[Chapter]: + """章节标题匹配:查询词命中标题,或标题词命中查询。按目录顺序返回。""" + if not chapters: + return [] + if tokens is None: + tokens = _tokenize(query) + q = query.lower() + hits = [] + for ch in chapters: + title = (ch.title or "").strip().lower() + if not title or len(title) < 2: + continue + if any(w and w in title for w in tokens): + hits.append(ch) + continue + title_tokens = [ + w.strip() for w in jieba.cut(title) + if len(w.strip()) >= 2 and w.strip() not in STOPWORDS + ] + if any(tw in q for tw in title_tokens): + hits.append(ch) + hits.sort(key=lambda c: c.order_idx) + return hits + + +async def _keyword_candidates(db: AsyncSession, book_id: str, tokens: list[str], limit: int) -> list[Segment]: + """关键词候选:SQL LIKE 召回(含整句子串),按命中词数 + 长词加权打分。""" + if not tokens: + return [] + ordered = sorted(tokens, key=len, reverse=True)[:8] + conds = [Segment.text.contains(w) for w in ordered] + q = ( + select(Segment) + .where(Segment.book_id == book_id, or_(*conds)) + .order_by(Segment.order_idx) + .limit(limit * 4) + ) + result = await db.execute(q) + segs = list(result.scalars().all()) + scored = [] + for s in segs: + text = s.text.lower() + score = sum(3 if len(w) >= 4 else 1 for w in ordered if w in text) + scored.append((score, s)) + scored.sort(key=lambda x: -x[0]) + return [s for _, s in scored[:limit]] + + +async def _empty_fallback(db: AsyncSession, book_id: str, chapters: list[Chapter], top_k: int) -> list[Segment]: + """空检索兜底:返回目录最靠前的章节的开头段(全书开头),让 AI 至少有所依据。""" + result = await db.execute( + select(Segment) + .where(Segment.book_id == book_id) + .order_by(Segment.order_idx) + .limit(top_k * 3) + ) + segs = [s for s in result.scalars().all() if (s.text or "").strip()] + return segs[:top_k] + + +async def search_segments( + db: AsyncSession, + book_id: str, + query: str, + top_k: int = 15, + chapters: list[Chapter] | None = None, +) -> list[Segment]: + """混合检索:jieba 关键词 + API 向量余弦 → RRF 融合 → rerank 精排 → top_k。 + + 未配置 / 未生成向量 / API 失败时自动降级为纯关键词检索,绝不空手返回。 + """ + tokens = _tokenize(query) + + # 懒补向量(后台任务没跑完 / 重启后缺失时) + if get_status(book_id) != "done": + # 用独立会话执行:ensure_embeddings 失败时的 rollback 会过期会话内已加载的实例, + # 不能让调用方持有的 book/chapters 一起被过期(异步惰性访问会抛 MissingGreenlet) + async with async_session() as embed_session: + status = await ensure_embeddings(embed_session, book_id) + if status == "running": + # 后台正在生成,本次先用关键词,避免阻塞 + status = "failed" + + # 1. 关键词候选 + kw_candidates = await _keyword_candidates(db, book_id, tokens, top_k * 3) + + # 2. 向量候选 + vec_candidates: list[Segment] = [] + if tokens: + qvec = await embed_texts([query]) + if qvec and qvec[0]: + mat, vec_segments = await load_vectors(db, book_id) + if mat is not None and len(mat) > 0: + idxs = vector_search(qvec[0], mat, top_k * 3) + vec_candidates = [vec_segments[i] for i in idxs] + + # 3. RRF 融合 + if kw_candidates and vec_candidates: + by_id = {s.id: s for s in kw_candidates + vec_candidates} + fused = rrf_fuse( + [s.id for s in kw_candidates], + [s.id for s in vec_candidates], + ) + merged = [by_id[sid] for sid in fused if sid in by_id] + elif vec_candidates: + merged = vec_candidates + else: + merged = kw_candidates + + # 4. rerank 精排(对融合后的 top50 段打分) + if merged and RERANK_ENABLED: + docs = [s.text[:800] for s in merged[:50]] + idxs = await rerank_documents(query, docs, top_n=top_k) + if idxs is not None: + merged = [merged[i] for i in idxs if i < len(merged)] + + # 5. 兜底:完全没召回时给全书开头段,而不是空着 + if not merged: + merged = await _empty_fallback(db, book_id, chapters or [], top_k) + + return merged[:top_k] + + +def _chapter_full_text(chapter_segments: list[Segment], budget_tokens: int = 6000) -> str: + """整章注入:按 token 预算截取命中章节的原文。""" + parts = [] + used = 0 + for seg in chapter_segments: + t = seg.text + cost = estimate_tokens(t) + if parts and used + cost > budget_tokens: + break + parts.append(t) + used += cost + return "\n\n".join(parts) + + +def build_chat_prompt( + book: Book, + chapters: list[Chapter], + segments: list[Segment], + history: list[dict], + query: str, + chapter_segments: list[Segment] | None = None, +) -> list[dict]: + system = f"""# 你是 BookMate,一个帮助用户阅读书籍的 AI 助手。 + +## 当前书籍 +- 书名:《{book.title or '未知'}》 +- 作者:{book.author or '未知'} +- 总字数:约 {book.char_count:,} 字 + +## 目录结构 +""" + for ch in chapters[:40]: + indent = " " * (ch.level - 1) + system += f"{indent}- {ch.title or '(无标题)'}\n" + if len(chapters) > 40: + system += f" ...(共 {len(chapters)} 章)\n" + + # 命中章节的整章原文:全局性问题(如「第3章讲了什么」)直接给全文 + chapter_text = _chapter_full_text(chapter_segments or []) + if chapter_text: + system += "\n## 与问题直接相关的整章原文\n" + system += chapter_text + "\n" + + if segments: + system += "\n## 检索到的相关原文段落\n" + for i, seg in enumerate(segments, 1): + text_preview = seg.text[:800].replace("\n", " ") + system += f"\n[[ref:{i}]] {text_preview}\n" + else: + system += ( + "\n## 检索说明\n" + "本次没有检索到与问题直接匹配的原文段落。请基于上方目录结构、" + "书名与你的常识,指出书中哪些章节最可能涉及该主题," + "并明确说明「这是基于目录的推断」,不要编造具体原文内容。\n" + ) + + system += """ +## 回答要求 +1. 优先基于「检索到的相关原文段落」和「整章原文」回答,不要臆造书里没有的内容 +2. 引用原文时必须带 [[ref:N]] 标记(N 对应上方段落编号);同一段落只需引用一次,不要反复出现相同的 [[ref:N]];整个回答的引用控制在 5 条左右,只引用你真正依据的段落 +3. 如果检索内容不足以回答,坦诚说明,并给出基于目录的建议 +4. 用中文回答 +5. 当用户要求「思维导图 / 脑图 / mind map」时,输出 mermaid mindmap 语法,放在 ```mermaid 代码块里。格式:第一行写 `mindmap`,根节点写 `root((主题))`,子节点通过缩进表示层级,例如: +```mermaid +mindmap + root((主题)) + 一级分支 + 二级分支 +``` +不要把其他解释文字塞进代码块,代码块外可以有简短说明。""" + + messages = [{"role": "system", "content": system}] + if history: + messages.extend(history[-10:]) + messages.append({"role": "user", "content": query}) + return messages + + +def _trim_messages(messages: list[dict], budget_tokens: int) -> list[dict]: + """内容感知裁剪:保留 system(含检索原文)与最新提问,从最旧历史开始丢弃。 + + 替代原先粗糙的 `messages[:1] + messages[-(len//2):]`,避免裁掉检索上下文。 + """ + if estimate_tokens("".join(m["content"] for m in messages)) <= budget_tokens: + return messages + + system = messages[0] + tail = messages[-1] + used = estimate_tokens(system["content"] + tail["content"]) + kept = [] + for m in reversed(messages[1:-1]): + cost = estimate_tokens(m["content"]) + if used + cost <= budget_tokens: + kept.append(m) + used += cost + else: + break + kept.reverse() + return [system] + kept + [tail] + + +def parse_citations(content: str, segments: list[Segment]) -> dict: + citations = [] + for m in CITATION_RE.finditer(content): + idx = int(m.group(1)) - 1 + if 0 <= idx < len(segments): + seg = segments[idx] + citations.append({ + "segment_id": seg.id, + "cfi": seg.cfi, + "excerpt": seg.text[:200], + }) + clean_text = CITATION_RE.sub("", content) + return {"text": clean_text, "citations": citations} + + +def estimate_tokens(text: str) -> int: + """粗略估算 token 数:中文每字约 1.5 token,英文每词约 1.3 token""" + chinese_chars = len(re.findall(r"[一-鿿]", text)) + english_words = len(re.findall(r"[a-zA-Z]+", text)) + return int(chinese_chars * 1.5 + english_words * 1.3 + (len(text) - chinese_chars - len(re.findall(r"[a-zA-Z]", text))) * 0.5) diff --git a/backend/app/rag/vector.py b/backend/app/rag/vector.py new file mode 100644 index 0000000..2ccbd9b --- /dev/null +++ b/backend/app/rag/vector.py @@ -0,0 +1,218 @@ +"""向量检索服务(B 档)。 + +- embedding:调用 OpenAI 兼容 /embeddings API(如硅基流动 `BAAI/bge-m3`,1024 维) +- rerank:调用 /v1/rerank API(如硅基流动 `BAAI/bge-reranker-v2-m3`) +- 检索:书本段规模几千~几万,直接全量 numpy 余弦扫描,无需引入向量数据库 +- 融合:关键词结果 + 向量结果做 RRF(Reciprocal Rank Fusion) +- 降级:未配置 API Key / 调用失败时自动退回纯关键词模式,绝不阻塞主流程 +""" +import json +import logging +import threading + +import numpy as np +import httpx +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.models.book import Segment + +logger = logging.getLogger(__name__) + +EMBEDDING_ENABLED = bool(settings.embedding_api_key) +RERANK_ENABLED = bool(settings.rerank_api_key) + +# book_id -> 向量生成状态:"done" / "running" / "failed" +_vector_status: dict[str, str] = {} +_status_lock = threading.Lock() + + +# ======================= API 客户端 ======================= + +async def embed_texts(texts: list[str]) -> list[list[float] | None] | None: + """生成一批文本的向量,返回与 texts 同序的列表(单条失败置 None,其余正常)。 + + 单次请求过大 / 服务端失败时自动对半拆开递归重试,直到单条; + 单条超模型输入上限时截断重试一次,仍失败则跳过该条(置 None)。 + 未配置时返回 None;鉴权/配置类错误(API 不可用)直接抛出。 + """ + if not EMBEDDING_ENABLED or not texts: + return None + url = settings.embedding_base_url.rstrip("/") + "/embeddings" + headers = {"Authorization": f"Bearer {settings.embedding_api_key}"} + out: list[list[float] | None] = [None] * len(texts) + # 空文本无法向量化(OpenRouter 对空串直接 400 too_small),预过滤不送请求 + tasks: list[tuple[int, str]] = [(i, t) for i, t in enumerate(texts) if t and t.strip()] + if not tasks: + return out + + async with httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=15.0)) as client: + async def _call(items: list[str], idxs: list[int]) -> None: + if not items: + return + try: + resp = await client.post( + url, headers=headers, + json={"model": settings.embedding_model, "input": items}, + ) + resp.raise_for_status() + rows = resp.json().get("data") or [] + for j, row in enumerate(rows): + idx = row.get("index", j) + if 0 <= idx < len(idxs) and isinstance(row.get("embedding"), list): + out[idxs[idx]] = [float(v) for v in row["embedding"]] + except Exception as e: # noqa: BLE001 + # 鉴权/配置错误拆分也救不了,直接整体失败 + if isinstance(e, httpx.HTTPStatusError) and e.response.status_code in (401, 403, 404, 405): + raise + if len(items) > 1: + mid = len(items) // 2 + await _call(items[:mid], idxs[:mid]) + await _call(items[mid:], idxs[mid:]) + elif len(items[0]) > 1500: + # 单条超模型输入上限:截断再试一次(超长段向量化本就意义有限) + try: + resp = await client.post( + url, headers=headers, + json={"model": settings.embedding_model, "input": [items[0][:1500]]}, + ) + resp.raise_for_status() + rows = resp.json().get("data") or [] + if rows and isinstance(rows[0].get("embedding"), list): + out[idxs[0]] = [float(v) for v in rows[0]["embedding"]] + except Exception as e2: # noqa: BLE001 + logger.warning("embedding 单条失败(跳过): %s", e) + else: + logger.warning("embedding 单条失败(跳过): %s", e) + + await _call([t for _, t in tasks], [i for i, _ in tasks]) + + return out + + +async def rerank_documents(query: str, documents: list[str], top_n: int | None = None) -> list[int] | None: + """调用 rerank API。返回按相关性降序的原始文档索引;未配置或失败返回 None。""" + if not RERANK_ENABLED or not documents: + return None + url = settings.rerank_base_url.rstrip("/") + "/rerank" + headers = {"Authorization": f"Bearer {settings.rerank_api_key}"} + payload = {"model": settings.rerank_model, "query": query, "documents": documents} + if top_n: + payload["top_n"] = top_n + try: + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.post(url, headers=headers, json=payload) + resp.raise_for_status() + results = resp.json()["results"] + ordered = sorted(results, key=lambda r: -float(r["relevance_score"])) + return [int(r["index"]) for r in ordered] + except Exception as e: # noqa: BLE001 + logger.warning("rerank API 调用失败: %s", e) + return None + + +# ======================= 向量检索 ======================= + +async def load_vectors(db: AsyncSession, book_id: str) -> tuple[np.ndarray | None, list[Segment]]: + """读取该书所有已生成向量的段。返回 (向量矩阵, 对应段列表)。""" + result = await db.execute( + select(Segment) + .where(Segment.book_id == book_id, Segment.embedding.is_not(None)) + .order_by(Segment.order_idx) + ) + segs = list(result.scalars().all()) + vecs = [] + kept = [] + for s in segs: + try: + vecs.append(json.loads(s.embedding)) + kept.append(s) + except (json.JSONDecodeError, TypeError): + continue + if not vecs: + return None, [] + return np.asarray(vecs, dtype=np.float32), kept + + +def vector_search(query_vec: list[float], mat: np.ndarray, top_k: int) -> list[int]: + """余弦相似度 top_k,返回 mat 行索引(降序)。""" + q = np.asarray(query_vec, dtype=np.float32) + q_norm = np.linalg.norm(q) + if q_norm == 0: + return [] + sims = (mat @ q) / (np.linalg.norm(mat, axis=1) * q_norm + 1e-9) + top = np.argsort(-sims)[:top_k] + return [int(i) for i in top] + + +def rrf_fuse(*rankings: list[str], k: int = 60) -> list[str]: + """Reciprocal Rank Fusion:合并多个按相关性降序的 segment_id 列表。""" + scores: dict[str, float] = {} + for ranking in rankings: + for rank, sid in enumerate(ranking): + scores[sid] = scores.get(sid, 0.0) + 1.0 / (k + rank + 1) + return [sid for sid, _ in sorted(scores.items(), key=lambda kv: -kv[1])] + + +# ======================= 向量生成(上传后后台 + 检索前懒补)======================= + +def _set_status(book_id: str, status: str) -> None: + with _status_lock: + _vector_status[book_id] = status + + +def get_status(book_id: str) -> str | None: + with _status_lock: + return _vector_status.get(book_id) + + +async def ensure_embeddings(db: AsyncSession, book_id: str) -> str: + """确保 book 的每个 Segment 都有向量。返回状态 done / running / failed / disabled。 + + - 后台任务与请求可能并发调用:running 状态会直接返回,由另一方补齐 + - 只对缺少向量的段生成,重复调用是增量补差 + """ + if not EMBEDDING_ENABLED: + _set_status(book_id, "failed") + return "disabled" + + status = get_status(book_id) + if status == "running": + return "running" + + result = await db.execute( + select(Segment) + .where(Segment.book_id == book_id, Segment.embedding.is_(None)) + .order_by(Segment.order_idx) + ) + missing = list(result.scalars().all()) + # 空文本段无法向量化(API 对空串 400),也永远无检索价值,直接排除 + missing = [s for s in missing if (s.text or "").strip()] + if not missing: + _set_status(book_id, "done") + return "done" + + _set_status(book_id, "running") + try: + batch = settings.embedding_batch_size + for start in range(0, len(missing), batch): + segs = missing[start:start + batch] + vecs = await embed_texts([s.text for s in segs]) + if vecs is None: + raise RuntimeError("embedding API 不可用") + # 单条失败时 vec 为 None,跳过不落库;其余照常持久化 + for seg, vec in zip(segs, vecs): + if vec is not None: + seg.embedding = json.dumps([float(v) for v in vec], ensure_ascii=False) + await db.commit() + # 已尝试全部缺失段。个别被模型拒绝的段(内容/超限)由关键词档兜底, + # 标记 done 避免每次检索都重复重试;API 级故障会在 except 里落为 failed + _set_status(book_id, "done") + return "done" + except Exception as e: # noqa: BLE001 + logger.warning("向量生成失败 book=%s: %s", book_id, e) + _set_status(book_id, "failed") + # 已提交的批次不受影响;rollback 仅丢弃本批次未提交变更 + await db.rollback() + return "failed" diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/book.py b/backend/app/schemas/book.py new file mode 100644 index 0000000..0c7abe0 --- /dev/null +++ b/backend/app/schemas/book.py @@ -0,0 +1,56 @@ +from pydantic import BaseModel +from datetime import datetime + + +class BookOut(BaseModel): + id: str + filename: str + title: str | None + author: str | None + language: str + char_count: int + status: str + created_at: datetime + + class Config: + from_attributes = True + + +class BookDetailOut(BookOut): + chapter_count: int = 0 + segment_count: int = 0 + + +class ChapterOut(BaseModel): + id: str + book_id: str + order_idx: int + title: str | None + level: int + heading_path: str | None + cfi_start: str | None + cfi_end: str | None + char_count: int + + class Config: + from_attributes = True + + +class SegmentOut(BaseModel): + id: str + chapter_id: str + book_id: str + order_idx: int + text: str + char_count: int + cfi: str | None + + class Config: + from_attributes = True + + +class ParseStatus(BaseModel): + book_id: str + status: str + title: str | None + char_count: int \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..3d1365d --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,16 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +sqlalchemy[asyncio]==2.0.35 +aiosqlite==0.20.0 +alembic==1.13.2 +ebooklib==0.18 +beautifulsoup4==4.12.3 +lxml==5.3.0 +pydantic==2.9.2 +pydantic-settings==2.5.2 +python-multipart==0.0.12 +aiofiles==24.1.0 +httpx==0.28.1 +python-dotenv==1.0.1 +jieba==0.42.1 +numpy==2.5.4 \ No newline at end of file diff --git a/backend/test_book_file.py b/backend/test_book_file.py new file mode 100644 index 0000000..616cee6 --- /dev/null +++ b/backend/test_book_file.py @@ -0,0 +1,57 @@ +"""Run with: .venv/Scripts/python.exe -B -m unittest test_book_file""" +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch +import httpx +from fastapi import FastAPI +from app.api.books import router +from app.core.database import get_db + + +class BookFileTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.file = self.root / "sample.epub" + self.content = b"PK\x03\x04original-epub-bytes" + self.file.write_bytes(self.content) + self.db = SimpleNamespace(get=AsyncMock(return_value=SimpleNamespace(file_path=str(self.file)))) + app = FastAPI() + app.include_router(router, prefix="/api") + + async def fake_db(): + yield self.db + + app.dependency_overrides[get_db] = fake_db + self.client = httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") + self.config = patch("app.api.books.settings", SimpleNamespace(upload_dir=str(self.root))) + self.config.start() + + async def asyncTearDown(self): + await self.client.aclose() + self.config.stop() + self.temp.cleanup() + + async def test_original_bytes_and_type(self): + response = await self.client.get("/api/books/book-id/file") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, self.content) + self.assertEqual(response.headers["content-type"], "application/epub+zip") + + async def test_unknown_book(self): + self.db.get.return_value = None + self.assertEqual((await self.client.get("/api/books/unknown/file")).status_code, 404) + + async def test_missing_file(self): + self.db.get.return_value.file_path = str(self.root / "missing.epub") + self.assertEqual((await self.client.get("/api/books/book-id/file")).status_code, 404) + + async def test_outside_upload_directory(self): + self.db.get.return_value.file_path = str(self.root.parent / "outside.epub") + self.assertEqual((await self.client.get("/api/books/book-id/file")).status_code, 404) + + async def test_non_epub_not_served(self): + self.db.get.return_value.file_path = str(self.root / "secrets.txt") + self.assertEqual((await self.client.get("/api/books/book-id/file")).status_code, 404) diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md new file mode 100644 index 0000000..af3b7c8 --- /dev/null +++ b/frontend/AGENTS.md @@ -0,0 +1,9 @@ + + +## This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/eslint.config.mjs b/frontend/eslint.config.mjs similarity index 100% rename from eslint.config.mjs rename to frontend/eslint.config.mjs diff --git a/next.config.ts b/frontend/next.config.ts similarity index 82% rename from next.config.ts rename to frontend/next.config.ts index a0cf0c7..9e59cd9 100644 --- a/next.config.ts +++ b/frontend/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + devIndicators: false, cacheComponents: true, partialPrefetching: true, turbopack: { @@ -14,4 +14,4 @@ const nextConfig: NextConfig = { }, }; -export default nextConfig; +export default nextConfig; \ No newline at end of file diff --git a/package-lock.json b/frontend/package-lock.json similarity index 63% rename from package-lock.json rename to frontend/package-lock.json index a94759e..45fc97d 100644 --- a/package-lock.json +++ b/frontend/package-lock.json @@ -8,9 +8,19 @@ "name": "frontend", "version": "0.1.0", "dependencies": { + "epubjs": "^0.3.93", + "html-to-image": "^1.11.13", + "katex": "^0.19.0", + "lucide-react": "^1.61.0", + "mermaid": "^12.2.0", "next": "16.4.2", "react": "19.3.0", - "react-dom": "19.3.0" + "react-dom": "19.3.0", + "react-markdown": "^10.1.0", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "shiki": "^4.5.0" }, "devDependencies": { "@tailwindcss/turbopack": "^4", @@ -36,6 +46,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@antfu/install-pkg": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-2.1.0.tgz", + "integrity": "sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.8.0", + "tinyexec": "^1.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -276,6 +299,49 @@ "node": ">=6.9.0" } }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-13.2.0.tgz", + "integrity": "sha512-bA9dvWlhAUcHkNwU+8w9yiUxJPlj/Lm3VqS7MD+NbIR/7LmpNLuK/AWNFdCo/T3SK5ND0/UHf3IJvl/Jn/i6yg==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "13.2.0", + "@chevrotain/types": "13.2.0" + } + }, + "node_modules/@chevrotain/gast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-13.2.0.tgz", + "integrity": "sha512-bCARdAsG+eEbOveSXcOAC7pVxA04I1ionzeMOwIYf5NX7SaS7zgn4Dgug2BSqoL+iyKeMNSorQjsFSHJHsCNwg==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "13.2.0" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-13.2.0.tgz", + "integrity": "sha512-kxpUkLtiaK09ph+dib+Yvm+3sWSnuwlo25gqfKRgTPNmF6H7uW4gVkMjaRdtnN783S9sM6vUNWh9CsxYX4QwKw==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-13.2.0.tgz", + "integrity": "sha512-7qrYHRlKfWf2AvBfJi5eVOrKAkbxvMFsy/iIHTGIiApNcEecCaVIdR+mIQhyCBsTDJxBz4JWXHMo6krbhR9hVg==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-13.2.0.tgz", + "integrity": "sha512-SzKYY68rseOrb9KILVzPsl6SjkQtdWw01SRGB5aCVI5kFcHltQsOYV3JB0Ffst72DgkvHCyDQ0NLwxtmCCf97Q==", + "license": "Apache-2.0" + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -519,6 +585,23 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.7.tgz", + "integrity": "sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^2.0.1", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -1118,6 +1201,18 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mermaid-js/parser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-2.0.1.tgz", + "integrity": "sha512-j37dj/h6p6pots1L7pLPPNuABEEu9WP4OYg9pW1g+nYG331j0+3canE9OagtYdytYvdSAMG+2fKU5cxep6LgjA==", + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~13.2.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz", @@ -1384,6 +1479,106 @@ "dev": true, "license": "MIT" }, + "node_modules/@shikijs/core": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.5.0.tgz", + "integrity": "sha512-6CIgwq+bt2JZ3OSjb/nBkkwEf/CfpHcbam+Mg5Pllco7HAGRQ2/EFhZrrJtbyNTgKwknfVS+llsXGygiDGwJWw==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.5.0", + "@shikijs/types": "4.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.5.0.tgz", + "integrity": "sha512-Ed6UdEj84LyYkwnxM5CvSMWAD+id9ExTYri0/+x41U9zrgNifPSS9lZm0JqBP0VfYI8yr8tY5nbNq9+p9R0kFA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.5.0.tgz", + "integrity": "sha512-dKVmbJecVdB9recl8a/CU4jdpNXyjJQrIcesIkpS70s5HS02ohpnCSpMUbotPnEUzbAcLQ+S4hwZYbkBBiTcJw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.5.0", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.5.0.tgz", + "integrity": "sha512-wwBTqwduXSz0X4v9Qa2EWeFaYJEw1clCncWjoRNQRbVDu3SPRTRWucnouYapKj3b8Q+sk/41Vbc4B5c8nuYzLA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.5.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.5.0.tgz", + "integrity": "sha512-UM7ShlA8oz0OWxLLO8BmO9Ip6fVqdkt8yF0EH7nOCqHfrx1eEAzF0ZUVK7L+I8NEgeu1PEWSRlxaiPPeCb4iDg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.5.0.tgz", + "integrity": "sha512-zGb87H3UBbpuI4yoh5UguB1XbU+2BDU3kflM7UfZ+Cy2hWo9lBtqjSVK20gmLY6ukYl040hAypGXMgud9vul8Q==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.5.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.5.0.tgz", + "integrity": "sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.1.0.tgz", + "integrity": "sha512-cVBuhDQjBZaNMTwBquYfPydrUP+E3hE6oYGasx8h6ZrODIM7/r+gxLDXdd66oszbrggZHEntdE6j+5gYIVyMWQ==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", @@ -1686,13 +1881,298 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz", + "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.9.tgz", + "integrity": "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1707,6 +2187,37 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, + "node_modules/@types/localforage": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/@types/localforage/-/localforage-0.0.34.tgz", + "integrity": "sha512-tJxahnjm9dEI1X+hQSC5f2BSd/coZaqbIl1m3TCl0q9SVuC52XcXfV0XmoCU1+PmjyucuVITwoTnN8OlTbEXXA==", + "deprecated": "This is a stub types definition for localforage (https://github.com/localForage/localForage). localforage provides its own type definitions, so you don't need @types/localforage installed!", + "license": "MIT", + "dependencies": { + "localforage": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", @@ -1721,7 +2232,6 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1737,6 +2247,19 @@ "@types/react": "^19.3.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.72.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.0.tgz", @@ -2032,6 +2555,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "license": "ISC" + }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", @@ -2386,6 +2915,25 @@ "win32" ] }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz", + "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==", + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, "node_modules/acorn": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", @@ -2672,6 +3220,16 @@ "node": ">= 0.4" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2829,6 +3387,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2846,6 +3414,62 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chevrotain": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-13.2.0.tgz", + "integrity": "sha512-GTouk5YzgBxlTPCaDn5q0q2EJKkrYmnPF+HmF8v0Qo4r3iyPG7HJivkN1wthCXZcm3sIcZFpGTRovLPNdJNRCA==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "13.2.0", + "@chevrotain/gast": "13.2.0", + "@chevrotain/regexp-to-ast": "13.2.0", + "@chevrotain/types": "13.2.0", + "@chevrotain/utils": "13.2.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -2872,6 +3496,25 @@ "dev": true, "license": "MIT" }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2886,6 +3529,35 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.51.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.51.0.tgz", + "integrity": "sha512-7bDKx/5jm5MxzsXxc93g6Z2+4ZsuT/MddrfRmTDJNAkWsYWHU8EVNEsBiIEWnMAMCHMmRfUEjkuo3jeNBOIHRw==", + "hasInstallScript": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2905,9 +3577,529 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, + "node_modules/cytoscape": { + "version": "3.34.4", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.4.tgz", + "integrity": "sha512-msY99tMY8iWTg+sIPGfSqDcPNOt4xcj+v/K/7qUFaCUxxlqnMkUYLBjRUzn9W5CU/R2hrmtJa5+UtovrEM2ctg==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -2969,11 +4161,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2987,6 +4184,19 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3030,6 +4240,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3040,6 +4268,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -3053,6 +4294,15 @@ "node": ">=0.10.0" } }, + "node_modules/dompurify": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", + "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3075,6 +4325,12 @@ "dev": true, "license": "ISC" }, + "node_modules/elkjs": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", + "license": "EPL-2.0" + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -3096,6 +4352,35 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/epubjs": { + "version": "0.3.93", + "resolved": "https://registry.npmjs.org/epubjs/-/epubjs-0.3.93.tgz", + "integrity": "sha512-c06pNSdBxcXv3dZSbXAVLE1/pmleRhOT6mXNZo6INKmvuKpYB65MwU/lO7830czCtjIiK9i+KR+3S+p0wtljrw==", + "license": "BSD-2-Clause", + "dependencies": { + "@types/localforage": "0.0.34", + "@xmldom/xmldom": "^0.7.5", + "core-js": "^3.18.3", + "event-emitter": "^0.3.5", + "jszip": "^3.7.1", + "localforage": "^1.10.0", + "lodash": "^4.17.21", + "marks-pane": "^1.0.9", + "path-webpack": "0.0.3" + } + }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -3295,6 +4580,58 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3661,6 +4998,21 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -3715,6 +5067,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3725,6 +5087,31 @@ "node": ">=0.10.0" } }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4063,6 +5450,12 @@ "dev": true, "license": "ISC" }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -4157,6 +5550,197 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -4174,6 +5758,44 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4184,6 +5806,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4201,6 +5829,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -4211,6 +5849,18 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4226,6 +5876,39 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -4384,6 +6067,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-document.all": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", @@ -4459,6 +6152,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -4512,6 +6215,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -4792,6 +6507,34 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", + "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/katex": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.19.0.tgz", + "integrity": "sha512-v6Tznz3zJ7u3niRCoDTsumM2+HA2XXcCu+WAacCeHD2z3p9A9Ks987o5FzfTGBN0e8A0vjEgIDvLbICrXpdw/Q==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^15.0.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4802,6 +6545,11 @@ "json-buffer": "3.0.1" } }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -4822,6 +6570,12 @@ "node": ">=0.10" } }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -4836,6 +6590,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -5109,6 +6872,24 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/localforage": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", + "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", + "license": "Apache-2.0", + "dependencies": { + "lie": "3.1.1" + } + }, + "node_modules/localforage/node_modules/lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5125,6 +6906,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5132,6 +6925,16 @@ "dev": true, "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -5155,6 +6958,21 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.61.0.tgz", + "integrity": "sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==", + "license": "ISC", + "peerDependencies": { + "@types/react": "*", + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5165,6 +6983,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/marks-pane": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/marks-pane/-/marks-pane-1.0.9.tgz", + "integrity": "sha512-Ahs4oeG90tbdPWwAJkAAoHg2lRR8lAs9mZXETNPO9hYg3AkjUJBKi1NQ4aaIQZVGrig7c/3NUV1jANl8rFTeMg==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5175,6 +7021,309 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.3.tgz", + "integrity": "sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.1.0.tgz", + "integrity": "sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.1.tgz", + "integrity": "sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.2.0.tgz", + "integrity": "sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5185,6 +7334,684 @@ "node": ">= 8" } }, + "node_modules/mermaid": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-12.2.0.tgz", + "integrity": "sha512-w7nr/SFrLO7BaVTs3dQTvYSV6nYSBj0Yy9DjBhsIJtAcHqRzXSq4Pg32ujHZ/AdWsAMNEvvhbm5dt9E2tOiHzw==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.2", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^2.0.1", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "chevrotain": "~13.2.0", + "cytoscape": "^3.34.0", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.21", + "dompurify": "^3.4.12", + "elkjs": "^0.9.3", + "es-toolkit": "^1.45.1", + "katex": "^0.18.0", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/mermaid/node_modules/katex": { + "version": "0.18.10", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.10.tgz", + "integrity": "sha512-/B6p9eY9DX7aHBfpkHdpirDTZ5QH9xTwL9y837PWuzuv41O5jFdqNQwVQEQsimY0/iVNkwBY4+4hMfhQ7d4Dbw==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^15.0.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/micromark": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz", + "integrity": "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.4.tgz", + "integrity": "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.1.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.3" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz", + "integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/micromark-extension-math/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.1.0.tgz", + "integrity": "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-edit-map": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-edit-map/-/micromark-util-edit-map-1.0.0.tgz", + "integrity": "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz", + "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -5226,7 +8053,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -5324,6 +8150,12 @@ } } }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "license": "ISC" + }, "node_modules/node-exports-info": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", @@ -5476,6 +8308,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5545,6 +8394,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-manager-detector": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.9.0.tgz", + "integrity": "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==", + "license": "MIT" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5558,6 +8419,49 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5585,6 +8489,12 @@ "dev": true, "license": "MIT" }, + "node_modules/path-webpack": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/path-webpack/-/path-webpack-0.0.3.tgz", + "integrity": "sha512-AmeDxedoo5svf7aB3FYqSAKqMxys014lVKBzy1o/5vv9CtU7U4wgGWL1dA2o6MOzcD53ScN4Jmiq6VbtLz1vIQ==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5604,6 +8514,22 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -5652,6 +8578,12 @@ "node": ">= 0.8.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5664,6 +8596,16 @@ "react-is": "^16.13.1" } }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5723,6 +8665,54 @@ "dev": true, "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5746,6 +8736,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -5767,6 +8781,132 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/rehype-katex/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -5822,6 +8962,24 @@ "node": ">=0.10.0" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5846,6 +9004,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -5866,6 +9030,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -5901,6 +9071,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", @@ -5966,6 +9142,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/sharp": { "version": "0.35.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", @@ -6052,6 +9234,25 @@ "node": ">=8" } }, + "node_modules/shiki": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.5.0.tgz", + "integrity": "sha512-OcRW72ngQvc+MSVWBRIovC/BTW589iUE/Vxav7VeM4RcY9MUjqB+IR6ILG1k9nuW6LiG/nct9uma4/MBtnZbmQ==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.5.0", + "@shikijs/engine-javascript": "4.5.0", + "@shikijs/engine-oniguruma": "4.5.0", + "@shikijs/langs": "4.5.0", + "@shikijs/themes": "4.5.0", + "@shikijs/types": "4.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -6137,6 +9338,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -6158,6 +9369,15 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -6272,6 +9492,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -6295,6 +9529,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -6318,6 +9570,12 @@ } } }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -6365,6 +9623,15 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -6426,6 +9693,26 @@ "node": ">=8.0" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -6439,6 +9726,15 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -6471,6 +9767,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "license": "ISC" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6625,6 +9927,121 @@ "dev": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unrs-resolver": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", @@ -6704,6 +10121,77 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.3.tgz", + "integrity": "sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6861,6 +10349,16 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/frontend/package.json similarity index 56% rename from package.json rename to frontend/package.json index 66b49b0..1d6e8f3 100644 --- a/package.json +++ b/frontend/package.json @@ -9,9 +9,24 @@ "lint": "eslint" }, "dependencies": { + "epubjs": "^0.3.93", + "html-to-image": "^1.11.13", + "katex": "^0.19.0", + "lucide-react": "^1.61.0", + "mermaid": "^12.2.0", "next": "16.4.2", "react": "19.3.0", - "react-dom": "19.3.0" + "react-dom": "19.3.0", + "react-markdown": "^10.1.0", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "shiki": "^4.5.0" + }, + "overrides": { + "epubjs": { + "@xmldom/xmldom": "^0.9.12" + } }, "devDependencies": { "@tailwindcss/turbopack": "^4", diff --git a/public/file.svg b/frontend/public/file.svg similarity index 100% rename from public/file.svg rename to frontend/public/file.svg diff --git a/public/globe.svg b/frontend/public/globe.svg similarity index 100% rename from public/globe.svg rename to frontend/public/globe.svg diff --git a/public/next.svg b/frontend/public/next.svg similarity index 100% rename from public/next.svg rename to frontend/public/next.svg diff --git a/public/vercel.svg b/frontend/public/vercel.svg similarity index 100% rename from public/vercel.svg rename to frontend/public/vercel.svg diff --git a/public/window.svg b/frontend/public/window.svg similarity index 100% rename from public/window.svg rename to frontend/public/window.svg diff --git a/src/app/favicon.ico b/frontend/src/app/favicon.ico similarity index 100% rename from src/app/favicon.ico rename to frontend/src/app/favicon.ico diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000..d242d68 --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,128 @@ +@import "tailwindcss"; + +/* Full-book reading stays above the workspace, keeping chat state mounted. */ +.open-full-book { display: flex; align-items: center; gap: 7px; padding: 9px 12px; border: 1px solid #b9cbb8; border-radius: 7px; color: var(--accent); background: var(--accent-soft); font-size: 12px; } +.book-card-read { display: flex; align-items: center; gap: 5px; margin-top: 12px; padding: 5px 0; color: var(--accent); font-size: 11px; } +.book-card-read:hover { text-decoration: underline; } +.epub-reader { position: fixed; inset: 0; z-index: 100; display: flex; flex-direction: column; background: #f1efe7; color: var(--fg); } +.epub-toolbar { display: flex; align-items: center; gap: 18px; min-height: 74px; padding: 12px 28px; border-bottom: 1px solid var(--border); background: var(--bg-card); } +.epub-toolbar button, .epub-footer button { display: flex; align-items: center; justify-content: center; gap: 7px; padding: 9px; border-radius: 6px; font-size: 12px; } +.epub-toolbar button:hover, .epub-footer button:hover { background: var(--accent-soft); } +.epub-book-title { flex: 1; min-width: 0; } +.epub-book-title strong, .epub-book-title small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.epub-book-title strong { font: 17px Georgia, serif; } +.epub-book-title small { margin-top: 4px; color: var(--fg-muted); font-size: 11px; } +.epub-font-controls { display: flex; align-items: center; gap: 6px; border-left: 1px solid var(--border); padding-left: 12px; font-size: 12px; } +.epub-body { flex: 1; min-height: 0; display: flex; gap: 24px; padding: 24px; } +.epub-toc { width: 260px; flex-shrink: 0; overflow-y: auto; background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px; } +.epub-toc h3 { position: sticky; top: 0; display: flex; align-items: center; gap: 8px; padding: 18px 16px; margin: 0; background: var(--bg-card); font-size: 14px; border-bottom: 1px solid var(--border); } +.epub-toc button { display: block; width: 100%; padding: 11px 16px; text-align: left; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; } +.epub-toc button:hover, .epub-toc button[aria-current] { background: var(--accent-soft); color: var(--accent); } +.epub-toc > p { padding: 16px; font-size: 12px; color: var(--fg-muted); } +.epub-paper { flex: 1; position: relative; min-width: 0; max-width: 1000px; margin: 0 auto; padding: 18px 24px; background: var(--bg-card); border: 1px solid #e3dfd3; box-shadow: 0 4px 24px #283d3010; border-radius: 4px; } +.epub-viewport { width: 100%; height: 100%; overflow: hidden; } +.epub-loading { position: absolute; inset: 0; z-index: 2; display: flex; flex-direction: column; align-items: center; justify-content: center; background: var(--bg-card); color: var(--accent); font-size: 13px; } +.epub-error { position: absolute; top: 12px; left: 12px; right: 12px; z-index: 3; padding: 16px; background: #f6e7dc; border: 1px solid #dbbf9d; color: #835c43; font-size: 13px; } +.epub-error button { display: flex; align-items: center; gap: 6px; margin-top: 8px; text-decoration: underline; } +.epub-selection { display: flex; align-items: center; justify-content: center; gap: 16px; padding: 10px 16px; background: #dfe9df; font-size: 12px; } +.epub-selection button { display: flex; align-items: center; gap: 6px; padding: 8px 12px; background: var(--accent); color: white; border-radius: 6px; } +.epub-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 68px; padding: 10px 28px; padding-bottom: max(10px, env(safe-area-inset-bottom)); background: var(--bg-card); border-top: 1px solid var(--border); } +.epub-footer > div { min-width: 0; max-width: 65%; text-align: center; } +.epub-footer strong, .epub-footer small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.epub-footer strong { font-size: 12px; font-weight: 400; } +.epub-footer small { margin-top: 5px; font-size: 10px; color: var(--fg-muted); } +@media (max-width: 680px) { + .open-full-book { padding: 8px; } + .epub-toolbar { min-height: 64px; gap: 8px; padding: 8px; } + .epub-toolbar > button span { display: none; } + .epub-font-controls { padding-left: 4px; gap: 2px; } + .epub-body { padding: 8px; gap: 0; } + .epub-paper { padding: 8px 0; } + .epub-toc { position: absolute; z-index: 5; top: 72px; bottom: 80px; left: 8px; width: min(300px, calc(100% - 16px)); box-shadow: 10px 0 32px #283d3033; } + .epub-footer { padding: 8px; } + .epub-footer button span { display: none; } +} + +:root { + /* 暖纸色、墨绿色的阅读空间设计系统 */ + --bg: #f6f4ef; + --bg-card: #fffdf8; + --bg-raised: #eeece6; + --bg-hover: rgba(36, 59, 51, .06); + --fg: #243b33; + --fg-muted: #7e887f; + --border: #dfded7; + --border-strong: #c7c9bf; + --accent: #31594a; + --accent-hover: #23483a; + --accent-soft: #dfe9df; + --user-bubble: #31594a; + --assistant-bubble: transparent; + --danger: #bd6058; + --success: #59896b; +} + +body { + background: var(--bg); + color: var(--fg); + font-family: Arial, "PingFang SC", "Microsoft YaHei", sans-serif; +} + +.library-sidebar{background:#eff0ea;border-right:1px solid var(--border);z-index:30}.brand{padding:25px 22px 22px}.brand-mark{width:36px;height:36px;border-radius:11px;color:#f8f5ed;background:var(--accent);box-shadow:5px 5px 0 #c9d8ca}.brand-name{margin:0;font:700 18px Georgia,serif}.nav-library{width:100%;border:0;display:flex;align-items:center;gap:10px;padding:11px 12px;border-radius:10px;color:var(--fg-muted);font-size:13px;text-align:left;cursor:pointer}.nav-library span{margin-left:auto;font-size:11px;color:#a6aaa0}.nav-library:hover,.nav-library.active{color:var(--fg);background:rgba(49,89,74,.1)}.upload-button{border:1px solid #254a3c;border-radius:9px;padding:11px 13px;color:#f8f5ed;background:var(--accent);font-size:13px;font-weight:700;cursor:pointer;box-shadow:0 5px 0 #c5d5c6}.sidebar-footer{padding:14px 16px 18px;margin-top:auto;border-top:1px solid var(--border)}.reading-note{display:flex;gap:7px;padding:9px 7px 15px;color:#9a8470;font:italic 12px Georgia,serif}.sidebar-footer>button{width:100%;display:flex;align-items:center;gap:9px;border:0;padding:9px 7px;color:var(--fg-muted);background:transparent;font-size:12px;text-align:left}.sidebar-footer>button span{flex:1}.sidebar-edition{display:block;margin-top:12px;color:#abb0a8;font-size:8px;letter-spacing:.16em}.sidebar-resizer{width:3px!important;background:var(--border)!important}.main-panel{background:var(--bg)}.reader-header{min-height:72px;padding:0 36px;background:rgba(246,244,239,.9)}.reader-header h2{font:18px Georgia,serif}.back-library{display:none}.conversation-sidebar{width:204px;background:#f0f0eb;border-right:1px solid var(--border)}.composer-area{background:rgba(246,244,239,.95);border-top:1px solid var(--border)}.composer{background:var(--bg-card);border:1px solid #d9dbd3;box-shadow:0 7px 20px rgba(36,59,51,.06)}.composer:focus-within{border-color:#8eaa94}.mobile-menu,.sidebar-scrim{display:none}.streaming-answer{border-left:2px solid #99b89f;padding-left:12px} + +::selection { + background: #bfd7c8; +} + +/* Scrollbar */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-thumb { background: #c9cdc6; border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #a9b2a6; } +::-webkit-scrollbar-track { background: transparent; } + +button { cursor: pointer; } +button:disabled { cursor: default; opacity: .5; } +:focus-visible { outline: 2px solid #7a9b80; outline-offset: 3px; } +.topbar-note { color: #9a9e90; font: italic 12px Georgia, serif; } +.breadcrumb-slash { margin: 0 10px; color: #b8c0b6; } +.books-toolbar h2 span { color: #8e9e8d; font: 12px Arial, sans-serif; margin-left: 6px; } +.book-card-info span { color: #889687; font-size: 10px; letter-spacing: .08em; } +.book-open-label { position: absolute; right: 10px; bottom: 8px; display: flex; gap: 4px; align-items: center; color: var(--accent); font-size: 10px; opacity: 0; } +.book-card:hover .book-open-label, .book-card:focus-within .book-open-label { opacity: 1; } +.book-card-delete { position: absolute; right: 5px; top: 5px; } +.book-card:focus-within .book-card-delete button { opacity: 1; } +.book-cover h3 { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; } +.add-book-card small { color: #a1ad9f; font-size: 10px; } +.orbit { position: absolute; border: 1px solid #8da68b; border-radius: 50%; opacity: .4; } +.orbit-one { width: 310px; height: 145px; top: 65px; left: 15px; transform: rotate(-25deg); } +.orbit-two { width: 250px; height: 110px; top: 79px; left: 45px; transform: rotate(35deg); } +.art-caption { position: absolute; bottom: 3px; right: 22px; color: #a8bda9; font: italic 11px Georgia, serif; } +.book-line { width: 80%; height: 1px; background: #bda988; } +.cover-motif { display: flex; gap: 3px; } +.cover-motif span { width: 3px; height: 3px; border: 1px solid currentColor; border-radius: 50%; } +.text-link { display: flex; align-items: center; gap: 5px; color: var(--accent); font-size: 12px; } +.library-state p { font-size: 12px; } +.hero-art { flex-shrink: 0; } +.hero-copy { min-width: 0; } +.hero-button:hover { background: rgba(255,255,255,.16); } +.prompt-grid button:hover { background: var(--accent-soft); border-color: #9bb79e; } +.no-results { grid-column: 1 / -1; } +@media (max-width: 1100px) and (min-width: 681px) { + .library-hero { padding: 32px; } + .hero-copy h1 { font-size: 36px; } + .hero-art { width: 220px; margin-left: -30px; } + .art-book-front { left: 5px; } + .orbit { display: none; } +} +@media (max-width: 680px) { + .topbar-note, .desktop-break { display: none; } + .hero-art { width: 365px; } + .library-sidebar.is-open .brand { padding-left: 65px; } + .book-card-delete button { opacity: 1; } + .book-cover h3 { font-size: 13px; } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; } +} +.library-page{min-height:100%;overflow-y:auto}.library-topbar{height:70px;display:flex;align-items:center;justify-content:space-between;max-width:1170px;margin:auto;padding:0 30px;color:#9ba49c;font-size:12px}.library-content{max-width:1170px;margin:auto;padding:0 30px 24px}.library-hero{min-height:370px;display:flex;align-items:center;justify-content:space-between;padding:46px 50px 42px 64px;border-radius:5px;color:#f6f3eb;background:#31594a;overflow:hidden;position:relative}.hero-copy{position:relative;z-index:2}.eyebrow{display:block;color:#a9c0aa;font-size:10px;letter-spacing:.2em;font-weight:700}.hero-copy h1{margin:22px 0 17px;font:400 46px/1.2 Georgia,serif}.hero-copy h1 span{color:#e6bb8d}.hero-copy p{margin:0;color:#c2d1c2;font-size:14px;line-height:1.9}.hero-button{display:flex;align-items:center;gap:9px;margin-top:26px;border:1px solid #a3c0a6;border-radius:4px;padding:11px 15px;color:#eff5ed;background:rgba(255,255,255,.08);font-size:12px}.hero-art{width:365px;height:280px;position:relative}.art-book{position:absolute;display:flex;flex-direction:column;justify-content:space-between;box-shadow:12px 16px 0 rgba(22,50,39,.25)}.art-book-back{width:125px;height:175px;right:26px;top:34px;padding:23px 16px;color:#efe5d4;background:#9c7763;font:bold 11px/1.3 Arial;transform:rotate(10deg)}.art-book-front{width:144px;height:198px;left:76px;top:44px;align-items:center;padding:24px 16px 18px;color:#3c5e4b;background:#e8dbbe;font:26px Georgia,serif;transform:rotate(-8deg)}.art-book-front small{font:8px Arial;letter-spacing:.16em;color:#9e896e}.art-star{position:absolute;top:10px;right:45px;color:#dfbd93;font-size:30px}.books-section{padding:45px 4px 0}.books-toolbar{display:flex;justify-content:space-between;align-items:end;margin-bottom:23px}.books-toolbar h2{margin:0;font:27px Georgia,serif}.books-toolbar p{margin:8px 0 0;color:#9ca49c;font-size:12px}.book-search{width:220px;display:flex;align-items:center;gap:8px;padding:9px 11px;border-bottom:1px solid #c9cdc5;color:#99a39a}.book-search input{width:100%;border:0;outline:0;color:var(--fg);background:transparent;font-size:12px}.book-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:26px 18px}.book-card{position:relative;min-width:0}.book-card-open{width:100%;border:0;padding:0;color:inherit;background:transparent;text-align:left}.book-stage{aspect-ratio:1.28/1;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;border-radius:4px}.cover-tone-0{background:#dbe4dc}.cover-tone-1{background:#e8ddca}.cover-tone-2{background:#d5e0e4}.cover-tone-3{background:#e5d7da}.book-cover{width:43%;aspect-ratio:.7;display:flex;flex-direction:column;align-items:center;justify-content:space-between;padding:15px 10px 11px;color:#e8eddf;background:#31594a;box-shadow:7px 9px 0 rgba(58,77,66,.2);text-align:center;transform:rotate(-3deg)}.cover-tone-1 .book-cover{color:#594c3c;background:#d5a982}.cover-tone-2 .book-cover{background:#466d73}.cover-tone-3 .book-cover{background:#c48f95}.book-cover h3{margin:auto 0;font:15px/1.25 Georgia,serif}.cover-label,.cover-author{font-size:6px;letter-spacing:.12em}.book-card-info{padding:13px 1px 0}.book-card-info h3{overflow:hidden;margin:0;color:var(--fg);font:16px Georgia,serif;text-overflow:ellipsis;white-space:nowrap}.book-card-info p{margin:5px 0 8px;color:#89938b;font-size:11px}.add-book-card{min-height:230px;display:flex;flex-direction:column;align-items:center;justify-content:center;border:1px dashed #bfc7bf;color:#74877a;background:rgba(255,255,255,.3)}.add-book-card>span{display:grid;place-items:center;width:46px;height:46px;margin-bottom:13px;border:1px solid #a8bca9;border-radius:50%;color:#6e9277}.add-book-card strong{font:15px Georgia,serif}.add-book-card p{margin:7px 0 20px;color:#9aa59c;font-size:11px}.library-state{min-height:220px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;color:#9ca69d;text-align:center}.library-state h3{margin:3px 0 0;color:var(--fg);font:17px Georgia,serif}.library-bottom{display:flex;justify-content:space-between;margin-top:48px;padding-top:14px;border-top:1px solid var(--border);color:#abb1a9;font-size:10px}.chat-welcome{max-width:650px;margin:auto;padding:45px 0 25px;text-align:center}.chat-welcome-icon{display:grid;place-items:center;width:63px;height:63px;margin:0 auto 20px;border:1px solid #c4d2c4;border-radius:50%;color:var(--accent);background:#eaf0e9}.chat-welcome h2{margin:14px 0 8px;font:30px Georgia,serif}.chat-welcome>p{margin:0;color:#89938b;font-size:13px}.prompt-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:33px;text-align:left}.prompt-grid button{min-height:132px;display:flex;flex-direction:column;align-items:flex-start;gap:9px;padding:14px;border:1px solid var(--border);border-radius:5px;color:var(--fg-muted);background:rgba(255,255,255,.3);text-align:left}.prompt-grid strong{color:var(--fg);font-size:12px}.prompt-grid span{font-size:11px;line-height:1.55}.prompt-grid svg:last-child{margin-top:auto;align-self:flex-end;color:#a7b6a9} +@media(max-width:900px){.library-hero{padding-left:38px}.hero-art{transform:scale(.8)}.book-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(max-width:680px){.mobile-menu{display:grid;place-items:center;position:fixed;top:13px;left:13px;z-index:50;width:38px;height:38px;border:1px solid var(--border);border-radius:9px;color:var(--fg);background:var(--bg-card)}.sidebar-scrim{display:block;position:fixed;inset:0;z-index:20;border:0;background:rgba(20,36,28,.28)}.library-sidebar{position:fixed;inset:0 auto 0 0;width:270px!important;transform:translateX(-105%);transition:transform .22s}.library-sidebar.is-open{transform:translateX(0)}.sidebar-resizer,.conversation-sidebar{display:none}.library-topbar{height:64px;padding-left:65px}.library-content{padding:0 16px 20px}.library-hero{min-height:460px;align-items:flex-start;padding:34px 25px}.hero-copy h1{font-size:37px}.hero-art{position:absolute;right:-85px;bottom:-35px;transform:scale(.7)}.books-section{padding-top:31px}.books-toolbar{display:block}.book-search{width:100%;margin-top:20px}.book-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:24px 12px}.book-stage{aspect-ratio:1/1.08}.book-cover{width:50%}.library-bottom{display:none}.reader-header{min-height:62px;padding-left:57px}.back-library{display:grid;place-items:center}.chat-welcome{padding:30px 8px}.chat-welcome h2{font-size:25px}.prompt-grid{grid-template-columns:1fr}.prompt-grid button{min-height:0}.composer-hint{display:none}} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..1e6e86d --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "BookMate - AI 读书助手", + description: "上传 EPUB,和 AI 一起读书", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 0000000..741d93a --- /dev/null +++ b/frontend/src/app/page.tsx @@ -0,0 +1,899 @@ +"use client"; + +import { useState, useEffect, useMemo, useRef } from "react"; +import { + BookMarked, BookOpen, Bot, Check, ChevronDown, ChevronLeft, ChevronRight, Copy, + Download, GitBranch, Loader2, Paperclip, Plus, RotateCcw, Send, Settings2, + Sparkles, Trash2, Upload, User, X, ArrowUpRight, Library, Search, Menu, MessageSquare, ArrowLeft, +} from "lucide-react"; +import { api, Book, Conversation, Message, Citation, AiModelEntry, formatCharCount } from "@/lib/api"; +import MarkdownRenderer from "@/components/MarkdownRenderer"; +import ModelManagerDialog from "@/components/ModelManagerDialog"; +import CitationReaderDialog from "@/components/CitationReaderDialog"; +import EpubReader from "@/components/EpubReader"; + +const SIDEBAR_MIN = 180; +const SIDEBAR_MAX = 480; + +export default function Home() { + const [books, setBooks] = useState([]); + const [selectedBook, setSelectedBook] = useState(null); + const [conversations, setConversations] = useState([]); + const [activeConv, setActiveConv] = useState(null); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [streaming, setStreaming] = useState(false); + const [streamContent, setStreamContent] = useState(""); + const [uploading, setUploading] = useState(false); + const [model, setModel] = useState("deepseek"); + const [aiModels, setAiModels] = useState([]); + const [pickerOpen, setPickerOpen] = useState(false); + const [managerOpen, setManagerOpen] = useState(false); + const [readerBook, setReaderBook] = useState(null); + const [search, setSearch] = useState(""); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [booksLoading, setBooksLoading] = useState(true); + const [booksError, setBooksError] = useState(false); + const chatEndRef = useRef(null); + const fileInputRef = useRef(null); + const [sidebarWidth, setSidebarWidth] = useState(256); + const sidebarDragRef = useRef<{ startX: number; startW: number } | null>(null); + // 多版本回答:每个 user 消息当前选中哪个版本的回答(key = user 消息 id) + const [activeVersions, setActiveVersions] = useState>({}); + // 流式状态归属:null 表示新问题(渲染在末尾);有值表示重试某条 user 消息(渲染在该轮内) + const [streamTurnId, setStreamTurnId] = useState(null); + + const turns = useMemo(() => buildTurns(messages), [messages]); + + const loadBooks = () => { + setBooksLoading(true); + setBooksError(false); + api.listBooks().then(setBooks).catch(() => setBooksError(true)).finally(() => setBooksLoading(false)); + }; + useEffect(() => { + api.listBooks().then(setBooks).catch(() => setBooksError(true)).finally(() => setBooksLoading(false)); + }, []); + + // 加载可用模型;默认选中第一个(通常是 deepseek) + useEffect(() => { + api.listModels().then((ms) => { + setAiModels(ms); + if (ms.length > 0) setModel((cur) => (ms.some((m) => m.name === cur) ? cur : ms[0].name)); + }).catch(() => setAiModels([])); + }, []); + + const currentModel = aiModels.find((m) => m.name === model) ?? aiModels[0]; + + // 恢复/保存侧栏宽度(localStorage) + useEffect(() => { + const saved = localStorage.getItem("bookmate.sidebarWidth"); + const n = saved ? Number(saved) : 256; + if (Number.isFinite(n)) setSidebarWidth(Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, n))); + }, []); + useEffect(() => { + localStorage.setItem("bookmate.sidebarWidth", String(sidebarWidth)); + }, [sidebarWidth]); + + useEffect(() => { + if (selectedBook) api.listConversations(selectedBook.id).then(setConversations); + }, [selectedBook]); + + useEffect(() => { + if (activeConv) api.getMessages(activeConv.id).then(setMessages); + }, [activeConv]); + + useEffect(() => { chatEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, streamContent]); + + const handleUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setUploading(true); + try { + const book = await api.uploadBook(file); + setBooks((prev) => [book, ...prev]); + setSelectedBook(book); + } catch (err: unknown) { + alert(err instanceof Error ? err.message : "Upload failed"); + } + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const startChat = async (book: Book) => { + setSidebarOpen(false); + setSelectedBook(book); + setActiveConv(null); + setMessages([]); + const convs = await api.listConversations(book.id); + setConversations(convs); + if (convs.length > 0) { + setActiveConv(convs[0]); + } else { + const conv = await api.createConversation(book.id, model); + setConversations([conv]); + setActiveConv(conv); + } + }; + + const handleSend = async () => { + if (!input.trim() || streaming) return; + let conv = activeConv; + if (!conv && selectedBook) { + conv = await api.createConversation(selectedBook.id, model); + setConversations((prev) => [conv!, ...prev]); + setActiveConv(conv); + } + if (!conv) return; + + const userContent = input; + setInput(""); + const tempId = `temp-${Date.now()}`; + setMessages((prev) => [...prev, { id: tempId, conversation_id: conv!.id, parent_message_id: null, role: "user", content: userContent, citations: null, model, created_at: new Date().toISOString() }]); + setStreaming(true); + setStreamContent(""); + setStreamTurnId(null); + + let fullContent = ""; + try { + const resp = await api.sendMessage(conv!.id, userContent, model); + const reader = resp.body?.getReader(); + if (!reader) throw new Error("No reader"); + + const decoder = new TextDecoder(); + let buffer = ""; + let assistantId = ""; + let finalCitations: Citation[] = []; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const data = JSON.parse(line.slice(6)); + if (data.type === "token") { + fullContent += data.content; + setStreamContent(fullContent); + } else if (data.type === "done") { + assistantId = data.message_id; + finalCitations = data.citations || []; + } else if (data.type === "error") { + throw new Error(data.content); + } + } + } + setMessages((prev) => { + const filtered = prev.filter((m) => m.id !== tempId); + const userMsg: Message = { id: tempId, conversation_id: conv!.id, parent_message_id: null, role: "user", content: userContent, citations: null, model, created_at: new Date().toISOString() }; + const aiMsg: Message = { id: assistantId, conversation_id: conv!.id, parent_message_id: null, role: "assistant", content: fullContent, citations: finalCitations, model, created_at: new Date().toISOString() }; + return [...filtered, userMsg, aiMsg]; + }); + } catch (err: unknown) { + if (fullContent.trim()) { + // 输出被中断(流式错误/网络):保留已生成的部分,不吞掉内容 + setMessages((prev) => [ + ...prev.filter((m) => m.id !== tempId), + { + id: `interrupted-${Date.now()}`, conversation_id: conv!.id, parent_message_id: null, + role: "assistant", content: fullContent, citations: null, model, + created_at: new Date().toISOString(), + }, + ]); + } + alert(err instanceof Error ? err.message : "Chat error"); + } + setStreaming(false); + setStreamContent(""); + }; + + const handleRetry = async (userMsg: Message) => { + if (streaming) return; + setStreaming(true); + setStreamContent(""); + setStreamTurnId(userMsg.id); + let fullContent = ""; + try { + const conv = activeConv!; + // replyTo = 被重试的用户消息:后端不再新建 user 消息,新回答挂为它的新版本 + const resp = await api.sendMessage(conv.id, userMsg.content, userMsg.model || model, userMsg.id); + const reader = resp.body?.getReader(); + if (!reader) throw new Error("No reader"); + const decoder = new TextDecoder(); + let buffer = ""; + let assistantId = ""; + let finalCitations: Citation[] = []; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const data = JSON.parse(line.slice(6)); + if (data.type === "token") { + fullContent += data.content; + setStreamContent(fullContent); + } else if (data.type === "done") { + assistantId = data.message_id; + finalCitations = data.citations || []; + } else if (data.type === "error") { + throw new Error(data.content); + } + } + } + // 新版本生成完毕:追加为被重试 user 消息之下的一个新回答版本(parent 指向它) + const newIdx = buildTurns(messages).find((t) => t.user.id === userMsg.id)?.versions.length ?? 0; + setMessages((prev) => [...prev, { + id: assistantId, conversation_id: conv.id, parent_message_id: userMsg.id, + role: "assistant", content: fullContent, citations: finalCitations, + model: userMsg.model || model, created_at: new Date().toISOString(), + }]); + setActiveVersions((prev) => ({ ...prev, [userMsg.id]: newIdx })); + } catch (err: unknown) { + if (fullContent.trim()) { + // 重试生成被中断:保留已生成的部分作为该问题的版本,不吞掉内容 + const newIdx = buildTurns(messages).find((t) => t.user.id === userMsg.id)?.versions.length ?? 0; + setMessages((prev) => [ + ...prev, + { + id: `interrupted-${Date.now()}`, conversation_id: activeConv!.id, parent_message_id: userMsg.id, + role: "assistant", content: fullContent, citations: null, + model: userMsg.model || model, created_at: new Date().toISOString(), + }, + ]); + setActiveVersions((prev) => ({ ...prev, [userMsg.id]: newIdx })); + } + alert(err instanceof Error ? err.message : "Retry failed"); + } + setStreaming(false); + setStreamContent(""); + setStreamTurnId(null); + }; + + const handleCopy = async (text: string) => { + await navigator.clipboard.writeText(text); + }; + + const handleDelete = async (msgId: string) => { + if (!confirm("删除这条消息?")) return; + try { + await api.deleteMessage(msgId); + setMessages((prev) => prev.filter((m) => m.id !== msgId)); + } catch { + alert("删除失败"); + } + }; + + const handleBranch = async (msgId: string) => { + if (!activeConv) return; + try { + const branch = await api.branchConversation(activeConv.id, msgId); + setConversations((prev) => [branch, ...prev]); + setActiveConv(branch); + } catch { + alert("创建分支失败"); + } + }; + + const handleNewConversation = async () => { + if (!selectedBook) return; + try { + const conv = await api.createConversation(selectedBook.id, model); + setConversations((prev) => [conv, ...prev]); + setActiveConv(conv); + setMessages([]); + } catch { + alert("新建对话失败"); + } + }; + + const handleDeleteConversation = async (convId: string) => { + if (!confirm("删除这个对话?")) return; + try { + await api.deleteConversation(convId); + setConversations((prev) => prev.filter((c) => c.id !== convId)); + if (activeConv?.id === convId) { + setActiveConv(null); + setMessages([]); + } + } catch { + alert("删除失败"); + } + }; + + const handleDeleteBook = async (book: Book) => { + if (!confirm(`删除《${book.title || book.filename}》?书、目录和对话都会被删除。`)) return; + try { + await api.deleteBook(book.id); + setBooks((prev) => prev.filter((b) => b.id !== book.id)); + if (selectedBook?.id === book.id) { + setSelectedBook(null); + setActiveConv(null); + setConversations([]); + setMessages([]); + } + } catch { + alert("删除失败"); + } + }; + + const handleExport = async () => { + if (!activeConv) return; + try { + const data = await api.exportConversation(activeConv.id); + const blob = new Blob([data.content], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${data.title || "conversation"}.md`; + a.click(); + URL.revokeObjectURL(url); + } catch { + alert("导出失败"); + } + }; + + const selectConv = (conv: Conversation) => { + setActiveConv(conv); + setMessages([]); + }; + + // 侧栏拖动调整宽度(pointer capture + window 监听,拖动期间禁选文本) + const onSidebarResizePointerDown = (e: React.PointerEvent) => { + if (e.button !== 0) return; + e.preventDefault(); + try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* ignore */ } + sidebarDragRef.current = { startX: e.clientX, startW: sidebarWidth }; + document.body.classList.add("select-none", "cursor-col-resize"); + const onMove = (ev: PointerEvent) => { + if (!sidebarDragRef.current) return; + const next = sidebarDragRef.current.startW + (ev.clientX - sidebarDragRef.current.startX); + const max = Math.min(SIDEBAR_MAX, window.innerWidth * 0.5); + setSidebarWidth(Math.max(SIDEBAR_MIN, Math.min(max, next))); + }; + const onUp = () => { + sidebarDragRef.current = null; + document.body.classList.remove("select-none", "cursor-col-resize"); + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); + }; + + return ( +
+ {sidebarOpen && + {/* Sidebar */} + + + {/* 可拖动分隔条:调整书库侧栏宽度 */} +
+ + {/* Main */} +
+ {selectedBook ? ( + <> +
+ +
+

{selectedBook.title || selectedBook.filename}

+

{selectedBook.author}

+
+ + +
+ +
+
+

对话历史

+ + {conversations.map((c) => ( +
+ + handleDeleteConversation(c.id)}> + + +
+ ))} +
+ +
+
+
+ {turns.length === 0 && !streaming && ( +
+
+ READ · THINK · DISCOVER +

从一个好问题开始。

+

关于《{selectedBook.title || selectedBook.filename}》,你想了解什么?

+
+ {[{ icon: BookOpen, title: "读懂这本书", prompt: "请梳理这本书的核心观点与整体结构。" }, { icon: MessageSquare, title: "深入一个观点", prompt: "这本书中有哪些值得深入思考的观点?请结合原文展开。" }, { icon: GitBranch, title: "连接知识", prompt: "请整理这本书中重要概念之间的联系。" }].map(({ icon: Icon, title, prompt }) => ( + + ))} +
+
+ )} + {turns.map((turn) => { + const isLastTurn = turn.user.id === turns[turns.length - 1]?.user.id; + const versions = [...turn.versions]; + const activeIdx = activeVersions[turn.user.id] ?? versions.length - 1; + const shown = versions[activeIdx] ?? null; + const streamingHere = streaming && streamTurnId === turn.user.id && !!streamContent; + return ( +
+ + {versions.length > 1 && ( +
+ + + 版本 {activeIdx + 1} / {versions.length} + + +
+ )} + {shown && ( + + )} + {streamingHere && ( + + )} +
+ ); + })} + {streaming && !streamTurnId && streamContent && ( + + )} +
+
+
+ +
+ {/* 输入胶囊:模型选择 + 输入合并(Perplexity 风) */} +
+
+
+
+ {pickerOpen &&
setPickerOpen(false)} />} + + {pickerOpen && ( +
+

选择回答模型

+ {aiModels.map((m) => ( + + ))} + {aiModels.length === 0 && ( +

尚未配置模型,点「管理」添加

+ )} +
+ )} +
+ +
+ +
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing && handleSend()} + placeholder={selectedBook ? "问这本书的任何问题..." : "先选择一本书"} + disabled={!selectedBook || streaming} + className="flex-1 bg-transparent px-1 py-1.5 text-sm text-[var(--fg)] placeholder-[var(--fg-muted)] focus:outline-none disabled:opacity-50" + /> + +
+
+
+
+
+
+ + ) : ( +
+
我的阅读空间 / 书库 与好书,慢慢相处
+
+
+
A LITTLE READING, A LITTLE MORE WORLD

每一本书,
都值得再读深一点。

收藏你的好书,与 AI 一起追问、思考,
让读过的每一页,都成为自己的收获。

+
+
+

我的书库 {books.length}

在这里,开启下一次思想漫游。

+ {booksLoading ?

正在整理你的书库…

: booksError ?

暂时无法加载书库

请确认阅读服务已启动,然后再试一次。

: ( +
+ {books.filter((b) => `${b.title || b.filename} ${b.author || ""}`.toLowerCase().includes(search.toLowerCase())).map((book, i) => ( +
+ + +
handleDeleteBook(book)}>
+
+ ))} + {!search && } + {search && !books.some((b) => `${b.title || b.filename} ${b.author || ""}`.toLowerCase().includes(search.toLowerCase())) &&

没有找到相关书籍

换个书名或作者试试。

} +
+ )} +
+
阅读的意义,藏在每一次思考里。MADE FOR CURIOUS MINDS
+
+
+ )} +
+ + {readerBook && ( + setReaderBook(null)} + onAsk={async (text) => { + if (streaming) { + alert("AI 正在回答,请稍后再解读这段原文。"); + return; + } + try { + if (selectedBook?.id !== readerBook.id || !activeConv) await startChat(readerBook); + setInput(text); + setReaderBook(null); + } catch { + alert("暂时无法打开对话,请稍后重试。"); + } + }} + /> + )} + setManagerOpen(false)} + models={aiModels} + onChanged={() => api.listModels().then(setAiModels)} + /> +
+ ); +} + +function ChatBubble({ + message, + isStreaming, + onCopy, + onDelete, + onBranch, + onRetry, +}: { + message: Message; + isStreaming?: boolean; + onCopy?: (text: string) => void; + onDelete?: (id: string) => void; + onBranch?: (id: string) => void; + onRetry?: (msg: Message) => void; +}) { + const [copied, setCopied] = useState(false); + const [citationsOpen, setCitationsOpen] = useState(false); + const [readerSegmentId, setReaderSegmentId] = useState(null); + const isUser = message.role === "user"; + const isStream = isStreaming || message.id === "stream"; + + // 同一段落被 AI 重复引用时只显示一条(引用渲染去重,不影响已注入的检索内容) + const uniqCitations = useMemo(() => { + const seen = new Set(); + return (message.citations ?? []).filter((c) => { + if (seen.has(c.segment_id)) return false; + seen.add(c.segment_id); + return true; + }); + }, [message.citations]); + + const handleCopy = () => { + onCopy?.(message.content); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + return ( + <> +
+
+
+ {isUser ? : } +
+ +
+
+ + {uniqCitations.length > 0 && ( +
+ + + {citationsOpen && ( +
+ {uniqCitations.map((c, i) => ( +
+ [{i + 1}] + + {(c.excerpt || "").replace(/《heading_path》\s*/g, "").slice(0, 80)}{(c.excerpt || "").length > 80 ? "…" : ""} + + +
+ ))} +
+ )} +
+ )} +
+ + {!isStream && ( +
+ + {copied ? : } + + {onRetry && ( + onRetry(message)}> + + + )} + {onBranch && ( + onBranch(message.id)}> + + + )} + {onDelete && ( + onDelete(message.id)}> + + + )} +
+ )} +
+
+
+ setReaderSegmentId(null)} + /> + + ); +} + +interface Turn { + user: Message; + versions: Message[]; +} + +/** + * 把平铺的消息流聚合成「回合」列表:每个 user 消息 + 它名下的所有 AI 回答版本。 + * assistant 按 parent_message_id 归属;旧数据 parent 为 null 时,挂到它之前最近的 user。 + */ +function buildTurns(msgs: Message[]): Turn[] { + const turns: Turn[] = []; + const byUser = new Map(); + let lastTurn: Turn | null = null; + for (const m of msgs) { + if (m.role === "user") { + lastTurn = { user: m, versions: [] }; + turns.push(lastTurn); + byUser.set(m.id, lastTurn); + } else if (m.role === "assistant") { + const target = (m.parent_message_id && byUser.get(m.parent_message_id)) || lastTurn; + if (target) target.versions.push(m); + } + } + return turns; +} + +function ActionBtn({ title, onClick, children, danger }: { title: string; onClick: () => void; children: React.ReactNode; danger?: boolean }) { + return ( + + ); +} diff --git a/frontend/src/components/CitationReaderDialog.tsx b/frontend/src/components/CitationReaderDialog.tsx new file mode 100644 index 0000000..81d0f38 --- /dev/null +++ b/frontend/src/components/CitationReaderDialog.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { BookOpen, Loader2, X } from "lucide-react"; +import { api } from "@/lib/api"; + +interface SegmentCtx { + segment: { id: string; text: string; order_idx: number; cfi: string | null }; + chapter: { id: string; title: string | null; level: number } | null; + before: { id: string; text: string; order_idx: number; cfi: string | null }[]; + after: { id: string; text: string; order_idx: number; cfi: string | null }[]; +} + +function cleanText(text: string): string { + // 解析阶段写入的《heading_path》占位不影响阅读,展示前清掉 + return text.replace(/《heading_path》\s*/g, ""); +} + +function Para({ text, muted }: { text: string; muted?: boolean }) { + return ( +

+ {cleanText(text)} +

+ ); +} + +export default function CitationReaderDialog({ + open, + segmentId, + onClose, +}: { + open: boolean; + segmentId: string | null; + onClose: () => void; +}) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open || !segmentId) return; + setLoading(true); + setError(null); + setData(null); + api + .getSegmentContext(segmentId) + .then(setData) + .catch((e) => setError(e instanceof Error ? e.message : "加载失败")) + .finally(() => setLoading(false)); + }, [open, segmentId]); + + if (!open) return null; + + return ( +
+
e.stopPropagation()} + > + {/* 头部 */} +
+ +
+

+ {data?.chapter?.title || "阅读原文"} +

+ {data && ( +

+ 第 {data.segment.order_idx + 1} 段 + {data.segment.cfi ? " · EPUB 内定位可用" : ""} +

+ )} +
+ +
+ + {/* 正文 */} +
+ {loading && ( +
+ 加载原文… +
+ )} + {error &&

{error}

} + {data && ( + <> +
+ {data.before.map((p) => ( + + ))} +
+
+ +
+
+ {data.after.map((p) => ( + + ))} +
+ + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/CodeBlock.tsx b/frontend/src/components/CodeBlock.tsx new file mode 100644 index 0000000..309d041 --- /dev/null +++ b/frontend/src/components/CodeBlock.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** + * Server-side-safe syntax highlighting: shiki is loaded lazily inside an + * effect (so SSR renders the plain fallback), and unknown languages degrade + * to a readable
 instead of throwing.
+ */
+export default function CodeBlock({ code, language }: { code: string; language?: string }) {
+  const [html, setHtml] = useState(null);
+
+  useEffect(() => {
+    let cancelled = false;
+    const lang = language || "text";
+
+    import("shiki/bundle/web")
+      .then(async ({ codeToHtml }) => {
+        const out = await codeToHtml(code, { lang, theme: "github-dark" });
+        if (!cancelled) setHtml(out);
+      })
+      .catch(() => {
+        // Unknown language or loader failure: keep the plain fallback.
+      });
+
+    return () => {
+      cancelled = true;
+    };
+  }, [code, language]);
+
+  if (html) {
+    return (
+      
+ ); + } + + return ( +
+      {code}
+    
+ ); +} diff --git a/frontend/src/components/EpubReader.tsx b/frontend/src/components/EpubReader.tsx new file mode 100644 index 0000000..3b5669b --- /dev/null +++ b/frontend/src/components/EpubReader.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { Book as EpubBook, Rendition, Location, NavItem, Contents } from "epubjs"; +import type Section from "epubjs/types/section"; +import { ArrowLeft, BookOpen, ChevronLeft, ChevronRight, List, Loader2, Minus, Plus, RotateCcw, MessageSquare } from "lucide-react"; +import { api, type Book } from "@/lib/api"; + +interface TocEntry { href: string; label: string; depth: number } +function flattenToc(items: NavItem[], depth = 0): TocEntry[] { + return items.flatMap((item) => [ + { href: item.href, label: item.label.trim(), depth }, + ...flattenToc(item.subitems || [], depth + 1), + ]); +} +function readSaved(key: string) { + try { return localStorage.getItem(key); } catch { return null; } +} +function save(key: string, value: string) { + try { localStorage.setItem(key, value); } catch { /* Reading works without storage. */ } +} + +export default function EpubReader({ book, onClose, onAsk }: { + book: Book; + onClose: () => void; + onAsk: (text: string) => void; +}) { + const dialogRef = useRef(null); + const viewerRef = useRef(null); + const renditionRef = useRef(null); + const closeRef = useRef(onClose); + const [toc, setToc] = useState([]); + const [tocOpen, setTocOpen] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [attempt, setAttempt] = useState(0); + const [fontSize, setFontSize] = useState(18); + const [location, setLocation] = useState(null); + const [selectedText, setSelectedText] = useState(""); + const [turning, setTurning] = useState(false); + const positionKey = `bookmate.reader.${book.id}`; + + useEffect(() => { closeRef.current = onClose; }, [onClose]); + + useEffect(() => { + const previousFocus = document.activeElement as HTMLElement | null; + dialogRef.current?.querySelector("button")?.focus(); + const keydown = (event: KeyboardEvent) => { + if (event.key === "Escape") { event.preventDefault(); closeRef.current(); } + if (event.key !== "Tab") return; + const controls = dialogRef.current?.querySelectorAll('button:not(:disabled), [href], input, iframe, [tabindex="0"]'); + if (!controls?.length) return; + const first = controls[0], last = controls[controls.length - 1]; + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } + }; + document.addEventListener("keydown", keydown); + return () => { document.removeEventListener("keydown", keydown); previousFocus?.focus(); }; + }, []); + + useEffect(() => { + let disposed = false; + let epub: EpubBook | undefined; + let observer: ResizeObserver | undefined; + const controller = new AbortController(); + const font = Number(readSaved("bookmate.reader.fontSize")) || 18; + const initialFont = Math.max(14, Math.min(28, font)); + const open = async () => { + try { + const [{ Book: Epub }, data] = await Promise.all([ + import("epubjs"), api.getBookFile(book.id, controller.signal), + ]); + if (disposed || !viewerRef.current) return; + epub = new Epub({ replacements: "blobUrl" }); + // Register handlers for the promises epub.js rejects alongside open(). + void epub.opened.catch(() => {}); + void epub.ready.catch(() => {}); + await epub.open(data, "binary"); + if (disposed) return; + const navigation = await epub.loaded.navigation; + if (disposed) return; + const entries = flattenToc(navigation.toc); + if (!entries.length) { + epub.spine.each((section: Section) => { + if (section.linear) entries.push({ href: section.href, label: `章节 ${entries.length + 1}`, depth: 0 }); + }); + } + setToc(entries); + setFontSize(initialFont); + const host = viewerRef.current!; + const rendition = epub.renderTo(host, { + width: host.clientWidth, height: host.clientHeight, + spread: "none", flow: "paginated", allowScriptedContent: false, + }); + renditionRef.current = rendition; + rendition.themes.default({ + body: { color: "#243b33", background: "#fffdf8", "line-height": "1.9 !important", "font-family": 'Georgia, "Songti SC", "SimSun", serif' }, + "img, svg": { "max-width": "100% !important", "max-height": "90vh !important" }, + a: { color: "#31594a" }, + }); + rendition.themes.fontSize(`${initialFont}px`); + rendition.on("relocated", (next: Location) => { + if (disposed) return; + setLocation(next); + setSelectedText(""); + if (next.start.cfi) save(positionKey, next.start.cfi); + }); + rendition.on("selected", (cfi: string, contents: Contents) => { + if (disposed) return; + try { setSelectedText(contents.range(cfi).toString().trim().slice(0, 4000)); } catch { /* No selection. */ } + }); + rendition.on("keyup", (event: KeyboardEvent) => { + if (event.key === "Escape") closeRef.current(); + }); + rendition.on("displayError", () => { + if (!disposed) setError("这一页未能显示,请重试或从目录选择其他章节。"); + }); + const saved = readSaved(positionKey); + try { await rendition.display(saved || undefined); } + catch { + if (!saved) throw new Error("无法显示书籍正文"); + await rendition.display(); + } + if (disposed) return; + setError(""); + setLoading(false); + observer = new ResizeObserver(([entry]) => { + if (!disposed && entry.contentRect.width > 0 && entry.contentRect.height > 0) { + rendition.resize(entry.contentRect.width, entry.contentRect.height); + } + }); + observer.observe(host); + } catch (err) { + if (!disposed) { setError(err instanceof Error ? err.message : "书籍打开失败"); setLoading(false); } + } + }; + void open(); + return () => { + disposed = true; + controller.abort(); + observer?.disconnect(); + renditionRef.current = null; + epub?.destroy(); + }; + }, [book.id, attempt, positionKey]); + + const navigate = async (target: "prev" | "next" | string) => { + const rendition = renditionRef.current; + if (!rendition || turning || loading) return; + setTurning(true); + setError(""); + try { + if (target === "prev") await rendition.prev(); + else if (target === "next") await rendition.next(); + else { await rendition.display(target); setTocOpen(false); } + } catch { setError("这一页未能显示,请重试或从目录选择其他章节。"); } + finally { setTurning(false); } + }; + const changeFont = (delta: number) => { + const next = Math.max(14, Math.min(28, fontSize + delta)); + setFontSize(next); + save("bookmate.reader.fontSize", String(next)); + renditionRef.current?.themes.fontSize(`${next}px`); + }; + const currentHref = location?.start.href.split("#")[0]; + const currentChapter = toc.find((entry) => entry.href === location?.start.href) + || toc.find((entry) => entry.href.split("#")[0] === currentHref); + + return ( +
+
+ +
{book.title || book.filename}{book.author || "完整 EPUB 阅读"}
+ +
{fontSize}
+
+
+ {tocOpen && } +
+ {loading &&

正在翻开这本书…

} + {error &&

{error}

} +
+
+
+ {selectedText &&
已选中 {selectedText.length} 字
} +
+ +
{currentChapter?.label || "阅读原文"}{location ? `本节 ${location.start.displayed.page} / ${location.start.displayed.total} 页` : "保留原书正文、图片与目录"} · 自动记住阅读位置
+ +
+
+ ); +} diff --git a/frontend/src/components/MarkdownRenderer.tsx b/frontend/src/components/MarkdownRenderer.tsx new file mode 100644 index 0000000..5faaec8 --- /dev/null +++ b/frontend/src/components/MarkdownRenderer.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { Children, type ReactElement, type ReactNode } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; +import rehypeKatex from "rehype-katex"; +import "katex/dist/katex.min.css"; + +import CodeBlock from "./CodeBlock"; +import MermaidDiagram from "./MermaidDiagram"; +import MindMap, { isMermaidMindMapSource } from "./MindMap"; + +interface Props { + content: string; + variant?: "light" | "dark"; +} + +/** Extract raw text out of react-markdown's children for a code block. */ +function codeText(children: React.ReactNode): string { + const raw = Array.isArray(children) ? children.join("") : String(children ?? ""); + return raw.replace(/\n$/, ""); +} + +export default function MarkdownRenderer({ content, variant = "light" }: Props) { + const dark = variant === "dark"; + + return ( + ( +

{children}

+ ), + h2: ({ children }) => ( +

{children}

+ ), + h3: ({ children }) => ( +

{children}

+ ), + p: ({ children }) =>

{children}

, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + input: ({ checked, disabled }) => ( + + ), + blockquote: ({ children }) => ( +
    + {children} +
    + ), + code: ({ className, children }) => { + // 带语言标注的必然出现在
     里,块级渲染由 pre 统一处理;
    +          // 这里只负责真正的行内代码。注意要保留 className,供 pre 读取语言。
    +          if (className) {
    +            return {children};
    +          }
    +          return (
    +            
    +              {children}
    +            
    +          );
    +        },
    +        pre: ({ children }) => {
    +          // 所有代码块都会先经过 
    ,所以块级判断放在这里最可靠:
    +          // 提取语言和源码,交给 CodeBlock(高亮)或 MermaidDiagram(图表)。
    +          const child = Children.toArray(children)[0] as ReactElement<{
    +            className?: string;
    +            children?: ReactNode;
    +          }>;
    +          const childProps = child?.props ?? {};
    +          const lang = /language-([\w-]+)/.exec(String(childProps.className ?? ""))?.[1];
    +          const text = codeText(childProps.children);
    +          if (lang === "mermaid") {
    +            // mermaid 的 mindmap 解析器对中文 / 
    / 数字容易报 NODE_DEND 错误, + // 所以 mindmap 块不交给 mermaid,直接用 MindMap 组件渲染成可交互思维导图。 + if (isMermaidMindMapSource(text)) return ; + return ; + } + return ; + }, + table: ({ children }) => ( +
    + + {children} +
    +
    + ), + thead: ({ children }) => ( + {children} + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + {children} + ), + hr: () =>
    , + a: ({ href, children }) => ( + + {children} + + ), + strong: ({ children }) => ( + {children} + ), + em: ({ children }) => {children}, + }} + > + {content} + + ); +} diff --git a/frontend/src/components/MermaidDiagram.tsx b/frontend/src/components/MermaidDiagram.tsx new file mode 100644 index 0000000..5727e7f --- /dev/null +++ b/frontend/src/components/MermaidDiagram.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +let idCounter = 0; + +/** + * Renders a ```mermaid code block into an actual diagram. mermaid is loaded + * lazily in an effect (SSR-safe); on parse failure it falls back to showing + * the raw source so the content is never lost. + */ +export default function MermaidDiagram({ code }: { code: string }) { + const [svg, setSvg] = useState(null); + const [error, setError] = useState(false); + const idRef = useRef(`mmd-${++idCounter}-${Date.now().toString(36)}`); + + useEffect(() => { + let cancelled = false; + + import("mermaid") + .then(async (mod) => { + if (cancelled) return; + const mermaid = mod.default; + mermaid.initialize({ startOnLoad: false }); + const { svg: out } = await mermaid.render(idRef.current, code); + if (!cancelled) setSvg(out); + }) + .catch(() => { + if (!cancelled) setError(true); + }); + + return () => { + cancelled = true; + }; + }, [code]); + + if (svg) { + return ( +
    + ); + } + + if (error) { + return ( +
    +        {code}
    +      
    + ); + } + + return
    图表渲染中…
    ; +} diff --git a/frontend/src/components/MindMap.tsx b/frontend/src/components/MindMap.tsx new file mode 100644 index 0000000..3426cde --- /dev/null +++ b/frontend/src/components/MindMap.tsx @@ -0,0 +1,494 @@ +"use client"; + +import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import { Brain, Check, Copy, Download, Image as ImageIcon, Loader2, Maximize2, Minus, Plus } from "lucide-react"; +import CodeBlock from "./CodeBlock"; + +interface MindNode { + text: string; + children: MindNode[]; + level: number; +} + +interface LineItem { + indent: number; + text: string; +} + +/** 布局常量:节点尺寸按中文字符估算,保证中文能自然换行。 */ +const CHAR_W = 15; // 平均每字宽度(text-sm 中文约 14px) +const NODE_MIN_W = 64; +const NODE_MAX_W = 200; +const LINE_H = 22; +const PAD_X = 12; +const PAD_Y = 9; +const H_GAP = 56; // 父子两列之间的水平间距 +const V_GAP = 14; // 兄弟节点之间的垂直间距 +const PAD = 24; // 画布内边距 +const VIEW_H = 320; // 渲染视图可视高度 + +const DEPTH_STYLES = [ + "bg-indigo-600 text-white border-indigo-700 shadow-md font-semibold", // 根节点 + "bg-indigo-950/80 text-indigo-200 border-indigo-800 shadow-sm", + "bg-blue-950/80 text-blue-200 border-blue-800 shadow-sm", + "bg-emerald-950/80 text-emerald-200 border-emerald-800 shadow-sm", + "bg-amber-950/80 text-amber-200 border-amber-800 shadow-sm", + "bg-purple-950/80 text-purple-200 border-purple-800 shadow-sm", +]; + +/** Shared indent-stack builder: nests by comparing indents directly. */ +function buildTree(items: LineItem[]): MindNode | null { + if (items.length === 0) return null; + const root: MindNode = { text: items[0].text, children: [], level: 0 }; + const stack: { node: MindNode; indent: number }[] = [{ node: root, indent: items[0].indent }]; + + for (let i = 1; i < items.length; i++) { + const { indent, text } = items[i]; + const node: MindNode = { text, children: [], level: 0 }; + while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop(); + const parent = stack.length > 0 ? stack[stack.length - 1].node : root; + node.level = parent.level + 1; + parent.children.push(node); + stack.push({ node, indent }); + } + return root; +} + +function parseMarkdownList(content: string): MindNode | null { + const items: LineItem[] = []; + let rootFromHeading: string | null = null; + + for (const raw of content.split("\n")) { + const line = raw; + const list = line.match(/^([ \t]*)[-*+]\s+(.+)/); + if (list) { + items.push({ indent: list[1].replace(/\t/g, " ").length, text: list[2].trim() }); + } else if (!rootFromHeading) { + const h = line.match(/^#+\s*(.+)/); + if (h) rootFromHeading = h[1].trim(); + } + } + + if (items.length === 0) return null; + const tree = buildTree(items); + // 如果列表前有标题,用它作为根节点(原文顶层的并列内容都挂到标题下)。 + if (tree && rootFromHeading) { + return { text: rootFromHeading, children: [tree], level: 0 }; + } + return tree; +} + +/** Strip mermaid mindmap shape wrappers like ((..)) [..] {..} and icons. */ +function cleanMermaidNode(line: string): string { + const mirror: Record = { "(": ")", "[": "]", "{": "}", "<": ">" }; + let text = line + .replace(/^root\b\s*/, "") // mermaid 要求根节点带 root 关键字 + .replace(/::icon\([^)]*\)/g, "") + .replace(//gi, " ") + .trim(); + + // 剥掉包围形状的括号:((..)) / [[..]] / [..] / (..) / {..} / ([..]) … + // 从两端按「相同开括号连续出现的次数」剥掉等量的闭括号,天然支持嵌套成对形状。 + let guard = 0; + while (guard++ < 5) { + const first = text[0]; + const expected = mirror[first]; + if (!expected) break; + let openRun = 0; + while (openRun < text.length && text[openRun] === first) openRun++; + if (openRun > 0 && openRun * 2 < text.length && text.endsWith(expected.repeat(openRun))) { + text = text.slice(openRun, text.length - openRun).trim(); + } else { + break; + } + } + return text.trim(); +} + +function parseMermaidMindMap(src: string): MindNode | null { + const items: LineItem[] = []; + let headerSkipped = false; + + for (const raw of src.split("\n")) { + if (!raw.trim()) continue; + if (!headerSkipped) { + // 第一行是 mindmap 声明,跳过;若已是 root((…)) 则不跳。 + headerSkipped = true; + if (/^\s*mindmap\s*$/i.test(raw)) continue; + } + const indent = (raw.match(/^(\s*)/)?.[1] ?? "").replace(/\t/g, " ").length; + const text = cleanMermaidNode(raw.trim()); + if (!text) continue; + items.push({ indent, text }); + } + + return buildTree(items); +} + +/** True when the text looks like a mermaid mindmap source (header line = mindmap). */ +export function isMermaidMindMapSource(text: string): boolean { + const first = text.split("\n").find((l) => l.trim()) ?? ""; + return /^\s*mindmap\s*$/i.test(first); +} + +/** 主解析入口:优先按 mermaid mindmap,其次按 Markdown 列表。 */ +function parseMindMap(code: string): MindNode | null { + if (isMermaidMindMapSource(code)) return parseMermaidMindMap(code); + return parseMarkdownList(code); +} + +interface LayoutNode { + node: MindNode; + x: number; + y: number; + w: number; + h: number; + parent: LayoutNode | null; + children: LayoutNode[]; +} + +interface Edge { + x1: number; + y1: number; + x2: number; + y2: number; +} + +function nodeMetrics(text: string): { w: number; h: number } { + const w = Math.min(Math.max(text.length * CHAR_W + PAD_X * 2, NODE_MIN_W), NODE_MAX_W); + const perLine = Math.max(1, Math.floor((w - PAD_X * 2) / CHAR_W)); + const lines = Math.max(1, Math.ceil(text.length / perLine)); + return { w, h: lines * LINE_H + PAD_Y * 2 }; +} + +/** 递归布局:同深度的节点共用一列,保证任意兄弟子树永不重叠。 */ +function layoutRoot(root: MindNode): { nodes: LayoutNode[]; edges: Edge[]; width: number; height: number } { + const size = new Map(); + const maxW: number[] = []; + + (function measure(n: MindNode, d: number) { + const m = nodeMetrics(n.text); + maxW[d] = Math.max(maxW[d] ?? 0, m.w); + let sh = 0; + for (const c of n.children) { + measure(c, d + 1); + sh += size.get(c)!.subtreeH; + } + if (n.children.length > 1) sh += V_GAP * (n.children.length - 1); + size.set(n, { ...m, subtreeH: Math.max(m.h, sh) }); + })(root, 0); + + const colX: number[] = []; + for (let d = 0; d < maxW.length; d++) colX[d] = (colX[d - 1] ?? 0) + maxW[d] + H_GAP; + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + const all: LayoutNode[] = []; + + function place(n: MindNode, d: number, blockCenterY: number, parent: LayoutNode | null): void { + const m = size.get(n)!; + const x = colX[d]; + const y = blockCenterY - m.h / 2; + const ln: LayoutNode = { node: n, x, y, w: m.w, h: m.h, parent, children: [] }; + if (parent) parent.children.push(ln); + all.push(ln); + + let childrenH = 0; + for (const c of n.children) childrenH += size.get(c)!.subtreeH; + if (n.children.length > 1) childrenH += V_GAP * (n.children.length - 1); + let top = blockCenterY - childrenH / 2; + for (const c of n.children) { + const ch = size.get(c)!.subtreeH; + place(c, d + 1, top + ch / 2, ln); + top += ch + V_GAP; + } + + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x + m.w); + maxY = Math.max(maxY, y + m.h); + } + place(root, 0, 0, null); + + const edges: Edge[] = []; + for (const ln of all) { + if (!ln.parent) continue; + edges.push({ + x1: ln.parent.x + ln.parent.w, + y1: ln.parent.y + ln.parent.h / 2, + x2: ln.x, + y2: ln.y + ln.h / 2, + }); + } + + // 平移到带内边距的坐标系 + const dx = PAD - minX; + const dy = PAD - minY; + for (const ln of all) { + ln.x += dx; + ln.y += dy; + } + for (const e of edges) { + e.x1 += dx; + e.y1 += dy; + e.x2 += dx; + e.y2 += dy; + } + + return { + nodes: all, + edges, + width: maxX - minX + PAD * 2, + height: maxY - minY + PAD * 2, + }; +} + +export default function MindMap({ code }: { code: string }) { + const [view, setView] = useState<"render" | "code">("render"); + const [copied, setCopied] = useState(false); + const [exporting, setExporting] = useState(false); + const [scale, setScale] = useState(1); + const [pan, setPan] = useState({ x: 0, y: 0 }); + + const wrapRef = useRef(null); // 可视窗口(overflow hidden) + const stageRef = useRef(null); // 被平移/缩放的画布 + const dragRef = useRef<{ startX: number; startY: number; panX: number; panY: number } | null>(null); + const stateRef = useRef({ scale, pan }); + stateRef.current = { scale, pan }; + + const root = useMemo(() => parseMindMap(code), [code]); + const layout = useMemo(() => (root ? layoutRoot(root) : null), [root]); + + // 挂载时把整张图放进可视窗口 + useLayoutEffect(() => { + const el = wrapRef.current; + if (!el || !layout) return; + const vw = el.clientWidth; + const vh = el.clientHeight; + const s = Math.min((vw - 16) / layout.width, (vh - 16) / layout.height, 1); + setScale(Math.max(s, 0.05)); + setPan({ x: 0, y: 0 }); + }, [layout]); + + // 滚轮缩放(原生非 passive 监听,避免页面跟着滚动) + useLayoutEffect(() => { + const el = wrapRef.current; + if (!el) return; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + const rect = el.getBoundingClientRect(); + const cx = e.clientX - rect.left; + const cy = e.clientY - rect.top; + const { scale: s, pan: p } = stateRef.current; + const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15; + const next = Math.min(3, Math.max(0.2, s * factor)); + const wx = (cx - p.x) / s; + const wy = (cy - p.y) / s; + setScale(next); + setPan({ x: cx - wx * next, y: cy - wy * next }); + }; + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel); + }, []); + + const onPointerDown = (e: React.PointerEvent) => { + (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); + dragRef.current = { startX: e.clientX, startY: e.clientY, panX: pan.x, panY: pan.y }; + }; + const onPointerMove = (e: React.PointerEvent) => { + const d = dragRef.current; + if (!d) return; + setPan({ x: d.panX + (e.clientX - d.startX), y: d.panY + (e.clientY - d.startY) }); + }; + const onPointerUp = () => { + dragRef.current = null; + }; + + const zoomBy = (f: number) => setScale((s) => Math.min(3, Math.max(0.2, s * f))); + + const fit = () => { + const el = wrapRef.current; + if (!el || !layout) return; + const vw = el.clientWidth; + const vh = el.clientHeight; + const s = Math.min((vw - 16) / layout.width, (vh - 16) / layout.height, 1); + setScale(Math.max(s, 0.05)); + setPan({ x: 0, y: 0 }); + }; + + const copySource = async () => { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + alert("复制失败"); + } + }; + + const exportMd = () => { + const content = "```mermaid\n" + code.replace(/\n+$/, "") + "\n```\n"; + const blob = new Blob([content], { type: "text/markdown;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "思维导图.md"; + a.click(); + URL.revokeObjectURL(url); + }; + + const exportImage = async () => { + const outer = wrapRef.current; + const inner = stageRef.current; + if (!outer || !inner || !layout || exporting) return; + const prevOuter = { width: outer.style.width, height: outer.style.height, overflow: outer.style.overflow }; + const prevTransform = inner.style.transform; + outer.style.width = `${layout.width}px`; + outer.style.height = `${layout.height}px`; + outer.style.overflow = "visible"; + inner.style.transform = "translate(0px, 0px) scale(1)"; + setExporting(true); + try { + const { toPng } = await import("html-to-image"); + const dataUrl = await toPng(outer, { pixelRatio: 2, backgroundColor: "#10151f", cacheBust: true }); + const a = document.createElement("a"); + a.href = dataUrl; + a.download = "思维导图.png"; + a.click(); + } catch { + alert("导出图片失败"); + } finally { + outer.style.width = prevOuter.width; + outer.style.height = prevOuter.height; + outer.style.overflow = prevOuter.overflow; + inner.style.transform = prevTransform; + setExporting(false); + } + }; + + if (!root || !layout) return null; + + return ( +
    + {/* 工具栏:无边框控制台风,视图切换 pill + 纯图标按钮 */} +
    +
    + + +
    +
    + + + {view === "render" && ( + + )} + {view === "render" && ( +
    + + {Math.round(scale * 100)}% + + +
    + )} +
    + + {/* 视图内容 */} + {view === "render" ? ( +
    +
    + + {layout.edges.map((e, i) => { + const midX = e.x1 + (e.x2 - e.x1) * 0.5; + return ( + + ); + })} + + {layout.nodes.map((ln, i) => { + const depthStyle = DEPTH_STYLES[Math.min(ln.node.level, DEPTH_STYLES.length - 1)]; + return ( +
    + {ln.node.text} +
    + ); + })} +
    + + 滚轮缩放 · 拖动平移 + +
    + ) : ( + + )} +
    + ); +} diff --git a/frontend/src/components/ModelManagerDialog.tsx b/frontend/src/components/ModelManagerDialog.tsx new file mode 100644 index 0000000..d451095 --- /dev/null +++ b/frontend/src/components/ModelManagerDialog.tsx @@ -0,0 +1,469 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + ArrowLeft, Check, FlaskConical, KeyRound, Loader2, Pencil, Plus, + Search, Settings2, Trash2, Upload, X, +} from "lucide-react"; +import { api, AiModelEntry } from "@/lib/api"; + +interface Props { + open: boolean; + onClose: () => void; + models: AiModelEntry[]; + onChanged: () => void; +} + +interface Preset { + key: string; + label: string; + kind: "openai_compatible" | "anthropic"; + base_url: string; + model_id: string; + hint: string; +} + +const PRESETS: Preset[] = [ + { key: "openrouter", label: "OpenRouter", kind: "openai_compatible", base_url: "https://openrouter.ai/api/v1", model_id: "", hint: "一个 key 用全部模型,如 deepseek/deepseek-r1" }, + { key: "deepseek", label: "DeepSeek", kind: "openai_compatible", base_url: "https://api.deepseek.com/v1", model_id: "deepseek-chat", hint: "deepseek-chat(V3) / deepseek-reasoner(R1)" }, + { key: "sensenova", label: "SenseNova", kind: "openai_compatible", base_url: "https://token.sensenova.cn/v1", model_id: "", hint: "商汤日日新" }, + { key: "siliconflow", label: "硅基流动", kind: "openai_compatible", base_url: "https://api.siliconflow.cn/v1", model_id: "", hint: "国产模型市场,低价" }, + { key: "claude", label: "Claude", kind: "anthropic", base_url: "", model_id: "claude-sonnet-4-20250514", hint: "需 Anthropic API Key" }, +]; + +// 批量导入模式的平台快捷选择 +const IMPORT_PROVIDERS: { label: string; base_url: string }[] = [ + { label: "OpenRouter", base_url: "https://openrouter.ai/api/v1" }, + { label: "硅基流动", base_url: "https://api.siliconflow.cn/v1" }, + { label: "SenseNova", base_url: "https://token.sensenova.cn/v1" }, + { label: "自定义", base_url: "" }, +]; + +const inputCls = + "w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--bg-raised)] text-sm text-[var(--fg)] placeholder-[var(--fg-muted)] focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"; + +export default function ModelManagerDialog({ open, onClose, models, onChanged }: Props) { + const [mode, setMode] = useState<"list" | "form" | "import">("list"); + + // 表单 + const [form, setForm] = useState({ name: "", label: "", kind: "openai_compatible", base_url: "", api_key: "", model_id: "" }); + const [editingName, setEditingName] = useState(null); + const [saving, setSaving] = useState(false); + const [saveMsg, setSaveMsg] = useState(null); + + // 测试 + const [testingName, setTestingName] = useState(null); + const [testResult, setTestResult] = useState>({}); + + // 批量导入(任意 OpenAI 兼容平台) + const [importBase, setImportBase] = useState("https://openrouter.ai/api/v1"); + const [importKey, setImportKey] = useState(""); + const [fetching, setFetching] = useState(false); + const [providerModels, setProviderModels] = useState<{ id: string; name: string }[]>([]); + const [importSearch, setImportSearch] = useState(""); + const [selected, setSelected] = useState>(new Set()); + const [importMsg, setImportMsg] = useState(null); + + const filtered = useMemo(() => { + const q = importSearch.trim().toLowerCase(); + if (!q) return providerModels; + return providerModels.filter((m) => m.id.toLowerCase().includes(q) || (m.name || "").toLowerCase().includes(q)); + }, [providerModels, importSearch]); + + if (!open) return null; + + const applyPreset = (p: Preset) => { + setForm({ + name: "", label: p.label, kind: p.kind, + base_url: p.base_url, api_key: "", model_id: p.model_id, + }); + setEditingName(null); + setMode("form"); + }; + + const startAdd = () => { + setForm({ name: "", label: "", kind: "openai_compatible", base_url: "", api_key: "", model_id: "" }); + setEditingName(null); + setSaveMsg(null); + setMode("form"); + }; + + const startEdit = (m: AiModelEntry) => { + setForm({ name: m.name, label: m.label, kind: m.kind, base_url: m.base_url, api_key: "", model_id: m.model_id }); + setEditingName(m.name); + setSaveMsg(null); + setMode("form"); + }; + + const save = async () => { + if (!form.model_id.trim() || !form.label.trim()) { + setSaveMsg("请填写模型名称(显示名)和模型 ID"); + return; + } + if (!form.kind.startsWith("anthropic") && !form.base_url.trim()) { + setSaveMsg("OpenAI 兼容模型需要 base_url(可点上方预设自动填入)"); + return; + } + setSaving(true); + setSaveMsg(null); + try { + if (editingName) { + await api.updateModel(editingName, { + label: form.label, kind: form.kind, base_url: form.base_url, model_id: form.model_id, + ...(form.api_key.trim() ? { api_key: form.api_key } : {}), + }); + } else { + await api.addModel({ + label: form.label, kind: form.kind, + base_url: form.base_url, api_key: form.api_key, model_id: form.model_id, + }); + } + onChanged(); + setMode("list"); + setSaveMsg(null); + } catch (e) { + setSaveMsg(e instanceof Error ? e.message : "保存失败"); + } + setSaving(false); + }; + + const remove = async (m: AiModelEntry) => { + if (!confirm(`删除模型「${m.label}」?`)) return; + try { + await api.deleteModel(m.name); + onChanged(); + } catch { + alert("删除失败"); + } + }; + + const test = async (m: AiModelEntry) => { + setTestingName(m.name); + setTestResult((prev) => ({ ...prev, [m.name]: prev[m.name] })); + try { + const r = await api.testModel(m.name); + setTestResult((prev) => ({ + ...prev, + [m.name]: { ok: r.ok, text: r.ok ? `✅ 连通正常 · ${r.reply?.slice(0, 40) ?? ""}` : `❌ ${r.error ?? "失败"}` }, + })); + } catch (e) { + setTestResult((prev) => ({ ...prev, [m.name]: { ok: false, text: `❌ ${e instanceof Error ? e.message : "请求失败"}` } })); + } + setTestingName(null); + }; + + const fetchModels = async () => { + if (!importBase.trim() || !importKey.trim()) return; + setFetching(true); + setImportMsg(null); + try { + const r = await api.importModels(importBase.trim(), importKey.trim()); + setProviderModels(r.models); + setSelected(new Set()); + } catch (e) { + setImportMsg(`❌ ${e instanceof Error ? e.message : "拉取失败"}`); + } + setFetching(false); + }; + + const toggleSelect = (id: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const importSelected = async () => { + const ids = [...selected]; + if (!ids.length) return; + setSaving(true); + setImportMsg(null); + let ok = 0; + for (const id of ids) { + try { + await api.addModel({ + label: id, kind: "openai_compatible", + base_url: importBase, api_key: importKey, model_id: id, + }); + ok += 1; + } catch { + // 单个失败跳过(重名等) + } + } + setImportMsg(`已导入 ${ok}/${ids.length} 个模型`); + onChanged(); + setSaving(false); + }; + + return ( +
    +
    e.stopPropagation()} + > + {/* 头部 */} +
    + {mode !== "list" && ( + + )} + +

    + {mode === "form" ? (editingName ? "编辑模型" : "添加模型") : mode === "import" ? "从 OpenRouter 导入" : "管理模型"} +

    + +
    + + {/* 列表模式 */} + {mode === "list" && ( +
    +
    + + +
    + + {models.length === 0 && ( +

    还没有可用模型,点上方「添加模型」配置一个

    + )} + {models.map((m) => ( +
    +
    + {m.kind === "anthropic" ? : } +
    +
    +
    + {m.label} + {m.builtin && ( + 内置 + )} +
    +
    + {m.model_id} + {m.base_url ? ` · ${m.base_url.replace(/^https?:\/\//, "").split("/")[0]}` : ""} + {m.api_key_masked ? ` · ${m.api_key_masked}` : ""} +
    + {testResult[m.name] && ( +
    + {testResult[m.name].text} +
    + )} +
    +
    + + {!m.builtin && ( + <> + + + + )} +
    +
    + ))} +
    + )} + + {/* 表单模式 */} + {mode === "form" && ( +
    +
    +

    快捷预设(点一下自动填,只需补 API Key)

    +
    + {PRESETS.map((p) => ( + + ))} +
    +
    + {PRESETS.find((p) => p.label === form.label)?.hint ?? "预设只是帮填字段;你也可以完全手动填写任意 OpenAI 兼容 API"} +
    +
    + +
    + + setForm({ ...form, label: e.target.value })} placeholder="如 DeepSeek V3 / Qwen2.5-72B" /> +
    +
    + + +
    +
    + + setForm({ ...form, base_url: e.target.value })} placeholder="https://openrouter.ai/api/v1" /> +
    +
    + + setForm({ ...form, api_key: e.target.value })} placeholder={editingName ? "留空保持不变" : "sk-..."} autoComplete="off" /> +
    +
    + + setForm({ ...form, model_id: e.target.value })} placeholder="deepseek-chat / deepseek/deepseek-r1 / qwen2.5-72b-instruct" /> +
    + + {saveMsg &&
    {saveMsg}
    } + +
    + + +
    +
    + )} + + {/* 批量导入模式(任意 OpenAI 兼容平台) */} + {mode === "import" && ( +
    +
    + +
    + {IMPORT_PROVIDERS.map((p) => ( + + ))} +
    + + setImportBase(e.target.value)} + placeholder="https://openrouter.ai/api/v1" + /> + +
    + setImportKey(e.target.value)} + placeholder="sk-..." + autoComplete="off" + /> + +
    + {importMsg &&
    {importMsg}
    } +
    + + {providerModels.length > 0 && ( + <> +
    + + setImportSearch(e.target.value)} + placeholder={`搜索 ${providerModels.length} 个模型,如 deepseek / qwen / claude`} + /> +
    +
    + {filtered.map((m) => ( + + ))} + {filtered.length === 0 &&

    没有匹配的模型

    } +
    + + + )} +
    + )} +
    +
    + ); +} \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..4350b39 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,183 @@ +export interface Book { + id: string; + filename: string; + title: string | null; + author: string | null; + language: string; + char_count: number; + status: string; + created_at: string; +} + +export interface BookDetail extends Book { + chapter_count: number; + segment_count: number; +} + +export interface Chapter { + id: string; + book_id: string; + order_idx: number; + title: string | null; + level: number; + heading_path: string | null; + cfi_start: string | null; + cfi_end: string | null; + char_count: number; +} + +export interface Segment { + id: string; + chapter_id: string; + book_id: string; + order_idx: number; + text: string; + char_count: number; + cfi: string | null; +} + +export interface Citation { + segment_id: string; + cfi: string | null; + excerpt: string; +} + +export interface Conversation { + id: string; + book_id: string; + title: string | null; + model: string; + created_at: string; +} + +export interface AiModelEntry { + name: string; + label: string; + kind: "openai_compatible" | "anthropic"; + base_url: string; + api_key_masked: string; + model_id: string; + builtin: boolean; +} + +export interface Message { + id: string; + conversation_id: string; + parent_message_id: string | null; + role: "user" | "assistant" | "system"; + content: string; + citations: Citation[] | null; + model: string | null; + created_at: string; +} + +const API_BASE = "http://localhost:8000/api"; + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(`${API_BASE}${path}`, { + headers: { "Content-Type": "application/json" }, + ...options, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(err.detail || "Request failed"); + } + return res.json(); +} + +export const api = { + // Books + listBooks: () => request("/books"), + getBook: (id: string) => request(`/books/${id}`), + getBookFile: async (id: string, signal?: AbortSignal) => { + const response = await fetch(`${API_BASE}/books/${encodeURIComponent(id)}/file`, { signal }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: "无法打开 EPUB 文件" })); + throw new Error(error.detail || "无法打开 EPUB 文件"); + } + return response.arrayBuffer(); + }, + getChapters: (bookId: string) => request(`/books/${bookId}/chapters`), + getSegments: (bookId: string, chapterId?: string) => + request(`/books/${bookId}/segments${chapterId ? `?chapter_id=${chapterId}` : ""}`), + uploadBook: async (file: File) => { + const form = new FormData(); + form.append("file", file); + const res = await fetch(`${API_BASE}/books`, { method: "POST", body: form }); + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: "Upload failed" })); + throw new Error(err.detail || "Upload failed"); + } + return res.json() as Promise; + }, + deleteBook: (id: string) => request<{ deleted: string }>(`/books/${id}`, { method: "DELETE" }), + + // Conversations + listConversations: (bookId?: string) => + request(`/chat/conversations${bookId ? `?book_id=${bookId}` : ""}`), + createConversation: (bookId: string, model: string = "claude") => + request("/chat/conversations", { + method: "POST", + body: JSON.stringify({ book_id: bookId, model }), + }), + deleteConversation: (convId: string) => + request<{ deleted: string }>(`/chat/conversations/${convId}`, { method: "DELETE" }), + getMessages: (convId: string) => request(`/chat/conversations/${convId}/messages`), + + // Chat (SSE) + sendMessage: (convId: string, content: string, model?: string, replyTo?: string) => { + return fetch(`${API_BASE}/chat/conversations/${convId}/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content, model, ...(replyTo ? { reply_to: replyTo } : {}) }), + }); + }, + + // Search + search: (bookId: string, q: string) => + request(`/chat/search?book_id=${bookId}&q=${encodeURIComponent(q)}`), + + // 引用段落的阅读上下文(「阅读原文」弹层) + getSegmentContext: (segmentId: string) => + request<{ + segment: { id: string; text: string; order_idx: number; cfi: string | null }; + chapter: { id: string; title: string | null; level: number } | null; + before: { id: string; text: string; order_idx: number; cfi: string | null }[]; + after: { id: string; text: string; order_idx: number; cfi: string | null }[]; + }>(`/chat/segments/${segmentId}/context`), + + // Message actions + deleteMessage: (msgId: string) => + request<{ deleted: string }>(`/chat/messages/${msgId}`, { method: "DELETE" }), + retryMessage: (msgId: string) => { + return fetch(`${API_BASE}/chat/messages/${msgId}/retry`, { method: "POST" }); + }, + branchConversation: (convId: string, fromMessageId: string) => + request(`/chat/conversations/${convId}/branch?from_message_id=${fromMessageId}`, { + method: "POST", + }), + exportConversation: (convId: string) => + request<{ content: string; format: string; title: string }>(`/chat/conversations/${convId}/export`), + + // Models + listModels: () => request("/models"), + addModel: (data: { label: string; kind?: string; base_url?: string; api_key?: string; model_id: string }) => + request("/models", { method: "POST", body: JSON.stringify(data) }), + updateModel: (name: string, data: Partial<{ label: string; kind: string; base_url: string; api_key: string; model_id: string }>) => + request(`/models/${name}`, { method: "PUT", body: JSON.stringify(data) }), + deleteModel: (name: string) => + request<{ deleted: string }>(`/models/${name}`, { method: "DELETE" }), + testModel: (name: string) => + request<{ ok: boolean; error?: string; model?: string; reply?: string }>(`/models/${name}/test`, { method: "POST" }), + importModels: (baseUrl: string, apiKey: string) => + request<{ models: { id: string; name: string }[]; count: number }>("/models/import", { + method: "POST", + body: JSON.stringify({ base_url: baseUrl, api_key: apiKey }), + }), +}; + +export function formatCharCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M 字`; + if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K 字`; + return `${n} 字`; +} diff --git a/tsconfig.json b/frontend/tsconfig.json similarity index 100% rename from tsconfig.json rename to frontend/tsconfig.json diff --git a/src/app/globals.css b/src/app/globals.css deleted file mode 100644 index a2dc41e..0000000 --- a/src/app/globals.css +++ /dev/null @@ -1,26 +0,0 @@ -@import "tailwindcss"; - -:root { - --background: #ffffff; - --foreground: #171717; -} - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx deleted file mode 100644 index 9852c15..0000000 --- a/src/app/layout.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - -export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", -}; - -export default function RootLayout({ children }: LayoutProps<"/">) { - return ( - - {children} - - ); -} diff --git a/src/app/page.tsx b/src/app/page.tsx deleted file mode 100644 index c887311..0000000 --- a/src/app/page.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import Image from "next/image"; - -export default function Home() { - return ( -
    -
    - Next.js logo -
    -

    - To get started, edit the{" "} - - page.tsx - {" "} - file. -

    -

    - Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

    -
    - -
    -
    - ); -}