feat: add BookMate AI reading workspace and full EPUB reader

This commit is contained in:
2026-10-24 03:12:20 +08:00
parent eaeb45fc85
commit c7b0cfb92b
60 changed files with 8996 additions and 191 deletions
+32 -36
View File
@@ -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
+31 -22
View File
@@ -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`、模型凭据、数据库、上传书籍和构建产物不纳入版本控制。
+15
View File
@@ -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=
View File
View File
View File
+146
View File
@@ -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}
+356
View File
@@ -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 "",
)
+183
View File
@@ -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))
View File
+43
View File
@@ -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()
+33
View File
@@ -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
View File
+37
View File
@@ -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
+97
View File
@@ -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,
)
+187
View File
@@ -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
+207
View File
@@ -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)
+216
View File
@@ -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 ""
View File
+277
View File
@@ -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)
+42
View File
@@ -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"}
View File
+28
View File
@@ -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())
+60
View File
@@ -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")
+43
View File
@@ -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")
View File
+286
View File
@@ -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)
+218
View File
@@ -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"
View File
+56
View File
@@ -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
+16
View File
@@ -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
+57
View File
@@ -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)
+41
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
## 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.
<!-- END:nextjs-agent-rules -->
+36
View File
@@ -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.
+2 -2
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+16 -1
View File
@@ -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",

Before

Width:  |  Height:  |  Size: 391 B

After

Width:  |  Height:  |  Size: 391 B

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 128 B

After

Width:  |  Height:  |  Size: 128 B

Before

Width:  |  Height:  |  Size: 385 B

After

Width:  |  Height:  |  Size: 385 B

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long
+15
View File
@@ -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 (
<html lang="zh-CN" className="h-full">
<body className="h-full">{children}</body>
</html>
);
}
+899
View File
@@ -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<Book[]>([]);
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
const [conversations, setConversations] = useState<Conversation[]>([]);
const [activeConv, setActiveConv] = useState<Conversation | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
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<AiModelEntry[]>([]);
const [pickerOpen, setPickerOpen] = useState(false);
const [managerOpen, setManagerOpen] = useState(false);
const [readerBook, setReaderBook] = useState<Book | null>(null);
const [search, setSearch] = useState("");
const [sidebarOpen, setSidebarOpen] = useState(false);
const [booksLoading, setBooksLoading] = useState(true);
const [booksError, setBooksError] = useState(false);
const chatEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [sidebarWidth, setSidebarWidth] = useState(256);
const sidebarDragRef = useRef<{ startX: number; startW: number } | null>(null);
// 多版本回答:每个 user 消息当前选中哪个版本的回答(key = user 消息 id)
const [activeVersions, setActiveVersions] = useState<Record<string, number>>({});
// 流式状态归属:null 表示新问题(渲染在末尾);有值表示重试某条 user 消息(渲染在该轮内)
const [streamTurnId, setStreamTurnId] = useState<string | null>(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<HTMLInputElement>) => {
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<HTMLDivElement>) => {
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 (
<div className="app-shell flex h-full overflow-hidden">
{sidebarOpen && <button className="sidebar-scrim" aria-label="关闭导航" onClick={() => setSidebarOpen(false)} />}
<button className="mobile-menu" aria-label="打开导航" onClick={() => setSidebarOpen(true)}><Menu size={20} /></button>
{/* Sidebar */}
<aside className={`library-sidebar flex flex-col shrink-0 ${sidebarOpen ? "is-open" : ""}`} style={{ width: sidebarWidth }}>
<div className="brand flex items-center gap-3">
<div className="brand-mark flex items-center justify-center shrink-0">
<BookMarked size={16} className="text-white" />
</div>
<div className="min-w-0">
<h1 className="brand-name">BookMate</h1>
<p className="text-[11px] text-[var(--fg-muted)]">让阅读,多一种可能</p>
</div>
</div>
<div className="px-4 pt-2 pb-5">
<button className={`nav-library ${!selectedBook ? "active" : ""}`} onClick={() => { setSelectedBook(null); setSidebarOpen(false); }}><Library size={18} /> 我的书库 <span>{books.length}</span></button>
<input ref={fileInputRef} type="file" accept=".epub" onChange={handleUpload} className="hidden" />
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="upload-button w-full flex items-center justify-center gap-2"
>
{uploading ? <Loader2 size={15} className="animate-spin" /> : <Upload size={15} />}
{uploading ? "解析中..." : "导入书籍"}
</button>
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden px-2 pb-2">
<p className="text-[11px] text-[var(--fg-muted)] px-3 py-2 font-medium tracking-wider">书库</p>
{books.map((b) => (
<div key={b.id} className="flex items-center group">
<button
onClick={() => startChat(b)}
className={`flex-1 min-w-0 text-left px-3 py-2.5 rounded-xl transition-colors ${
selectedBook?.id === b.id ? "bg-[var(--accent-soft)]" : "hover:bg-[var(--bg-hover)]"
}`}
>
<div className="text-sm font-medium truncate text-[var(--fg)]">{b.title || b.filename}</div>
<div className="text-[11px] text-[var(--fg-muted)] mt-0.5 truncate">
{b.author || "未知作者"} · {formatCharCount(b.char_count)}
</div>
</button>
<ActionBtn danger title="删除书籍" onClick={() => handleDeleteBook(b)}>
<Trash2 size={15} />
</ActionBtn>
</div>
))}
{books.length === 0 && (
<div className="flex flex-col items-center text-center p-6 text-[var(--fg-muted)]">
<BookOpen size={28} strokeWidth={1.5} className="mb-2 opacity-60" />
<p className="text-xs">上传 EPUB 开始阅读</p>
</div>
)}
</div>
<div className="sidebar-footer">
<div className="reading-note"><Sparkles size={15} /><span>好问题,让一本书更厚。</span></div>
<button onClick={() => setManagerOpen(true)}><Settings2 size={17} /><span>模型与设置</span><ChevronRight size={14} /></button>
<span className="sidebar-edition">BOOKMATE · YOUR READING SPACE</span>
</div>
</aside>
{/* 可拖动分隔条:调整书库侧栏宽度 */}
<div
role="separator"
aria-orientation="vertical"
aria-label="拖动调整书库宽度"
title="拖动调整书库宽度"
onPointerDown={onSidebarResizePointerDown}
className="sidebar-resizer w-1 shrink-0 cursor-col-resize touch-none select-none bg-[var(--border)] hover:bg-[var(--accent)] active:bg-[var(--accent)] transition-colors"
/>
{/* Main */}
<main className="main-panel flex-1 flex flex-col min-w-0">
{selectedBook ? (
<>
<header className="reader-header border-b border-[var(--border)] flex items-center gap-3 shrink-0">
<button className="back-library" onClick={() => setSelectedBook(null)} aria-label="返回书库"><ArrowLeft size={18} /></button>
<div className="flex-1 min-w-0">
<h2 className="text-[15px] font-semibold truncate text-[var(--fg)]">{selectedBook.title || selectedBook.filename}</h2>
<p className="text-[11px] text-[var(--fg-muted)]">{selectedBook.author}</p>
</div>
<button
onClick={() => setReaderBook(selectedBook)}
className="open-full-book"
title="打开完整 EPUB"
>
<BookOpen size={16} />
<span>阅读全书</span>
</button>
<button
onClick={handleExport}
disabled={!activeConv}
className="text-xs px-3 py-1.5 rounded-lg hover:bg-[var(--bg-hover)] disabled:opacity-50 transition-colors flex items-center gap-1.5 text-[var(--fg-muted)] hover:text-[var(--fg)]"
title="导出对话"
>
<Download size={13} />
导出
</button>
</header>
<div className="flex-1 flex min-h-0">
<div className="conversation-sidebar overflow-y-auto overflow-x-hidden py-3 shrink-0">
<p className="text-[11px] text-[var(--fg-muted)] px-3 py-2 font-medium tracking-wider">对话历史</p>
<button
onClick={handleNewConversation}
disabled={!selectedBook}
className="w-full mb-1 px-3 py-2 rounded-xl text-xs text-[var(--fg-muted)] hover:text-[var(--fg)] hover:bg-[var(--bg-hover)] disabled:opacity-50 transition-colors flex items-center justify-center gap-1.5"
>
<Plus size={12} />
新建对话
</button>
{conversations.map((c) => (
<div key={c.id} className="flex items-center group">
<button
onClick={() => selectConv(c)}
className={`flex-1 min-w-0 text-left px-3 py-2 rounded-xl text-[13px] truncate text-[var(--fg)] transition-colors ${
activeConv?.id === c.id ? "bg-[var(--accent-soft)]" : "hover:bg-[var(--bg-hover)]"
}`}
>
{c.title || "对话"}
</button>
<ActionBtn danger title="删除对话" onClick={() => handleDeleteConversation(c.id)}>
<X size={14} />
</ActionBtn>
</div>
))}
</div>
<div className="flex-1 flex flex-col min-w-0">
<div className="flex-1 overflow-y-auto px-6 pt-6 pb-10 space-y-6">
<div className="max-w-3xl mx-auto w-full space-y-6">
{turns.length === 0 && !streaming && (
<div className="chat-welcome">
<div className="chat-welcome-icon"><BookOpen size={30} strokeWidth={1.4} /></div>
<span className="eyebrow">READ · THINK · DISCOVER</span>
<h2>从一个好问题开始。</h2>
<p>关于《{selectedBook.title || selectedBook.filename}》,你想了解什么?</p>
<div className="prompt-grid">
{[{ icon: BookOpen, title: "读懂这本书", prompt: "请梳理这本书的核心观点与整体结构。" }, { icon: MessageSquare, title: "深入一个观点", prompt: "这本书中有哪些值得深入思考的观点?请结合原文展开。" }, { icon: GitBranch, title: "连接知识", prompt: "请整理这本书中重要概念之间的联系。" }].map(({ icon: Icon, title, prompt }) => (
<button key={title} onClick={() => setInput(prompt)}><Icon size={19} /><strong>{title}</strong><span>{prompt}</span><ArrowUpRight size={15} /></button>
))}
</div>
</div>
)}
{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 (
<div key={turn.user.id} className="space-y-1.5">
<ChatBubble
message={turn.user}
onCopy={handleCopy}
onRetry={isLastTurn ? handleRetry : undefined}
/>
{versions.length > 1 && (
<div className="flex items-center gap-1 pl-10">
<button
type="button"
onClick={() => setActiveVersions((prev) => ({ ...prev, [turn.user.id]: activeIdx - 1 }))}
disabled={activeIdx <= 0}
className="p-1 rounded-md text-[var(--fg-muted)] hover:bg-white/10 hover:text-[var(--fg)] disabled:opacity-30 transition-colors"
title="上一个版本"
>
<ChevronLeft size={14} />
</button>
<span className="px-2 py-0.5 rounded-md bg-white/5 text-xs text-[var(--fg-muted)]">
版本 {activeIdx + 1} / {versions.length}
</span>
<button
type="button"
onClick={() => setActiveVersions((prev) => ({ ...prev, [turn.user.id]: activeIdx + 1 }))}
disabled={activeIdx >= versions.length - 1}
className="p-1 rounded-md text-[var(--fg-muted)] hover:bg-white/10 hover:text-[var(--fg)] disabled:opacity-30 transition-colors"
title="下一个版本"
>
<ChevronRight size={14} />
</button>
</div>
)}
{shown && (
<ChatBubble
key={shown.id}
message={shown}
onCopy={handleCopy}
onDelete={handleDelete}
onBranch={handleBranch}
/>
)}
{streamingHere && (
<ChatBubble
message={{ id: "stream", conversation_id: "", parent_message_id: turn.user.id, role: "assistant", content: streamContent, citations: null, model, created_at: "" }}
isStreaming={true}
/>
)}
</div>
);
})}
{streaming && !streamTurnId && streamContent && (
<ChatBubble
message={{ id: "stream", conversation_id: "", parent_message_id: null, role: "assistant", content: streamContent, citations: null, model, created_at: "" }}
isStreaming={true}
/>
)}
<div ref={chatEndRef} />
</div>
</div>
<div className="composer-area px-4 pt-3 pb-4">
{/* 输入胶囊:模型选择 + 输入合并(Perplexity 风) */}
<div className="max-w-3xl mx-auto">
<div className="composer rounded-2xl px-4 py-3">
<div className="flex items-center justify-between mb-1">
<div className="relative">
{pickerOpen && <div className="fixed inset-0 z-10" onClick={() => setPickerOpen(false)} />}
<button
onClick={() => setPickerOpen((v) => !v)}
className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-md bg-white/5 text-[var(--fg-muted)] hover:text-[var(--fg)] hover:bg-white/10 transition-colors"
title={currentModel ? `${currentModel.model_id} · ${currentModel.base_url || currentModel.kind}` : (aiModels.length ? "" : "尚未配置模型")}
>
<Sparkles size={12} className="text-[var(--accent)]" />
<span className="max-w-40 truncate">{currentModel?.label ?? (aiModels.length ? model : "未配置模型")}</span>
<ChevronDown size={12} />
</button>
{pickerOpen && (
<div className="absolute z-20 bottom-full mb-1.5 left-0 w-64 rounded-xl border border-[var(--border)] bg-[var(--bg-card)] shadow-2xl p-1.5 max-h-72 overflow-y-auto">
<p className="text-[10px] text-[var(--fg-muted)] px-2 pt-1 pb-1.5 font-medium tracking-wider">选择回答模型</p>
{aiModels.map((m) => (
<button
key={m.name}
onClick={() => { setModel(m.name); setPickerOpen(false); }}
className={`w-full flex items-center justify-between gap-2 px-2.5 py-2 rounded-lg text-left text-sm transition-colors ${
model === m.name ? "bg-[var(--accent-soft)] text-[var(--accent)]" : "text-[var(--fg)] hover:bg-[var(--bg-hover)]"
}`}
>
<span className="truncate">{m.label}</span>
<span className="text-[10px] text-[var(--fg-muted)] shrink-0">{m.builtin ? "内置" : "自定义"}</span>
</button>
))}
{aiModels.length === 0 && (
<p className="px-2 py-3 text-xs text-[var(--fg-muted)] text-center">尚未配置模型,点「管理」添加</p>
)}
</div>
)}
</div>
<button
onClick={() => setManagerOpen(true)}
className="text-xs px-2 py-1 rounded-md text-[var(--fg-muted)] hover:text-[var(--fg)] hover:bg-white/5 transition-colors flex items-center gap-1"
>
<Settings2 size={12} />
管理模型
</button>
</div>
<div className="flex items-center gap-2">
<input
value={input}
onChange={(e) => 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"
/>
<button
onClick={handleSend}
disabled={!input.trim() || streaming}
className="p-2 rounded-xl bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-40 disabled:hover:bg-[var(--accent)] transition-colors shrink-0"
title="发送"
>
{streaming ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</>
) : (
<div className="library-page">
<header className="library-topbar"><span>我的阅读空间 <span className="breadcrumb-slash">/</span> <strong>书库</strong></span><span className="topbar-note"><span /> 与好书,慢慢相处</span></header>
<div className="library-content">
<section className="library-hero">
<div className="hero-copy"><span className="eyebrow">A LITTLE READING, A LITTLE MORE WORLD</span><h1>每一本书,<br />都值得<span>再读深一点。</span></h1><p>收藏你的好书,与 AI 一起追问、思考,<br className="desktop-break" />让读过的每一页,都成为自己的收获。</p><button className="hero-button" onClick={() => fileInputRef.current?.click()} disabled={uploading}>{uploading ? <Loader2 size={17} className="animate-spin" /> : <Plus size={17} />}{uploading ? "正在导入…" : "添加一本好书"}<ArrowUpRight size={17} /></button></div>
<div className="hero-art" aria-hidden="true"><div className="orbit orbit-one" /><div className="orbit orbit-two" /><span className="art-star">✳</span><div className="art-book art-book-back"><span>THINK<br />BEYOND<br />THE PAGE.</span></div><div className="art-book art-book-front"><BookOpen size={28} strokeWidth={1} /><span>书中有光</span><small>A WORLD WITHIN</small><div className="book-line" /></div><div className="art-caption">Open a book. Open a world.</div></div>
</section>
<section className="books-section" aria-labelledby="books-heading">
<div className="books-toolbar"><div><h2 id="books-heading">我的书库 <span>{books.length}</span></h2><p>在这里,开启下一次思想漫游。</p></div><label className="book-search"><Search size={17} /><input aria-label="搜索书名或作者" placeholder="搜索书名、作者…" value={search} onChange={(e) => setSearch(e.target.value)} />{search && <button aria-label="清除搜索" onClick={() => setSearch("")}><X size={14} /></button>}</label></div>
{booksLoading ? <div className="library-state"><Loader2 size={23} className="animate-spin" /><p>正在整理你的书库…</p></div> : booksError ? <div className="library-state"><BookOpen size={26} /><h3>暂时无法加载书库</h3><p>请确认阅读服务已启动,然后再试一次。</p><button className="text-link" onClick={loadBooks}>重新加载 <RotateCcw size={14} /></button></div> : (
<div className="book-grid">
{books.filter((b) => `${b.title || b.filename} ${b.author || ""}`.toLowerCase().includes(search.toLowerCase())).map((book, i) => (
<article key={book.id} className="book-card group">
<button className="book-card-open" onClick={() => startChat(book)} aria-label={`阅读 ${book.title || book.filename}`}>
<div className={`book-stage cover-tone-${i % 4}`}><div className="book-cover"><span className="cover-label">BOOKMATE LIBRARY</span><h3>{book.title || book.filename}</h3><span className="cover-author">{book.author || "佚名"}</span><div className="cover-motif"><span /><span /><span /></div><BookOpen size={18} strokeWidth={1.2} /></div><span className="book-open-label">开始共读 <ArrowUpRight size={15} /></span></div>
<div className="book-card-info"><h3>{book.title || book.filename}</h3><p>{book.author || "未知作者"}</p><span>EPUB <i /> {formatCharCount(book.char_count)}</span></div>
</button>
<button className="book-card-read" onClick={() => setReaderBook(book)}><BookOpen size={14} /> 阅读全书</button>
<div className="book-card-delete"><ActionBtn danger title="删除书籍" onClick={() => handleDeleteBook(book)}><Trash2 size={15} /></ActionBtn></div>
</article>
))}
{!search && <button className="add-book-card" onClick={() => fileInputRef.current?.click()} disabled={uploading}><span><Plus size={24} strokeWidth={1.3} /></span><strong>{uploading ? "正在导入…" : "给书库添一本书"}</strong><p>导入 EPUB,开始和好书对话</p><small>每一次翻开,都有新的发现</small></button>}
{search && !books.some((b) => `${b.title || b.filename} ${b.author || ""}`.toLowerCase().includes(search.toLowerCase())) && <div className="library-state no-results"><Search size={25} /><h3>没有找到相关书籍</h3><p>换个书名或作者试试。</p></div>}
</div>
)}
</section>
<footer className="library-bottom"><BookOpen size={14} /><span>阅读的意义,藏在每一次思考里。</span><span>MADE FOR CURIOUS MINDS</span></footer>
</div>
</div>
)}
</main>
{readerBook && (
<EpubReader
key={readerBook.id}
book={readerBook}
onClose={() => 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("暂时无法打开对话,请稍后重试。");
}
}}
/>
)}
<ModelManagerDialog
open={managerOpen}
onClose={() => setManagerOpen(false)}
models={aiModels}
onChanged={() => api.listModels().then(setAiModels)}
/>
</div>
);
}
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<string | null>(null);
const isUser = message.role === "user";
const isStream = isStreaming || message.id === "stream";
// 同一段落被 AI 重复引用时只显示一条(引用渲染去重,不影响已注入的检索内容)
const uniqCitations = useMemo(() => {
const seen = new Set<string>();
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 (
<>
<div className={`flex group ${isUser ? "justify-end" : "justify-start"}`}>
<div className={`flex items-end gap-1.5 max-w-[82%] ${isUser ? "flex-row-reverse" : "flex-row"}`}>
<div
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 mb-1 ${
isUser
? "bg-[var(--accent)] text-white"
: "bg-[var(--accent-soft)] text-[var(--accent)]"
}`}
>
{isUser ? <User size={12} /> : <Bot size={12} />}
</div>
<div className="min-w-0">
<div
className={`${
isUser
? "rounded-2xl rounded-br-md px-4 py-2.5 text-[15px] leading-relaxed bg-[var(--accent)] text-white shadow-sm"
: "px-0.5 py-0.5 text-[15px] leading-[1.75] text-[var(--fg)]"
} ${isStream ? "streaming-answer" : ""}`}
>
<MarkdownRenderer content={message.content} variant={isUser ? "dark" : "light"} />
{uniqCitations.length > 0 && (
<div className={`mt-3 pt-2 border-t ${isUser ? "border-white/25" : "border-[var(--border)]"}`}>
<button
type="button"
onClick={() => setCitationsOpen((v) => !v)}
className={`text-xs font-medium flex items-center gap-1 transition-colors ${
isUser ? "text-white/80" : "text-[var(--fg-muted)] hover:text-[var(--fg)]"
}`}
>
<Paperclip size={11} />
引用来源({uniqCitations.length})
{uniqCitations.length < (message.citations?.length ?? 0) && (
<span className="opacity-70">已合并重复</span>
)}
<ChevronDown size={11} className={`transition-transform ${citationsOpen ? "rotate-180" : ""}`} />
</button>
{citationsOpen && (
<div className="mt-2 space-y-1.5">
{uniqCitations.map((c, i) => (
<div
key={i}
className={`text-xs py-1.5 px-2 rounded flex items-start gap-2 ${
isUser ? "text-white/85 bg-white/10" : "text-[var(--fg-muted)] bg-[var(--bg)]"
}`}
>
<span className={`font-medium shrink-0 ${isUser ? "text-white" : "text-[var(--accent)]"}`}>[{i + 1}]</span>
<span className="flex-1 min-w-0 leading-relaxed">
{(c.excerpt || "").replace(/《heading_path》\s*/g, "").slice(0, 80)}{(c.excerpt || "").length > 80 ? "…" : ""}
</span>
<button
type="button"
onClick={() => setReaderSegmentId(c.segment_id)}
className="shrink-0 flex items-center gap-0.5 font-medium text-[var(--accent-hover)] hover:underline mt-0.5"
title="阅读该段原文"
>
<BookOpen size={11} />
原文
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
{!isStream && (
<div className={`flex gap-0.5 mt-1 px-1 opacity-0 group-hover:opacity-100 transition-opacity ${isUser ? "justify-end" : "justify-start"}`}>
<ActionBtn title="复制" onClick={handleCopy}>
{copied ? <Check size={15} className="text-[var(--success)]" /> : <Copy size={15} />}
</ActionBtn>
{onRetry && (
<ActionBtn title="重新回答" onClick={() => onRetry(message)}>
<RotateCcw size={15} />
</ActionBtn>
)}
{onBranch && (
<ActionBtn title="从此分支" onClick={() => onBranch(message.id)}>
<GitBranch size={15} />
</ActionBtn>
)}
{onDelete && (
<ActionBtn title="删除" onClick={() => onDelete(message.id)}>
<Trash2 size={15} />
</ActionBtn>
)}
</div>
)}
</div>
</div>
</div>
<CitationReaderDialog
open={!!readerSegmentId}
segmentId={readerSegmentId}
onClose={() => 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<string, Turn>();
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 (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onClick(); }}
className={`w-7 h-7 flex items-center justify-center rounded-lg shrink-0 touch-manipulation opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity ${
danger
? "text-[var(--fg-muted)] hover:text-[var(--danger)] hover:bg-red-500/10 active:bg-red-500/15"
: "text-[var(--fg-muted)] hover:bg-white/10 hover:text-[var(--fg)] active:bg-white/10"
}`}
title={title}
aria-label={title}
>
{children}
</button>
);
}
@@ -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 (
<p className={`text-sm leading-relaxed ${muted ? "text-[var(--fg-muted)] text-[13px]" : "text-[var(--fg)]"}`}>
{cleanText(text)}
</p>
);
}
export default function CitationReaderDialog({
open,
segmentId,
onClose,
}: {
open: boolean;
segmentId: string | null;
onClose: () => void;
}) {
const [data, setData] = useState<SegmentCtx | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
<div
className="bg-[var(--bg-card)] border border-[var(--border)] rounded-2xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* 头部 */}
<div className="flex items-center gap-2 px-5 py-3.5 border-b border-[var(--border)] shrink-0">
<BookOpen size={16} className="text-[var(--accent)] shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-[var(--fg)] truncate">
{data?.chapter?.title || "阅读原文"}
</p>
{data && (
<p className="text-[11px] text-[var(--fg-muted)]">
第 {data.segment.order_idx + 1} 段
{data.segment.cfi ? " · EPUB 内定位可用" : ""}
</p>
)}
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-[var(--fg-muted)] hover:bg-white/10 hover:text-[var(--fg)] transition-colors"
title="关闭"
>
<X size={16} />
</button>
</div>
{/* 正文 */}
<div className="flex-1 overflow-y-auto p-5 space-y-3">
{loading && (
<div className="flex items-center justify-center gap-2 py-10 text-[var(--fg-muted)] text-sm">
<Loader2 size={16} className="animate-spin" /> 加载原文…
</div>
)}
{error && <p className="text-sm text-[var(--danger)] py-6 text-center">{error}</p>}
{data && (
<>
<div className="space-y-3 text-[var(--fg-muted)]">
{data.before.map((p) => (
<Para key={p.id} text={p.text} muted />
))}
</div>
<div className="rounded-xl border border-[var(--accent)] bg-[var(--accent-soft)] px-4 py-3">
<Para text={data.segment.text} />
</div>
<div className="space-y-3 text-[var(--fg-muted)]">
{data.after.map((p) => (
<Para key={p.id} text={p.text} muted />
))}
</div>
</>
)}
</div>
</div>
</div>
);
}
+42
View File
@@ -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 <pre> instead of throwing.
*/
export default function CodeBlock({ code, language }: { code: string; language?: string }) {
const [html, setHtml] = useState<string | null>(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 (
<div className="my-2 overflow-x-auto rounded-lg text-[0.85em]" dangerouslySetInnerHTML={{ __html: html }} />
);
}
return (
<pre className="my-2 p-3 rounded-lg text-[0.85em] font-mono overflow-x-auto bg-[#0d1117] text-[#e6edf3]">
<code>{code}</code>
</pre>
);
}
+195
View File
@@ -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<HTMLDivElement>(null);
const viewerRef = useRef<HTMLDivElement>(null);
const renditionRef = useRef<Rendition | null>(null);
const closeRef = useRef(onClose);
const [toc, setToc] = useState<TocEntry[]>([]);
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<Location | null>(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<HTMLButtonElement>("button")?.focus();
const keydown = (event: KeyboardEvent) => {
if (event.key === "Escape") { event.preventDefault(); closeRef.current(); }
if (event.key !== "Tab") return;
const controls = dialogRef.current?.querySelectorAll<HTMLElement>('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 (
<div ref={dialogRef} className="epub-reader" role="dialog" aria-modal="true" aria-label={`阅读《${book.title || book.filename}》`}>
<header className="epub-toolbar">
<button onClick={onClose} className="epub-return"><ArrowLeft size={18} /><span>返回</span></button>
<div className="epub-book-title"><strong>{book.title || book.filename}</strong><small>{book.author || "完整 EPUB 阅读"}</small></div>
<button aria-label="切换目录" aria-expanded={tocOpen} onClick={() => setTocOpen(!tocOpen)}><List size={19} /><span>目录</span></button>
<div className="epub-font-controls"><button aria-label="缩小字号" disabled={fontSize <= 14 || loading} onClick={() => changeFont(-2)}><Minus size={16} /></button><span>{fontSize}</span><button aria-label="增大字号" disabled={fontSize >= 28 || loading} onClick={() => changeFont(2)}><Plus size={16} /></button></div>
</header>
<div className="epub-body">
{tocOpen && <nav className="epub-toc" aria-label="全书目录"><h3><BookOpen size={16} /> 全书目录</h3>{toc.length ? toc.map((entry, index) => (
<button key={`${entry.href}-${index}`} aria-current={currentChapter === entry ? "location" : undefined} style={{ paddingLeft: 16 + Math.min(entry.depth, 6) * 12 }} onClick={() => void navigate(entry.href)} disabled={loading || turning}>{entry.label || "未命名章节"}</button>
)) : <p>{loading ? "正在加载目录…" : "此书没有可用目录"}</p>}</nav>}
<div className="epub-paper">
{loading && <div className="epub-loading" role="status"><Loader2 className="animate-spin" size={28} /><p>正在翻开这本书…</p></div>}
{error && <div className="epub-error" role="alert"><p>{error}</p><button onClick={() => { setError(""); setLoading(true); setAttempt((value) => value + 1); }}><RotateCcw size={15} />重新打开</button></div>}
<div ref={viewerRef} className="epub-viewport" aria-label="书籍正文" />
</div>
</div>
{selectedText && <div className="epub-selection"><span>已选中 {selectedText.length} 字</span><button onClick={() => onAsk(`请结合《${book.title || book.filename}》解释这段原文:\n\n「${selectedText}」`)}><MessageSquare size={15} /> 交给 AI 解读</button></div>}
<footer className="epub-footer">
<button onClick={() => void navigate("prev")} disabled={loading || turning || !location || location.atStart}><ChevronLeft size={18} /><span>上一页</span></button>
<div aria-live="polite"><strong>{currentChapter?.label || "阅读原文"}</strong><small>{location ? `本节 ${location.start.displayed.page} / ${location.start.displayed.total} 页` : "保留原书正文、图片与目录"} · 自动记住阅读位置</small></div>
<button onClick={() => void navigate("next")} disabled={loading || turning || !location || location.atEnd}><span>下一页</span><ChevronRight size={18} /></button>
</footer>
</div>
);
}
@@ -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 (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
h1: ({ children }) => (
<h1 className={`text-lg font-semibold mt-5 mb-2 ${dark ? "text-white" : "text-[var(--fg)]"}`}>{children}</h1>
),
h2: ({ children }) => (
<h2 className={`text-base font-semibold mt-4 mb-1.5 ${dark ? "text-white/90" : "text-[var(--fg)]"}`}>{children}</h2>
),
h3: ({ children }) => (
<h3 className={`text-[15px] font-medium mt-3 mb-1 ${dark ? "text-white/80" : "text-[var(--fg)]/90"}`}>{children}</h3>
),
p: ({ children }) => <p className="my-2 leading-[1.75]">{children}</p>,
ul: ({ children }) => <ul className="list-disc pl-5 my-1.5 space-y-0.5">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal pl-5 my-1.5 space-y-0.5">{children}</ol>,
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
input: ({ checked, disabled }) => (
<input type="checkbox" checked={checked} disabled={disabled} className="mr-1.5 accent-blue-500" />
),
blockquote: ({ children }) => (
<blockquote
className={`border-l-4 pl-3 my-2 italic py-1 rounded-r ${
dark ? "border-white/30 text-white/70 bg-white/10" : "border-[var(--accent)] text-[var(--fg-muted)] bg-[var(--accent-soft)]"
}`}
>
{children}
</blockquote>
),
code: ({ className, children }) => {
// 带语言标注的必然出现在 <pre> 里,块级渲染由 pre 统一处理;
// 这里只负责真正的行内代码。注意要保留 className,供 pre 读取语言。
if (className) {
return <code className={className}>{children}</code>;
}
return (
<code
className={`px-1.5 py-0.5 rounded text-[0.85em] font-mono ${
dark ? "bg-white/20 text-white" : "bg-[var(--accent-soft)] text-[var(--accent)]"
}`}
>
{children}
</code>
);
},
pre: ({ children }) => {
// 所有代码块都会先经过 <pre>,所以块级判断放在这里最可靠:
// 提取语言和源码,交给 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 解析器对中文 / <br/> / 数字容易报 NODE_DEND 错误,
// 所以 mindmap 块不交给 mermaid,直接用 MindMap 组件渲染成可交互思维导图。
if (isMermaidMindMapSource(text)) return <MindMap code={text} />;
return <MermaidDiagram code={text} />;
}
return <CodeBlock code={text} language={lang} />;
},
table: ({ children }) => (
<div className="overflow-x-auto my-2">
<table className={`min-w-full border-collapse border text-sm ${dark ? "border-white/20" : "border-[var(--border)]"}`}>
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className={dark ? "bg-white/10" : "bg-[var(--bg-raised)]"}>{children}</thead>
),
th: ({ children }) => (
<th className={`border px-3 py-1.5 text-left font-semibold ${dark ? "border-white/20" : "border-[var(--border)]"}`}>
{children}
</th>
),
td: ({ children }) => (
<td className={`border px-3 py-1.5 ${dark ? "border-white/20" : "border-[var(--border)]"}`}>{children}</td>
),
hr: () => <hr className={`my-3 ${dark ? "border-white/20" : "border-[var(--border)]"}`} />,
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={dark ? "text-blue-200 hover:underline" : "text-[var(--accent-hover)] hover:underline"}
>
{children}
</a>
),
strong: ({ children }) => (
<strong className={`font-bold ${dark ? "text-white" : "text-[var(--fg)]"}`}>{children}</strong>
),
em: ({ children }) => <em className="italic">{children}</em>,
}}
>
{content}
</ReactMarkdown>
);
}
@@ -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<string | null>(null);
const [error, setError] = useState(false);
const idRef = useRef<string>(`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 (
<div
className="my-2 rounded-lg border border-[var(--border)] bg-[var(--bg-card)] p-3 overflow-x-auto"
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
}
if (error) {
return (
<pre className="my-2 p-3 rounded-lg text-[0.85em] font-mono overflow-x-auto bg-[#0d1117] text-green-300">
<code>{code}</code>
</pre>
);
}
return <div className="my-2 p-3 text-xs text-[var(--fg-muted)]">图表渲染中…</div>;
}
+494
View File
@@ -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<string, string> = { "(": ")", "[": "]", "{": "}", "<": ">" };
let text = line
.replace(/^root\b\s*/, "") // mermaid 要求根节点带 root 关键字
.replace(/::icon\([^)]*\)/g, "")
.replace(/<br\s*\/?>/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<MindNode, { w: number; h: number; subtreeH: number }>();
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<HTMLDivElement>(null); // 可视窗口(overflow hidden)
const stageRef = useRef<HTMLDivElement>(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 (
<div className="my-4 rounded-xl bg-[var(--bg-card)] shadow-[inset_0_1px_0_rgba(255,255,255,0.06)] overflow-hidden">
{/* 工具栏:无边框控制台风,视图切换 pill + 纯图标按钮 */}
<div className="flex items-center gap-1 flex-wrap px-3 py-1.5 border-b border-[var(--border)]">
<div className="rounded-lg bg-white/5 p-0.5 flex overflow-hidden text-xs shrink-0">
<button
onClick={() => setView("render")}
className={`px-2.5 py-1 rounded-md transition-colors ${
view === "render" ? "bg-[var(--accent)] text-white" : "text-[var(--fg-muted)] hover:text-[var(--fg)]"
}`}
>
渲染
</button>
<button
onClick={() => setView("code")}
className={`px-2.5 py-1 rounded-md transition-colors ${
view === "code" ? "bg-[var(--accent)] text-white" : "text-[var(--fg-muted)] hover:text-[var(--fg)]"
}`}
>
代码
</button>
</div>
<div className="flex-1" />
<button
onClick={copySource}
className={`p-1.5 rounded-md transition-colors shrink-0 ${
copied ? "text-[var(--success)]" : "text-[var(--fg-muted)] hover:text-[var(--fg)] hover:bg-white/5"
}`}
title="复制 mermaid 源码"
aria-label="复制 mermaid 源码"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
<button
onClick={exportMd}
className="p-1.5 rounded-md text-[var(--fg-muted)] hover:text-[var(--fg)] hover:bg-white/5 transition-colors shrink-0"
title="导出为 Markdown"
aria-label="导出为 Markdown"
>
<Download size={14} />
</button>
{view === "render" && (
<button
onClick={exportImage}
disabled={exporting}
className="p-1.5 rounded-md text-[var(--fg-muted)] hover:text-[var(--fg)] hover:bg-white/5 transition-colors shrink-0 disabled:opacity-50"
title="导出为图片"
aria-label="导出为图片"
>
{exporting ? <Loader2 size={14} className="animate-spin" /> : <ImageIcon size={14} />}
</button>
)}
{view === "render" && (
<div className="flex items-center rounded-md bg-white/5 overflow-hidden text-xs shrink-0">
<button onClick={() => zoomBy(1 / 1.2)} className="p-1.5 text-[var(--fg-muted)] hover:text-[var(--fg)] hover:bg-white/5" title="缩小">
<Minus size={13} />
</button>
<span className="w-9 text-center text-[10px] text-[var(--fg-muted)]">{Math.round(scale * 100)}%</span>
<button onClick={() => zoomBy(1.2)} className="p-1.5 text-[var(--fg-muted)] hover:text-[var(--fg)] hover:bg-white/5" title="放大">
<Plus size={13} />
</button>
<button onClick={fit} className="p-1.5 text-[var(--fg-muted)] hover:text-[var(--fg)] hover:bg-white/5" title="适应窗口">
<Maximize2 size={13} />
</button>
</div>
)}
</div>
{/* 视图内容 */}
{view === "render" ? (
<div
ref={wrapRef}
className="relative overflow-hidden select-none bg-gradient-to-br from-[#0d1526] to-[#0a1120]"
style={{ height: VIEW_H }}
title="滚轮缩放 · 拖动平移"
>
<div
ref={stageRef}
className="absolute left-0 top-0 origin-top-left cursor-grab active:cursor-grabbing"
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`, touchAction: "none" }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<svg className="absolute left-0 top-0" width={layout.width} height={layout.height} style={{ overflow: "visible" }}>
{layout.edges.map((e, i) => {
const midX = e.x1 + (e.x2 - e.x1) * 0.5;
return (
<path
key={i}
d={`M ${e.x1} ${e.y1} C ${midX} ${e.y1}, ${midX} ${e.y2}, ${e.x2} ${e.y2}`}
stroke="#818cf8"
strokeWidth="1.5"
fill="none"
/>
);
})}
</svg>
{layout.nodes.map((ln, i) => {
const depthStyle = DEPTH_STYLES[Math.min(ln.node.level, DEPTH_STYLES.length - 1)];
return (
<div
key={i}
className={`absolute rounded-lg border px-3 py-1.5 text-sm leading-tight ${depthStyle}`}
style={{ left: ln.x, top: ln.y, width: ln.w }}
>
{ln.node.text}
</div>
);
})}
</div>
<span className="pointer-events-none absolute bottom-1 right-2 text-[10px] text-white/30 hidden sm:inline">
滚轮缩放 · 拖动平移
</span>
</div>
) : (
<CodeBlock code={code} language="mermaid" />
)}
</div>
);
}
@@ -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<string | null>(null);
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState<string | null>(null);
// 测试
const [testingName, setTestingName] = useState<string | null>(null);
const [testResult, setTestResult] = useState<Record<string, { ok: boolean; text: string }>>({});
// 批量导入(任意 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<Set<string>>(new Set());
const [importMsg, setImportMsg] = useState<string | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
<div
className="bg-[var(--bg-card)] border border-[var(--border)] rounded-2xl shadow-2xl w-full max-w-[600px] max-h-[80vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* 头部 */}
<div className="flex items-center gap-2 px-5 py-4 border-b border-[var(--border)]">
{mode !== "list" && (
<button onClick={() => setMode("list")} className="p-1 rounded-md text-[var(--fg-muted)] hover:bg-white/10 hover:text-[var(--fg)] transition-colors" title="返回">
<ArrowLeft size={16} />
</button>
)}
<Settings2 size={17} className="text-[var(--accent)]" />
<h2 className="flex-1 text-base font-semibold text-[var(--fg)]">
{mode === "form" ? (editingName ? "编辑模型" : "添加模型") : mode === "import" ? "从 OpenRouter 导入" : "管理模型"}
</h2>
<button onClick={onClose} className="p-1 rounded-md text-[var(--fg-muted)] hover:bg-white/10 hover:text-[var(--fg)] transition-colors" title="关闭">
<X size={16} />
</button>
</div>
{/* 列表模式 */}
{mode === "list" && (
<div className="flex-1 overflow-y-auto p-4 space-y-2">
<div className="flex gap-2 mb-3">
<button
onClick={startAdd}
className="flex-1 py-2 rounded-lg text-sm bg-[var(--accent)] text-white font-medium hover:bg-[var(--accent-hover)] transition-colors flex items-center justify-center gap-1.5"
>
<Plus size={15} />
添加模型
</button>
<button
onClick={() => setMode("import")}
className="flex-1 py-2 rounded-lg text-sm border border-[var(--border)] text-[var(--fg)] hover:bg-white/5 transition-colors flex items-center justify-center gap-1.5"
>
<Upload size={15} />
导入模型
</button>
</div>
{models.length === 0 && (
<p className="text-sm text-[var(--fg-muted)] text-center py-8">还没有可用模型,点上方「添加模型」配置一个</p>
)}
{models.map((m) => (
<div key={m.name} className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-[var(--border)] bg-[var(--bg-raised)]">
<div className="w-8 h-8 rounded-lg bg-[var(--accent-soft)] flex items-center justify-center shrink-0">
{m.kind === "anthropic" ? <KeyRound size={15} className="text-[var(--accent)]" /> : <Settings2 size={15} className="text-[var(--accent)]" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[var(--fg)] truncate">{m.label}</span>
{m.builtin && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-white/10 text-[var(--fg-muted)]">内置</span>
)}
</div>
<div className="text-xs text-[var(--fg-muted)] truncate">
{m.model_id}
{m.base_url ? ` · ${m.base_url.replace(/^https?:\/\//, "").split("/")[0]}` : ""}
{m.api_key_masked ? ` · ${m.api_key_masked}` : ""}
</div>
{testResult[m.name] && (
<div className={`text-xs mt-1 ${testResult[m.name].ok ? "text-[var(--success)]" : "text-[var(--danger)]"}`}>
{testResult[m.name].text}
</div>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => test(m)}
disabled={testingName === m.name}
className="p-1.5 rounded-lg text-[var(--fg-muted)] hover:bg-white/10 hover:text-[var(--accent)] transition-colors disabled:opacity-40"
title="测试连通"
>
{testingName === m.name ? <Loader2 size={15} className="animate-spin" /> : <FlaskConical size={15} />}
</button>
{!m.builtin && (
<>
<button
onClick={() => startEdit(m)}
className="p-1.5 rounded-lg text-[var(--fg-muted)] hover:bg-white/10 hover:text-[var(--accent)] transition-colors"
title="编辑"
>
<Pencil size={15} />
</button>
<button
onClick={() => remove(m)}
className="p-1.5 rounded-lg text-[var(--fg-muted)] hover:bg-white/10 hover:text-[var(--danger)] transition-colors"
title="删除"
>
<Trash2 size={15} />
</button>
</>
)}
</div>
</div>
))}
</div>
)}
{/* 表单模式 */}
{mode === "form" && (
<div className="flex-1 overflow-y-auto p-4 space-y-3">
<div>
<p className="text-xs text-[var(--fg-muted)] mb-1.5 font-medium">快捷预设(点一下自动填,只需补 API Key)</p>
<div className="flex flex-wrap gap-1.5">
{PRESETS.map((p) => (
<button
key={p.key}
onClick={() => applyPreset(p)}
className={`px-2.5 py-1 rounded-lg text-xs border transition-colors ${
form.label === p.label
? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]"
: "border-[var(--border)] text-[var(--fg-muted)] hover:bg-white/5 hover:text-[var(--fg)]"
}`}
title={p.hint}
>
{p.label}
</button>
))}
</div>
<div className="mt-1.5 text-xs text-[var(--fg-muted)]">
{PRESETS.find((p) => p.label === form.label)?.hint ?? "预设只是帮填字段;你也可以完全手动填写任意 OpenAI 兼容 API"}
</div>
</div>
<div>
<label className="text-xs text-[var(--fg-muted)] font-medium">显示名</label>
<input className={inputCls} value={form.label} onChange={(e) => setForm({ ...form, label: e.target.value })} placeholder="如 DeepSeek V3 / Qwen2.5-72B" />
</div>
<div>
<label className="text-xs text-[var(--fg-muted)] font-medium">类型</label>
<select className={inputCls} value={form.kind} onChange={(e) => setForm({ ...form, kind: e.target.value })}>
<option value="openai_compatible">OpenAI 兼容(OpenRouter / DeepSeek / 硅基流动等)</option>
<option value="anthropic">Anthropic(Claude)</option>
</select>
</div>
<div>
<label className="text-xs text-[var(--fg-muted)] font-medium">Base URL(openai_compatible 需要)</label>
<input className={inputCls} value={form.base_url} onChange={(e) => setForm({ ...form, base_url: e.target.value })} placeholder="https://openrouter.ai/api/v1" />
</div>
<div>
<label className="text-xs text-[var(--fg-muted)] font-medium">API Key(网页只存不显示)</label>
<input className={inputCls} type="password" value={form.api_key} onChange={(e) => setForm({ ...form, api_key: e.target.value })} placeholder={editingName ? "留空保持不变" : "sk-..."} autoComplete="off" />
</div>
<div>
<label className="text-xs text-[var(--fg-muted)] font-medium">模型 ID</label>
<input className={inputCls} value={form.model_id} onChange={(e) => setForm({ ...form, model_id: e.target.value })} placeholder="deepseek-chat / deepseek/deepseek-r1 / qwen2.5-72b-instruct" />
</div>
{saveMsg && <div className="text-xs text-[var(--danger)]">{saveMsg}</div>}
<div className="flex gap-2 pt-1">
<button
onClick={save}
disabled={saving}
className="flex-1 py-2 rounded-lg text-sm bg-[var(--accent)] text-white font-medium hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors flex items-center justify-center gap-1.5"
>
{saving ? <Loader2 size={15} className="animate-spin" /> : <Check size={15} />}
保存
</button>
<button
onClick={() => setMode("list")}
className="px-4 py-2 rounded-lg text-sm border border-[var(--border)] text-[var(--fg-muted)] hover:bg-white/5 transition-colors"
>
取消
</button>
</div>
</div>
)}
{/* 批量导入模式(任意 OpenAI 兼容平台) */}
{mode === "import" && (
<div className="flex-1 overflow-y-auto p-4 space-y-3">
<div>
<label className="text-xs text-[var(--fg-muted)] font-medium">平台</label>
<div className="flex flex-wrap gap-1.5 mt-1.5">
{IMPORT_PROVIDERS.map((p) => (
<button
key={p.label}
onClick={() => setImportBase(p.base_url)}
className={`px-2.5 py-1 rounded-lg text-xs border transition-colors ${
importBase === p.base_url
? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]"
: "border-[var(--border)] text-[var(--fg-muted)] hover:bg-white/5 hover:text-[var(--fg)]"
}`}
>
{p.label}
</button>
))}
</div>
<label className="text-xs text-[var(--fg-muted)] font-medium mt-3 block">Base URL</label>
<input
className={`${inputCls} mt-1`}
value={importBase}
onChange={(e) => setImportBase(e.target.value)}
placeholder="https://openrouter.ai/api/v1"
/>
<label className="text-xs text-[var(--fg-muted)] font-medium mt-3 block">API Key</label>
<div className="flex gap-2 mt-1">
<input
className={inputCls}
type="password"
value={importKey}
onChange={(e) => setImportKey(e.target.value)}
placeholder="sk-..."
autoComplete="off"
/>
<button
onClick={fetchModels}
disabled={fetching || !importBase.trim() || !importKey.trim()}
className="px-4 py-2 rounded-lg text-sm bg-[var(--accent)] text-white font-medium hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors shrink-0 flex items-center gap-1.5"
>
{fetching ? <Loader2 size={14} className="animate-spin" /> : <Search size={14} />}
拉取模型
</button>
</div>
{importMsg && <div className={`text-xs mt-1.5 ${importMsg.startsWith("❌") ? "text-[var(--danger)]" : "text-[var(--fg-muted)]"}`}>{importMsg}</div>}
</div>
{providerModels.length > 0 && (
<>
<div className="flex items-center gap-2">
<Search size={14} className="text-[var(--fg-muted)] shrink-0" />
<input
className={inputCls}
value={importSearch}
onChange={(e) => setImportSearch(e.target.value)}
placeholder={`搜索 ${providerModels.length} 个模型,如 deepseek / qwen / claude`}
/>
</div>
<div className="max-h-56 overflow-y-auto border border-[var(--border)] rounded-xl divide-y divide-[var(--border)]">
{filtered.map((m) => (
<button
key={m.id}
onClick={() => toggleSelect(m.id)}
className={`w-full flex items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${
selected.has(m.id) ? "bg-[var(--accent-soft)] text-[var(--fg)]" : "text-[var(--fg)] hover:bg-white/5"
}`}
>
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${
selected.has(m.id) ? "border-[var(--accent)] bg-[var(--accent)]" : "border-[var(--border)]"
}`}>
{selected.has(m.id) && <Check size={11} className="text-white" />}
</div>
<div className="min-w-0">
<div className="font-mono text-xs truncate">{m.id}</div>
<div className="text-xs text-[var(--fg-muted)] truncate">{m.name}</div>
</div>
</button>
))}
{filtered.length === 0 && <p className="px-3 py-4 text-xs text-[var(--fg-muted)] text-center">没有匹配的模型</p>}
</div>
<button
onClick={importSelected}
disabled={selected.size === 0 || saving}
className="w-full py-2 rounded-lg text-sm bg-[var(--accent)] text-white font-medium hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors flex items-center justify-center gap-1.5"
>
{saving ? <Loader2 size={15} className="animate-spin" /> : <Plus size={15} />}
导入已选({selected.size})个模型
</button>
</>
)}
</div>
)}
</div>
</div>
);
}
+183
View File
@@ -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<T>(path: string, options?: RequestInit): Promise<T> {
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<Book[]>("/books"),
getBook: (id: string) => request<BookDetail>(`/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<Chapter[]>(`/books/${bookId}/chapters`),
getSegments: (bookId: string, chapterId?: string) =>
request<Segment[]>(`/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<Book>;
},
deleteBook: (id: string) => request<{ deleted: string }>(`/books/${id}`, { method: "DELETE" }),
// Conversations
listConversations: (bookId?: string) =>
request<Conversation[]>(`/chat/conversations${bookId ? `?book_id=${bookId}` : ""}`),
createConversation: (bookId: string, model: string = "claude") =>
request<Conversation>("/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<Message[]>(`/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<Segment[]>(`/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<Conversation>(`/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<AiModelEntry[]>("/models"),
addModel: (data: { label: string; kind?: string; base_url?: string; api_key?: string; model_id: string }) =>
request<AiModelEntry>("/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<AiModelEntry>(`/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} 字`;
}
-26
View File
@@ -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;
}
-29
View File
@@ -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 (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}
-69
View File
@@ -1,69 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert h-5 w-[100px]"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the{" "}
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
page.tsx
</code>{" "}
file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert h-[14px] w-4"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={14}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}