feat: add BookMate AI reading workspace and full EPUB reader
@@ -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
|
||||
|
||||
@@ -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`、模型凭据、数据库、上传书籍和构建产物不纳入版本控制。
|
||||
|
||||
@@ -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=
|
||||
@@ -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}
|
||||
@@ -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 "",
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 ""
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 -->
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} 字`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||