feat: add BookMate AI reading workspace and full EPUB reader

This commit is contained in:
2026-10-24 03:12:20 +08:00
parent eaeb45fc85
commit c7b0cfb92b
60 changed files with 8996 additions and 191 deletions
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
## This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+17
View File
@@ -0,0 +1,17 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
devIndicators: false,
cacheComponents: true,
partialPrefetching: true,
turbopack: {
rules: {
"*.css": {
loaders: ["@tailwindcss/turbopack"],
as: "*.css",
},
},
},
};
export default nextConfig;
+10364
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"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-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",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.4.2",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "BookMate - AI 读书助手",
description: "上传 EPUB,和 AI 一起读书",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN" className="h-full">
<body className="h-full">{children}</body>
</html>
);
}
+899
View File
@@ -0,0 +1,899 @@
"use client";
import { useState, useEffect, useMemo, useRef } from "react";
import {
BookMarked, BookOpen, Bot, Check, ChevronDown, ChevronLeft, ChevronRight, Copy,
Download, GitBranch, Loader2, Paperclip, Plus, RotateCcw, Send, Settings2,
Sparkles, Trash2, Upload, User, X, ArrowUpRight, Library, Search, Menu, MessageSquare, ArrowLeft,
} from "lucide-react";
import { api, Book, Conversation, Message, Citation, AiModelEntry, formatCharCount } from "@/lib/api";
import MarkdownRenderer from "@/components/MarkdownRenderer";
import ModelManagerDialog from "@/components/ModelManagerDialog";
import CitationReaderDialog from "@/components/CitationReaderDialog";
import EpubReader from "@/components/EpubReader";
const SIDEBAR_MIN = 180;
const SIDEBAR_MAX = 480;
export default function Home() {
const [books, setBooks] = useState<Book[]>([]);
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
const [conversations, setConversations] = useState<Conversation[]>([]);
const [activeConv, setActiveConv] = useState<Conversation | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [streaming, setStreaming] = useState(false);
const [streamContent, setStreamContent] = useState("");
const [uploading, setUploading] = useState(false);
const [model, setModel] = useState("deepseek");
const [aiModels, setAiModels] = useState<AiModelEntry[]>([]);
const [pickerOpen, setPickerOpen] = useState(false);
const [managerOpen, setManagerOpen] = useState(false);
const [readerBook, setReaderBook] = useState<Book | null>(null);
const [search, setSearch] = useState("");
const [sidebarOpen, setSidebarOpen] = useState(false);
const [booksLoading, setBooksLoading] = useState(true);
const [booksError, setBooksError] = useState(false);
const chatEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [sidebarWidth, setSidebarWidth] = useState(256);
const sidebarDragRef = useRef<{ startX: number; startW: number } | null>(null);
// 多版本回答:每个 user 消息当前选中哪个版本的回答(key = user 消息 id)
const [activeVersions, setActiveVersions] = useState<Record<string, number>>({});
// 流式状态归属:null 表示新问题(渲染在末尾);有值表示重试某条 user 消息(渲染在该轮内)
const [streamTurnId, setStreamTurnId] = useState<string | null>(null);
const turns = useMemo(() => buildTurns(messages), [messages]);
const loadBooks = () => {
setBooksLoading(true);
setBooksError(false);
api.listBooks().then(setBooks).catch(() => setBooksError(true)).finally(() => setBooksLoading(false));
};
useEffect(() => {
api.listBooks().then(setBooks).catch(() => setBooksError(true)).finally(() => setBooksLoading(false));
}, []);
// 加载可用模型;默认选中第一个(通常是 deepseek)
useEffect(() => {
api.listModels().then((ms) => {
setAiModels(ms);
if (ms.length > 0) setModel((cur) => (ms.some((m) => m.name === cur) ? cur : ms[0].name));
}).catch(() => setAiModels([]));
}, []);
const currentModel = aiModels.find((m) => m.name === model) ?? aiModels[0];
// 恢复/保存侧栏宽度(localStorage)
useEffect(() => {
const saved = localStorage.getItem("bookmate.sidebarWidth");
const n = saved ? Number(saved) : 256;
if (Number.isFinite(n)) setSidebarWidth(Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, n)));
}, []);
useEffect(() => {
localStorage.setItem("bookmate.sidebarWidth", String(sidebarWidth));
}, [sidebarWidth]);
useEffect(() => {
if (selectedBook) api.listConversations(selectedBook.id).then(setConversations);
}, [selectedBook]);
useEffect(() => {
if (activeConv) api.getMessages(activeConv.id).then(setMessages);
}, [activeConv]);
useEffect(() => { chatEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, streamContent]);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const book = await api.uploadBook(file);
setBooks((prev) => [book, ...prev]);
setSelectedBook(book);
} catch (err: unknown) {
alert(err instanceof Error ? err.message : "Upload failed");
}
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const startChat = async (book: Book) => {
setSidebarOpen(false);
setSelectedBook(book);
setActiveConv(null);
setMessages([]);
const convs = await api.listConversations(book.id);
setConversations(convs);
if (convs.length > 0) {
setActiveConv(convs[0]);
} else {
const conv = await api.createConversation(book.id, model);
setConversations([conv]);
setActiveConv(conv);
}
};
const handleSend = async () => {
if (!input.trim() || streaming) return;
let conv = activeConv;
if (!conv && selectedBook) {
conv = await api.createConversation(selectedBook.id, model);
setConversations((prev) => [conv!, ...prev]);
setActiveConv(conv);
}
if (!conv) return;
const userContent = input;
setInput("");
const tempId = `temp-${Date.now()}`;
setMessages((prev) => [...prev, { id: tempId, conversation_id: conv!.id, parent_message_id: null, role: "user", content: userContent, citations: null, model, created_at: new Date().toISOString() }]);
setStreaming(true);
setStreamContent("");
setStreamTurnId(null);
let fullContent = "";
try {
const resp = await api.sendMessage(conv!.id, userContent, model);
const reader = resp.body?.getReader();
if (!reader) throw new Error("No reader");
const decoder = new TextDecoder();
let buffer = "";
let assistantId = "";
let finalCitations: Citation[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = JSON.parse(line.slice(6));
if (data.type === "token") {
fullContent += data.content;
setStreamContent(fullContent);
} else if (data.type === "done") {
assistantId = data.message_id;
finalCitations = data.citations || [];
} else if (data.type === "error") {
throw new Error(data.content);
}
}
}
setMessages((prev) => {
const filtered = prev.filter((m) => m.id !== tempId);
const userMsg: Message = { id: tempId, conversation_id: conv!.id, parent_message_id: null, role: "user", content: userContent, citations: null, model, created_at: new Date().toISOString() };
const aiMsg: Message = { id: assistantId, conversation_id: conv!.id, parent_message_id: null, role: "assistant", content: fullContent, citations: finalCitations, model, created_at: new Date().toISOString() };
return [...filtered, userMsg, aiMsg];
});
} catch (err: unknown) {
if (fullContent.trim()) {
// 输出被中断(流式错误/网络):保留已生成的部分,不吞掉内容
setMessages((prev) => [
...prev.filter((m) => m.id !== tempId),
{
id: `interrupted-${Date.now()}`, conversation_id: conv!.id, parent_message_id: null,
role: "assistant", content: fullContent, citations: null, model,
created_at: new Date().toISOString(),
},
]);
}
alert(err instanceof Error ? err.message : "Chat error");
}
setStreaming(false);
setStreamContent("");
};
const handleRetry = async (userMsg: Message) => {
if (streaming) return;
setStreaming(true);
setStreamContent("");
setStreamTurnId(userMsg.id);
let fullContent = "";
try {
const conv = activeConv!;
// replyTo = 被重试的用户消息:后端不再新建 user 消息,新回答挂为它的新版本
const resp = await api.sendMessage(conv.id, userMsg.content, userMsg.model || model, userMsg.id);
const reader = resp.body?.getReader();
if (!reader) throw new Error("No reader");
const decoder = new TextDecoder();
let buffer = "";
let assistantId = "";
let finalCitations: Citation[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = JSON.parse(line.slice(6));
if (data.type === "token") {
fullContent += data.content;
setStreamContent(fullContent);
} else if (data.type === "done") {
assistantId = data.message_id;
finalCitations = data.citations || [];
} else if (data.type === "error") {
throw new Error(data.content);
}
}
}
// 新版本生成完毕:追加为被重试 user 消息之下的一个新回答版本(parent 指向它)
const newIdx = buildTurns(messages).find((t) => t.user.id === userMsg.id)?.versions.length ?? 0;
setMessages((prev) => [...prev, {
id: assistantId, conversation_id: conv.id, parent_message_id: userMsg.id,
role: "assistant", content: fullContent, citations: finalCitations,
model: userMsg.model || model, created_at: new Date().toISOString(),
}]);
setActiveVersions((prev) => ({ ...prev, [userMsg.id]: newIdx }));
} catch (err: unknown) {
if (fullContent.trim()) {
// 重试生成被中断:保留已生成的部分作为该问题的版本,不吞掉内容
const newIdx = buildTurns(messages).find((t) => t.user.id === userMsg.id)?.versions.length ?? 0;
setMessages((prev) => [
...prev,
{
id: `interrupted-${Date.now()}`, conversation_id: activeConv!.id, parent_message_id: userMsg.id,
role: "assistant", content: fullContent, citations: null,
model: userMsg.model || model, created_at: new Date().toISOString(),
},
]);
setActiveVersions((prev) => ({ ...prev, [userMsg.id]: newIdx }));
}
alert(err instanceof Error ? err.message : "Retry failed");
}
setStreaming(false);
setStreamContent("");
setStreamTurnId(null);
};
const handleCopy = async (text: string) => {
await navigator.clipboard.writeText(text);
};
const handleDelete = async (msgId: string) => {
if (!confirm("删除这条消息?")) return;
try {
await api.deleteMessage(msgId);
setMessages((prev) => prev.filter((m) => m.id !== msgId));
} catch {
alert("删除失败");
}
};
const handleBranch = async (msgId: string) => {
if (!activeConv) return;
try {
const branch = await api.branchConversation(activeConv.id, msgId);
setConversations((prev) => [branch, ...prev]);
setActiveConv(branch);
} catch {
alert("创建分支失败");
}
};
const handleNewConversation = async () => {
if (!selectedBook) return;
try {
const conv = await api.createConversation(selectedBook.id, model);
setConversations((prev) => [conv, ...prev]);
setActiveConv(conv);
setMessages([]);
} catch {
alert("新建对话失败");
}
};
const handleDeleteConversation = async (convId: string) => {
if (!confirm("删除这个对话?")) return;
try {
await api.deleteConversation(convId);
setConversations((prev) => prev.filter((c) => c.id !== convId));
if (activeConv?.id === convId) {
setActiveConv(null);
setMessages([]);
}
} catch {
alert("删除失败");
}
};
const handleDeleteBook = async (book: Book) => {
if (!confirm(`删除《${book.title || book.filename}》?书、目录和对话都会被删除。`)) return;
try {
await api.deleteBook(book.id);
setBooks((prev) => prev.filter((b) => b.id !== book.id));
if (selectedBook?.id === book.id) {
setSelectedBook(null);
setActiveConv(null);
setConversations([]);
setMessages([]);
}
} catch {
alert("删除失败");
}
};
const handleExport = async () => {
if (!activeConv) return;
try {
const data = await api.exportConversation(activeConv.id);
const blob = new Blob([data.content], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${data.title || "conversation"}.md`;
a.click();
URL.revokeObjectURL(url);
} catch {
alert("导出失败");
}
};
const selectConv = (conv: Conversation) => {
setActiveConv(conv);
setMessages([]);
};
// 侧栏拖动调整宽度(pointer capture + window 监听,拖动期间禁选文本)
const onSidebarResizePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
e.preventDefault();
try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* ignore */ }
sidebarDragRef.current = { startX: e.clientX, startW: sidebarWidth };
document.body.classList.add("select-none", "cursor-col-resize");
const onMove = (ev: PointerEvent) => {
if (!sidebarDragRef.current) return;
const next = sidebarDragRef.current.startW + (ev.clientX - sidebarDragRef.current.startX);
const max = Math.min(SIDEBAR_MAX, window.innerWidth * 0.5);
setSidebarWidth(Math.max(SIDEBAR_MIN, Math.min(max, next)));
};
const onUp = () => {
sidebarDragRef.current = null;
document.body.classList.remove("select-none", "cursor-col-resize");
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
};
return (
<div className="app-shell flex h-full overflow-hidden">
{sidebarOpen && <button className="sidebar-scrim" aria-label="关闭导航" onClick={() => setSidebarOpen(false)} />}
<button className="mobile-menu" aria-label="打开导航" onClick={() => setSidebarOpen(true)}><Menu size={20} /></button>
{/* Sidebar */}
<aside className={`library-sidebar flex flex-col shrink-0 ${sidebarOpen ? "is-open" : ""}`} style={{ width: sidebarWidth }}>
<div className="brand flex items-center gap-3">
<div className="brand-mark flex items-center justify-center shrink-0">
<BookMarked size={16} className="text-white" />
</div>
<div className="min-w-0">
<h1 className="brand-name">BookMate</h1>
<p className="text-[11px] text-[var(--fg-muted)]">让阅读,多一种可能</p>
</div>
</div>
<div className="px-4 pt-2 pb-5">
<button className={`nav-library ${!selectedBook ? "active" : ""}`} onClick={() => { setSelectedBook(null); setSidebarOpen(false); }}><Library size={18} /> 我的书库 <span>{books.length}</span></button>
<input ref={fileInputRef} type="file" accept=".epub" onChange={handleUpload} className="hidden" />
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="upload-button w-full flex items-center justify-center gap-2"
>
{uploading ? <Loader2 size={15} className="animate-spin" /> : <Upload size={15} />}
{uploading ? "解析中..." : "导入书籍"}
</button>
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden px-2 pb-2">
<p className="text-[11px] text-[var(--fg-muted)] px-3 py-2 font-medium tracking-wider">书库</p>
{books.map((b) => (
<div key={b.id} className="flex items-center group">
<button
onClick={() => startChat(b)}
className={`flex-1 min-w-0 text-left px-3 py-2.5 rounded-xl transition-colors ${
selectedBook?.id === b.id ? "bg-[var(--accent-soft)]" : "hover:bg-[var(--bg-hover)]"
}`}
>
<div className="text-sm font-medium truncate text-[var(--fg)]">{b.title || b.filename}</div>
<div className="text-[11px] text-[var(--fg-muted)] mt-0.5 truncate">
{b.author || "未知作者"} · {formatCharCount(b.char_count)}
</div>
</button>
<ActionBtn danger title="删除书籍" onClick={() => handleDeleteBook(b)}>
<Trash2 size={15} />
</ActionBtn>
</div>
))}
{books.length === 0 && (
<div className="flex flex-col items-center text-center p-6 text-[var(--fg-muted)]">
<BookOpen size={28} strokeWidth={1.5} className="mb-2 opacity-60" />
<p className="text-xs">上传 EPUB 开始阅读</p>
</div>
)}
</div>
<div className="sidebar-footer">
<div className="reading-note"><Sparkles size={15} /><span>好问题,让一本书更厚。</span></div>
<button onClick={() => setManagerOpen(true)}><Settings2 size={17} /><span>模型与设置</span><ChevronRight size={14} /></button>
<span className="sidebar-edition">BOOKMATE · YOUR READING SPACE</span>
</div>
</aside>
{/* 可拖动分隔条:调整书库侧栏宽度 */}
<div
role="separator"
aria-orientation="vertical"
aria-label="拖动调整书库宽度"
title="拖动调整书库宽度"
onPointerDown={onSidebarResizePointerDown}
className="sidebar-resizer w-1 shrink-0 cursor-col-resize touch-none select-none bg-[var(--border)] hover:bg-[var(--accent)] active:bg-[var(--accent)] transition-colors"
/>
{/* Main */}
<main className="main-panel flex-1 flex flex-col min-w-0">
{selectedBook ? (
<>
<header className="reader-header border-b border-[var(--border)] flex items-center gap-3 shrink-0">
<button className="back-library" onClick={() => setSelectedBook(null)} aria-label="返回书库"><ArrowLeft size={18} /></button>
<div className="flex-1 min-w-0">
<h2 className="text-[15px] font-semibold truncate text-[var(--fg)]">{selectedBook.title || selectedBook.filename}</h2>
<p className="text-[11px] text-[var(--fg-muted)]">{selectedBook.author}</p>
</div>
<button
onClick={() => setReaderBook(selectedBook)}
className="open-full-book"
title="打开完整 EPUB"
>
<BookOpen size={16} />
<span>阅读全书</span>
</button>
<button
onClick={handleExport}
disabled={!activeConv}
className="text-xs px-3 py-1.5 rounded-lg hover:bg-[var(--bg-hover)] disabled:opacity-50 transition-colors flex items-center gap-1.5 text-[var(--fg-muted)] hover:text-[var(--fg)]"
title="导出对话"
>
<Download size={13} />
导出
</button>
</header>
<div className="flex-1 flex min-h-0">
<div className="conversation-sidebar overflow-y-auto overflow-x-hidden py-3 shrink-0">
<p className="text-[11px] text-[var(--fg-muted)] px-3 py-2 font-medium tracking-wider">对话历史</p>
<button
onClick={handleNewConversation}
disabled={!selectedBook}
className="w-full mb-1 px-3 py-2 rounded-xl text-xs text-[var(--fg-muted)] hover:text-[var(--fg)] hover:bg-[var(--bg-hover)] disabled:opacity-50 transition-colors flex items-center justify-center gap-1.5"
>
<Plus size={12} />
新建对话
</button>
{conversations.map((c) => (
<div key={c.id} className="flex items-center group">
<button
onClick={() => selectConv(c)}
className={`flex-1 min-w-0 text-left px-3 py-2 rounded-xl text-[13px] truncate text-[var(--fg)] transition-colors ${
activeConv?.id === c.id ? "bg-[var(--accent-soft)]" : "hover:bg-[var(--bg-hover)]"
}`}
>
{c.title || "对话"}
</button>
<ActionBtn danger title="删除对话" onClick={() => handleDeleteConversation(c.id)}>
<X size={14} />
</ActionBtn>
</div>
))}
</div>
<div className="flex-1 flex flex-col min-w-0">
<div className="flex-1 overflow-y-auto px-6 pt-6 pb-10 space-y-6">
<div className="max-w-3xl mx-auto w-full space-y-6">
{turns.length === 0 && !streaming && (
<div className="chat-welcome">
<div className="chat-welcome-icon"><BookOpen size={30} strokeWidth={1.4} /></div>
<span className="eyebrow">READ · THINK · DISCOVER</span>
<h2>从一个好问题开始。</h2>
<p>关于《{selectedBook.title || selectedBook.filename}》,你想了解什么?</p>
<div className="prompt-grid">
{[{ icon: BookOpen, title: "读懂这本书", prompt: "请梳理这本书的核心观点与整体结构。" }, { icon: MessageSquare, title: "深入一个观点", prompt: "这本书中有哪些值得深入思考的观点?请结合原文展开。" }, { icon: GitBranch, title: "连接知识", prompt: "请整理这本书中重要概念之间的联系。" }].map(({ icon: Icon, title, prompt }) => (
<button key={title} onClick={() => setInput(prompt)}><Icon size={19} /><strong>{title}</strong><span>{prompt}</span><ArrowUpRight size={15} /></button>
))}
</div>
</div>
)}
{turns.map((turn) => {
const isLastTurn = turn.user.id === turns[turns.length - 1]?.user.id;
const versions = [...turn.versions];
const activeIdx = activeVersions[turn.user.id] ?? versions.length - 1;
const shown = versions[activeIdx] ?? null;
const streamingHere = streaming && streamTurnId === turn.user.id && !!streamContent;
return (
<div key={turn.user.id} className="space-y-1.5">
<ChatBubble
message={turn.user}
onCopy={handleCopy}
onRetry={isLastTurn ? handleRetry : undefined}
/>
{versions.length > 1 && (
<div className="flex items-center gap-1 pl-10">
<button
type="button"
onClick={() => setActiveVersions((prev) => ({ ...prev, [turn.user.id]: activeIdx - 1 }))}
disabled={activeIdx <= 0}
className="p-1 rounded-md text-[var(--fg-muted)] hover:bg-white/10 hover:text-[var(--fg)] disabled:opacity-30 transition-colors"
title="上一个版本"
>
<ChevronLeft size={14} />
</button>
<span className="px-2 py-0.5 rounded-md bg-white/5 text-xs text-[var(--fg-muted)]">
版本 {activeIdx + 1} / {versions.length}
</span>
<button
type="button"
onClick={() => setActiveVersions((prev) => ({ ...prev, [turn.user.id]: activeIdx + 1 }))}
disabled={activeIdx >= versions.length - 1}
className="p-1 rounded-md text-[var(--fg-muted)] hover:bg-white/10 hover:text-[var(--fg)] disabled:opacity-30 transition-colors"
title="下一个版本"
>
<ChevronRight size={14} />
</button>
</div>
)}
{shown && (
<ChatBubble
key={shown.id}
message={shown}
onCopy={handleCopy}
onDelete={handleDelete}
onBranch={handleBranch}
/>
)}
{streamingHere && (
<ChatBubble
message={{ id: "stream", conversation_id: "", parent_message_id: turn.user.id, role: "assistant", content: streamContent, citations: null, model, created_at: "" }}
isStreaming={true}
/>
)}
</div>
);
})}
{streaming && !streamTurnId && streamContent && (
<ChatBubble
message={{ id: "stream", conversation_id: "", parent_message_id: null, role: "assistant", content: streamContent, citations: null, model, created_at: "" }}
isStreaming={true}
/>
)}
<div ref={chatEndRef} />
</div>
</div>
<div className="composer-area px-4 pt-3 pb-4">
{/* 输入胶囊:模型选择 + 输入合并(Perplexity 风) */}
<div className="max-w-3xl mx-auto">
<div className="composer rounded-2xl px-4 py-3">
<div className="flex items-center justify-between mb-1">
<div className="relative">
{pickerOpen && <div className="fixed inset-0 z-10" onClick={() => setPickerOpen(false)} />}
<button
onClick={() => setPickerOpen((v) => !v)}
className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-md bg-white/5 text-[var(--fg-muted)] hover:text-[var(--fg)] hover:bg-white/10 transition-colors"
title={currentModel ? `${currentModel.model_id} · ${currentModel.base_url || currentModel.kind}` : (aiModels.length ? "" : "尚未配置模型")}
>
<Sparkles size={12} className="text-[var(--accent)]" />
<span className="max-w-40 truncate">{currentModel?.label ?? (aiModels.length ? model : "未配置模型")}</span>
<ChevronDown size={12} />
</button>
{pickerOpen && (
<div className="absolute z-20 bottom-full mb-1.5 left-0 w-64 rounded-xl border border-[var(--border)] bg-[var(--bg-card)] shadow-2xl p-1.5 max-h-72 overflow-y-auto">
<p className="text-[10px] text-[var(--fg-muted)] px-2 pt-1 pb-1.5 font-medium tracking-wider">选择回答模型</p>
{aiModels.map((m) => (
<button
key={m.name}
onClick={() => { setModel(m.name); setPickerOpen(false); }}
className={`w-full flex items-center justify-between gap-2 px-2.5 py-2 rounded-lg text-left text-sm transition-colors ${
model === m.name ? "bg-[var(--accent-soft)] text-[var(--accent)]" : "text-[var(--fg)] hover:bg-[var(--bg-hover)]"
}`}
>
<span className="truncate">{m.label}</span>
<span className="text-[10px] text-[var(--fg-muted)] shrink-0">{m.builtin ? "内置" : "自定义"}</span>
</button>
))}
{aiModels.length === 0 && (
<p className="px-2 py-3 text-xs text-[var(--fg-muted)] text-center">尚未配置模型,点「管理」添加</p>
)}
</div>
)}
</div>
<button
onClick={() => setManagerOpen(true)}
className="text-xs px-2 py-1 rounded-md text-[var(--fg-muted)] hover:text-[var(--fg)] hover:bg-white/5 transition-colors flex items-center gap-1"
>
<Settings2 size={12} />
管理模型
</button>
</div>
<div className="flex items-center gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing && handleSend()}
placeholder={selectedBook ? "问这本书的任何问题..." : "先选择一本书"}
disabled={!selectedBook || streaming}
className="flex-1 bg-transparent px-1 py-1.5 text-sm text-[var(--fg)] placeholder-[var(--fg-muted)] focus:outline-none disabled:opacity-50"
/>
<button
onClick={handleSend}
disabled={!input.trim() || streaming}
className="p-2 rounded-xl bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-40 disabled:hover:bg-[var(--accent)] transition-colors shrink-0"
title="发送"
>
{streaming ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</>
) : (
<div className="library-page">
<header className="library-topbar"><span>我的阅读空间 <span className="breadcrumb-slash">/</span> <strong>书库</strong></span><span className="topbar-note"><span /> 与好书,慢慢相处</span></header>
<div className="library-content">
<section className="library-hero">
<div className="hero-copy"><span className="eyebrow">A LITTLE READING, A LITTLE MORE WORLD</span><h1>每一本书,<br />都值得<span>再读深一点。</span></h1><p>收藏你的好书,与 AI 一起追问、思考,<br className="desktop-break" />让读过的每一页,都成为自己的收获。</p><button className="hero-button" onClick={() => fileInputRef.current?.click()} disabled={uploading}>{uploading ? <Loader2 size={17} className="animate-spin" /> : <Plus size={17} />}{uploading ? "正在导入…" : "添加一本好书"}<ArrowUpRight size={17} /></button></div>
<div className="hero-art" aria-hidden="true"><div className="orbit orbit-one" /><div className="orbit orbit-two" /><span className="art-star">✳</span><div className="art-book art-book-back"><span>THINK<br />BEYOND<br />THE PAGE.</span></div><div className="art-book art-book-front"><BookOpen size={28} strokeWidth={1} /><span>书中有光</span><small>A WORLD WITHIN</small><div className="book-line" /></div><div className="art-caption">Open a book. Open a world.</div></div>
</section>
<section className="books-section" aria-labelledby="books-heading">
<div className="books-toolbar"><div><h2 id="books-heading">我的书库 <span>{books.length}</span></h2><p>在这里,开启下一次思想漫游。</p></div><label className="book-search"><Search size={17} /><input aria-label="搜索书名或作者" placeholder="搜索书名、作者…" value={search} onChange={(e) => setSearch(e.target.value)} />{search && <button aria-label="清除搜索" onClick={() => setSearch("")}><X size={14} /></button>}</label></div>
{booksLoading ? <div className="library-state"><Loader2 size={23} className="animate-spin" /><p>正在整理你的书库…</p></div> : booksError ? <div className="library-state"><BookOpen size={26} /><h3>暂时无法加载书库</h3><p>请确认阅读服务已启动,然后再试一次。</p><button className="text-link" onClick={loadBooks}>重新加载 <RotateCcw size={14} /></button></div> : (
<div className="book-grid">
{books.filter((b) => `${b.title || b.filename} ${b.author || ""}`.toLowerCase().includes(search.toLowerCase())).map((book, i) => (
<article key={book.id} className="book-card group">
<button className="book-card-open" onClick={() => startChat(book)} aria-label={`阅读 ${book.title || book.filename}`}>
<div className={`book-stage cover-tone-${i % 4}`}><div className="book-cover"><span className="cover-label">BOOKMATE LIBRARY</span><h3>{book.title || book.filename}</h3><span className="cover-author">{book.author || "佚名"}</span><div className="cover-motif"><span /><span /><span /></div><BookOpen size={18} strokeWidth={1.2} /></div><span className="book-open-label">开始共读 <ArrowUpRight size={15} /></span></div>
<div className="book-card-info"><h3>{book.title || book.filename}</h3><p>{book.author || "未知作者"}</p><span>EPUB <i /> {formatCharCount(book.char_count)}</span></div>
</button>
<button className="book-card-read" onClick={() => setReaderBook(book)}><BookOpen size={14} /> 阅读全书</button>
<div className="book-card-delete"><ActionBtn danger title="删除书籍" onClick={() => handleDeleteBook(book)}><Trash2 size={15} /></ActionBtn></div>
</article>
))}
{!search && <button className="add-book-card" onClick={() => fileInputRef.current?.click()} disabled={uploading}><span><Plus size={24} strokeWidth={1.3} /></span><strong>{uploading ? "正在导入…" : "给书库添一本书"}</strong><p>导入 EPUB,开始和好书对话</p><small>每一次翻开,都有新的发现</small></button>}
{search && !books.some((b) => `${b.title || b.filename} ${b.author || ""}`.toLowerCase().includes(search.toLowerCase())) && <div className="library-state no-results"><Search size={25} /><h3>没有找到相关书籍</h3><p>换个书名或作者试试。</p></div>}
</div>
)}
</section>
<footer className="library-bottom"><BookOpen size={14} /><span>阅读的意义,藏在每一次思考里。</span><span>MADE FOR CURIOUS MINDS</span></footer>
</div>
</div>
)}
</main>
{readerBook && (
<EpubReader
key={readerBook.id}
book={readerBook}
onClose={() => setReaderBook(null)}
onAsk={async (text) => {
if (streaming) {
alert("AI 正在回答,请稍后再解读这段原文。");
return;
}
try {
if (selectedBook?.id !== readerBook.id || !activeConv) await startChat(readerBook);
setInput(text);
setReaderBook(null);
} catch {
alert("暂时无法打开对话,请稍后重试。");
}
}}
/>
)}
<ModelManagerDialog
open={managerOpen}
onClose={() => setManagerOpen(false)}
models={aiModels}
onChanged={() => api.listModels().then(setAiModels)}
/>
</div>
);
}
function ChatBubble({
message,
isStreaming,
onCopy,
onDelete,
onBranch,
onRetry,
}: {
message: Message;
isStreaming?: boolean;
onCopy?: (text: string) => void;
onDelete?: (id: string) => void;
onBranch?: (id: string) => void;
onRetry?: (msg: Message) => void;
}) {
const [copied, setCopied] = useState(false);
const [citationsOpen, setCitationsOpen] = useState(false);
const [readerSegmentId, setReaderSegmentId] = useState<string | null>(null);
const isUser = message.role === "user";
const isStream = isStreaming || message.id === "stream";
// 同一段落被 AI 重复引用时只显示一条(引用渲染去重,不影响已注入的检索内容)
const uniqCitations = useMemo(() => {
const seen = new Set<string>();
return (message.citations ?? []).filter((c) => {
if (seen.has(c.segment_id)) return false;
seen.add(c.segment_id);
return true;
});
}, [message.citations]);
const handleCopy = () => {
onCopy?.(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<>
<div className={`flex group ${isUser ? "justify-end" : "justify-start"}`}>
<div className={`flex items-end gap-1.5 max-w-[82%] ${isUser ? "flex-row-reverse" : "flex-row"}`}>
<div
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 mb-1 ${
isUser
? "bg-[var(--accent)] text-white"
: "bg-[var(--accent-soft)] text-[var(--accent)]"
}`}
>
{isUser ? <User size={12} /> : <Bot size={12} />}
</div>
<div className="min-w-0">
<div
className={`${
isUser
? "rounded-2xl rounded-br-md px-4 py-2.5 text-[15px] leading-relaxed bg-[var(--accent)] text-white shadow-sm"
: "px-0.5 py-0.5 text-[15px] leading-[1.75] text-[var(--fg)]"
} ${isStream ? "streaming-answer" : ""}`}
>
<MarkdownRenderer content={message.content} variant={isUser ? "dark" : "light"} />
{uniqCitations.length > 0 && (
<div className={`mt-3 pt-2 border-t ${isUser ? "border-white/25" : "border-[var(--border)]"}`}>
<button
type="button"
onClick={() => setCitationsOpen((v) => !v)}
className={`text-xs font-medium flex items-center gap-1 transition-colors ${
isUser ? "text-white/80" : "text-[var(--fg-muted)] hover:text-[var(--fg)]"
}`}
>
<Paperclip size={11} />
引用来源({uniqCitations.length})
{uniqCitations.length < (message.citations?.length ?? 0) && (
<span className="opacity-70">已合并重复</span>
)}
<ChevronDown size={11} className={`transition-transform ${citationsOpen ? "rotate-180" : ""}`} />
</button>
{citationsOpen && (
<div className="mt-2 space-y-1.5">
{uniqCitations.map((c, i) => (
<div
key={i}
className={`text-xs py-1.5 px-2 rounded flex items-start gap-2 ${
isUser ? "text-white/85 bg-white/10" : "text-[var(--fg-muted)] bg-[var(--bg)]"
}`}
>
<span className={`font-medium shrink-0 ${isUser ? "text-white" : "text-[var(--accent)]"}`}>[{i + 1}]</span>
<span className="flex-1 min-w-0 leading-relaxed">
{(c.excerpt || "").replace(/《heading_path》\s*/g, "").slice(0, 80)}{(c.excerpt || "").length > 80 ? "…" : ""}
</span>
<button
type="button"
onClick={() => setReaderSegmentId(c.segment_id)}
className="shrink-0 flex items-center gap-0.5 font-medium text-[var(--accent-hover)] hover:underline mt-0.5"
title="阅读该段原文"
>
<BookOpen size={11} />
原文
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
{!isStream && (
<div className={`flex gap-0.5 mt-1 px-1 opacity-0 group-hover:opacity-100 transition-opacity ${isUser ? "justify-end" : "justify-start"}`}>
<ActionBtn title="复制" onClick={handleCopy}>
{copied ? <Check size={15} className="text-[var(--success)]" /> : <Copy size={15} />}
</ActionBtn>
{onRetry && (
<ActionBtn title="重新回答" onClick={() => onRetry(message)}>
<RotateCcw size={15} />
</ActionBtn>
)}
{onBranch && (
<ActionBtn title="从此分支" onClick={() => onBranch(message.id)}>
<GitBranch size={15} />
</ActionBtn>
)}
{onDelete && (
<ActionBtn title="删除" onClick={() => onDelete(message.id)}>
<Trash2 size={15} />
</ActionBtn>
)}
</div>
)}
</div>
</div>
</div>
<CitationReaderDialog
open={!!readerSegmentId}
segmentId={readerSegmentId}
onClose={() => setReaderSegmentId(null)}
/>
</>
);
}
interface Turn {
user: Message;
versions: Message[];
}
/**
* 把平铺的消息流聚合成「回合」列表:每个 user 消息 + 它名下的所有 AI 回答版本。
* assistant 按 parent_message_id 归属;旧数据 parent 为 null 时,挂到它之前最近的 user。
*/
function buildTurns(msgs: Message[]): Turn[] {
const turns: Turn[] = [];
const byUser = new Map<string, Turn>();
let lastTurn: Turn | null = null;
for (const m of msgs) {
if (m.role === "user") {
lastTurn = { user: m, versions: [] };
turns.push(lastTurn);
byUser.set(m.id, lastTurn);
} else if (m.role === "assistant") {
const target = (m.parent_message_id && byUser.get(m.parent_message_id)) || lastTurn;
if (target) target.versions.push(m);
}
}
return turns;
}
function ActionBtn({ title, onClick, children, danger }: { title: string; onClick: () => void; children: React.ReactNode; danger?: boolean }) {
return (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onClick(); }}
className={`w-7 h-7 flex items-center justify-center rounded-lg shrink-0 touch-manipulation opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity ${
danger
? "text-[var(--fg-muted)] hover:text-[var(--danger)] hover:bg-red-500/10 active:bg-red-500/15"
: "text-[var(--fg-muted)] hover:bg-white/10 hover:text-[var(--fg)] active:bg-white/10"
}`}
title={title}
aria-label={title}
>
{children}
</button>
);
}
@@ -0,0 +1,112 @@
"use client";
import { useEffect, useState } from "react";
import { BookOpen, Loader2, X } from "lucide-react";
import { api } from "@/lib/api";
interface SegmentCtx {
segment: { id: string; text: string; order_idx: number; cfi: string | null };
chapter: { id: string; title: string | null; level: number } | null;
before: { id: string; text: string; order_idx: number; cfi: string | null }[];
after: { id: string; text: string; order_idx: number; cfi: string | null }[];
}
function cleanText(text: string): string {
// 解析阶段写入的《heading_path》占位不影响阅读,展示前清掉
return text.replace(/《heading_path》\s*/g, "");
}
function Para({ text, muted }: { text: string; muted?: boolean }) {
return (
<p className={`text-sm leading-relaxed ${muted ? "text-[var(--fg-muted)] text-[13px]" : "text-[var(--fg)]"}`}>
{cleanText(text)}
</p>
);
}
export default function CitationReaderDialog({
open,
segmentId,
onClose,
}: {
open: boolean;
segmentId: string | null;
onClose: () => void;
}) {
const [data, setData] = useState<SegmentCtx | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open || !segmentId) return;
setLoading(true);
setError(null);
setData(null);
api
.getSegmentContext(segmentId)
.then(setData)
.catch((e) => setError(e instanceof Error ? e.message : "加载失败"))
.finally(() => setLoading(false));
}, [open, segmentId]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
<div
className="bg-[var(--bg-card)] border border-[var(--border)] rounded-2xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* 头部 */}
<div className="flex items-center gap-2 px-5 py-3.5 border-b border-[var(--border)] shrink-0">
<BookOpen size={16} className="text-[var(--accent)] shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-[var(--fg)] truncate">
{data?.chapter?.title || "阅读原文"}
</p>
{data && (
<p className="text-[11px] text-[var(--fg-muted)]">
第 {data.segment.order_idx + 1} 段
{data.segment.cfi ? " · EPUB 内定位可用" : ""}
</p>
)}
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-[var(--fg-muted)] hover:bg-white/10 hover:text-[var(--fg)] transition-colors"
title="关闭"
>
<X size={16} />
</button>
</div>
{/* 正文 */}
<div className="flex-1 overflow-y-auto p-5 space-y-3">
{loading && (
<div className="flex items-center justify-center gap-2 py-10 text-[var(--fg-muted)] text-sm">
<Loader2 size={16} className="animate-spin" /> 加载原文…
</div>
)}
{error && <p className="text-sm text-[var(--danger)] py-6 text-center">{error}</p>}
{data && (
<>
<div className="space-y-3 text-[var(--fg-muted)]">
{data.before.map((p) => (
<Para key={p.id} text={p.text} muted />
))}
</div>
<div className="rounded-xl border border-[var(--accent)] bg-[var(--accent-soft)] px-4 py-3">
<Para text={data.segment.text} />
</div>
<div className="space-y-3 text-[var(--fg-muted)]">
{data.after.map((p) => (
<Para key={p.id} text={p.text} muted />
))}
</div>
</>
)}
</div>
</div>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { useEffect, useState } from "react";
/**
* Server-side-safe syntax highlighting: shiki is loaded lazily inside an
* effect (so SSR renders the plain fallback), and unknown languages degrade
* to a readable <pre> instead of throwing.
*/
export default function CodeBlock({ code, language }: { code: string; language?: string }) {
const [html, setHtml] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const lang = language || "text";
import("shiki/bundle/web")
.then(async ({ codeToHtml }) => {
const out = await codeToHtml(code, { lang, theme: "github-dark" });
if (!cancelled) setHtml(out);
})
.catch(() => {
// Unknown language or loader failure: keep the plain fallback.
});
return () => {
cancelled = true;
};
}, [code, language]);
if (html) {
return (
<div className="my-2 overflow-x-auto rounded-lg text-[0.85em]" dangerouslySetInnerHTML={{ __html: html }} />
);
}
return (
<pre className="my-2 p-3 rounded-lg text-[0.85em] font-mono overflow-x-auto bg-[#0d1117] text-[#e6edf3]">
<code>{code}</code>
</pre>
);
}
+195
View File
@@ -0,0 +1,195 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { Book as EpubBook, Rendition, Location, NavItem, Contents } from "epubjs";
import type Section from "epubjs/types/section";
import { ArrowLeft, BookOpen, ChevronLeft, ChevronRight, List, Loader2, Minus, Plus, RotateCcw, MessageSquare } from "lucide-react";
import { api, type Book } from "@/lib/api";
interface TocEntry { href: string; label: string; depth: number }
function flattenToc(items: NavItem[], depth = 0): TocEntry[] {
return items.flatMap((item) => [
{ href: item.href, label: item.label.trim(), depth },
...flattenToc(item.subitems || [], depth + 1),
]);
}
function readSaved(key: string) {
try { return localStorage.getItem(key); } catch { return null; }
}
function save(key: string, value: string) {
try { localStorage.setItem(key, value); } catch { /* Reading works without storage. */ }
}
export default function EpubReader({ book, onClose, onAsk }: {
book: Book;
onClose: () => void;
onAsk: (text: string) => void;
}) {
const dialogRef = useRef<HTMLDivElement>(null);
const viewerRef = useRef<HTMLDivElement>(null);
const renditionRef = useRef<Rendition | null>(null);
const closeRef = useRef(onClose);
const [toc, setToc] = useState<TocEntry[]>([]);
const [tocOpen, setTocOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [attempt, setAttempt] = useState(0);
const [fontSize, setFontSize] = useState(18);
const [location, setLocation] = useState<Location | null>(null);
const [selectedText, setSelectedText] = useState("");
const [turning, setTurning] = useState(false);
const positionKey = `bookmate.reader.${book.id}`;
useEffect(() => { closeRef.current = onClose; }, [onClose]);
useEffect(() => {
const previousFocus = document.activeElement as HTMLElement | null;
dialogRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
const keydown = (event: KeyboardEvent) => {
if (event.key === "Escape") { event.preventDefault(); closeRef.current(); }
if (event.key !== "Tab") return;
const controls = dialogRef.current?.querySelectorAll<HTMLElement>('button:not(:disabled), [href], input, iframe, [tabindex="0"]');
if (!controls?.length) return;
const first = controls[0], last = controls[controls.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
};
document.addEventListener("keydown", keydown);
return () => { document.removeEventListener("keydown", keydown); previousFocus?.focus(); };
}, []);
useEffect(() => {
let disposed = false;
let epub: EpubBook | undefined;
let observer: ResizeObserver | undefined;
const controller = new AbortController();
const font = Number(readSaved("bookmate.reader.fontSize")) || 18;
const initialFont = Math.max(14, Math.min(28, font));
const open = async () => {
try {
const [{ Book: Epub }, data] = await Promise.all([
import("epubjs"), api.getBookFile(book.id, controller.signal),
]);
if (disposed || !viewerRef.current) return;
epub = new Epub({ replacements: "blobUrl" });
// Register handlers for the promises epub.js rejects alongside open().
void epub.opened.catch(() => {});
void epub.ready.catch(() => {});
await epub.open(data, "binary");
if (disposed) return;
const navigation = await epub.loaded.navigation;
if (disposed) return;
const entries = flattenToc(navigation.toc);
if (!entries.length) {
epub.spine.each((section: Section) => {
if (section.linear) entries.push({ href: section.href, label: `章节 ${entries.length + 1}`, depth: 0 });
});
}
setToc(entries);
setFontSize(initialFont);
const host = viewerRef.current!;
const rendition = epub.renderTo(host, {
width: host.clientWidth, height: host.clientHeight,
spread: "none", flow: "paginated", allowScriptedContent: false,
});
renditionRef.current = rendition;
rendition.themes.default({
body: { color: "#243b33", background: "#fffdf8", "line-height": "1.9 !important", "font-family": 'Georgia, "Songti SC", "SimSun", serif' },
"img, svg": { "max-width": "100% !important", "max-height": "90vh !important" },
a: { color: "#31594a" },
});
rendition.themes.fontSize(`${initialFont}px`);
rendition.on("relocated", (next: Location) => {
if (disposed) return;
setLocation(next);
setSelectedText("");
if (next.start.cfi) save(positionKey, next.start.cfi);
});
rendition.on("selected", (cfi: string, contents: Contents) => {
if (disposed) return;
try { setSelectedText(contents.range(cfi).toString().trim().slice(0, 4000)); } catch { /* No selection. */ }
});
rendition.on("keyup", (event: KeyboardEvent) => {
if (event.key === "Escape") closeRef.current();
});
rendition.on("displayError", () => {
if (!disposed) setError("这一页未能显示,请重试或从目录选择其他章节。");
});
const saved = readSaved(positionKey);
try { await rendition.display(saved || undefined); }
catch {
if (!saved) throw new Error("无法显示书籍正文");
await rendition.display();
}
if (disposed) return;
setError("");
setLoading(false);
observer = new ResizeObserver(([entry]) => {
if (!disposed && entry.contentRect.width > 0 && entry.contentRect.height > 0) {
rendition.resize(entry.contentRect.width, entry.contentRect.height);
}
});
observer.observe(host);
} catch (err) {
if (!disposed) { setError(err instanceof Error ? err.message : "书籍打开失败"); setLoading(false); }
}
};
void open();
return () => {
disposed = true;
controller.abort();
observer?.disconnect();
renditionRef.current = null;
epub?.destroy();
};
}, [book.id, attempt, positionKey]);
const navigate = async (target: "prev" | "next" | string) => {
const rendition = renditionRef.current;
if (!rendition || turning || loading) return;
setTurning(true);
setError("");
try {
if (target === "prev") await rendition.prev();
else if (target === "next") await rendition.next();
else { await rendition.display(target); setTocOpen(false); }
} catch { setError("这一页未能显示,请重试或从目录选择其他章节。"); }
finally { setTurning(false); }
};
const changeFont = (delta: number) => {
const next = Math.max(14, Math.min(28, fontSize + delta));
setFontSize(next);
save("bookmate.reader.fontSize", String(next));
renditionRef.current?.themes.fontSize(`${next}px`);
};
const currentHref = location?.start.href.split("#")[0];
const currentChapter = toc.find((entry) => entry.href === location?.start.href)
|| toc.find((entry) => entry.href.split("#")[0] === currentHref);
return (
<div ref={dialogRef} className="epub-reader" role="dialog" aria-modal="true" aria-label={`阅读《${book.title || book.filename}》`}>
<header className="epub-toolbar">
<button onClick={onClose} className="epub-return"><ArrowLeft size={18} /><span>返回</span></button>
<div className="epub-book-title"><strong>{book.title || book.filename}</strong><small>{book.author || "完整 EPUB 阅读"}</small></div>
<button aria-label="切换目录" aria-expanded={tocOpen} onClick={() => setTocOpen(!tocOpen)}><List size={19} /><span>目录</span></button>
<div className="epub-font-controls"><button aria-label="缩小字号" disabled={fontSize <= 14 || loading} onClick={() => changeFont(-2)}><Minus size={16} /></button><span>{fontSize}</span><button aria-label="增大字号" disabled={fontSize >= 28 || loading} onClick={() => changeFont(2)}><Plus size={16} /></button></div>
</header>
<div className="epub-body">
{tocOpen && <nav className="epub-toc" aria-label="全书目录"><h3><BookOpen size={16} /> 全书目录</h3>{toc.length ? toc.map((entry, index) => (
<button key={`${entry.href}-${index}`} aria-current={currentChapter === entry ? "location" : undefined} style={{ paddingLeft: 16 + Math.min(entry.depth, 6) * 12 }} onClick={() => void navigate(entry.href)} disabled={loading || turning}>{entry.label || "未命名章节"}</button>
)) : <p>{loading ? "正在加载目录…" : "此书没有可用目录"}</p>}</nav>}
<div className="epub-paper">
{loading && <div className="epub-loading" role="status"><Loader2 className="animate-spin" size={28} /><p>正在翻开这本书…</p></div>}
{error && <div className="epub-error" role="alert"><p>{error}</p><button onClick={() => { setError(""); setLoading(true); setAttempt((value) => value + 1); }}><RotateCcw size={15} />重新打开</button></div>}
<div ref={viewerRef} className="epub-viewport" aria-label="书籍正文" />
</div>
</div>
{selectedText && <div className="epub-selection"><span>已选中 {selectedText.length} 字</span><button onClick={() => onAsk(`请结合《${book.title || book.filename}》解释这段原文:\n\n「${selectedText}」`)}><MessageSquare size={15} /> 交给 AI 解读</button></div>}
<footer className="epub-footer">
<button onClick={() => void navigate("prev")} disabled={loading || turning || !location || location.atStart}><ChevronLeft size={18} /><span>上一页</span></button>
<div aria-live="polite"><strong>{currentChapter?.label || "阅读原文"}</strong><small>{location ? `本节 ${location.start.displayed.page} / ${location.start.displayed.total} 页` : "保留原书正文、图片与目录"} · 自动记住阅读位置</small></div>
<button onClick={() => void navigate("next")} disabled={loading || turning || !location || location.atEnd}><span>下一页</span><ChevronRight size={18} /></button>
</footer>
</div>
);
}
@@ -0,0 +1,130 @@
"use client";
import { Children, type ReactElement, type ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import "katex/dist/katex.min.css";
import CodeBlock from "./CodeBlock";
import MermaidDiagram from "./MermaidDiagram";
import MindMap, { isMermaidMindMapSource } from "./MindMap";
interface Props {
content: string;
variant?: "light" | "dark";
}
/** Extract raw text out of react-markdown's children for a code block. */
function codeText(children: React.ReactNode): string {
const raw = Array.isArray(children) ? children.join("") : String(children ?? "");
return raw.replace(/\n$/, "");
}
export default function MarkdownRenderer({ content, variant = "light" }: Props) {
const dark = variant === "dark";
return (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
h1: ({ children }) => (
<h1 className={`text-lg font-semibold mt-5 mb-2 ${dark ? "text-white" : "text-[var(--fg)]"}`}>{children}</h1>
),
h2: ({ children }) => (
<h2 className={`text-base font-semibold mt-4 mb-1.5 ${dark ? "text-white/90" : "text-[var(--fg)]"}`}>{children}</h2>
),
h3: ({ children }) => (
<h3 className={`text-[15px] font-medium mt-3 mb-1 ${dark ? "text-white/80" : "text-[var(--fg)]/90"}`}>{children}</h3>
),
p: ({ children }) => <p className="my-2 leading-[1.75]">{children}</p>,
ul: ({ children }) => <ul className="list-disc pl-5 my-1.5 space-y-0.5">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal pl-5 my-1.5 space-y-0.5">{children}</ol>,
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
input: ({ checked, disabled }) => (
<input type="checkbox" checked={checked} disabled={disabled} className="mr-1.5 accent-blue-500" />
),
blockquote: ({ children }) => (
<blockquote
className={`border-l-4 pl-3 my-2 italic py-1 rounded-r ${
dark ? "border-white/30 text-white/70 bg-white/10" : "border-[var(--accent)] text-[var(--fg-muted)] bg-[var(--accent-soft)]"
}`}
>
{children}
</blockquote>
),
code: ({ className, children }) => {
// 带语言标注的必然出现在 <pre> 里,块级渲染由 pre 统一处理;
// 这里只负责真正的行内代码。注意要保留 className,供 pre 读取语言。
if (className) {
return <code className={className}>{children}</code>;
}
return (
<code
className={`px-1.5 py-0.5 rounded text-[0.85em] font-mono ${
dark ? "bg-white/20 text-white" : "bg-[var(--accent-soft)] text-[var(--accent)]"
}`}
>
{children}
</code>
);
},
pre: ({ children }) => {
// 所有代码块都会先经过 <pre>,所以块级判断放在这里最可靠:
// 提取语言和源码,交给 CodeBlock(高亮)或 MermaidDiagram(图表)。
const child = Children.toArray(children)[0] as ReactElement<{
className?: string;
children?: ReactNode;
}>;
const childProps = child?.props ?? {};
const lang = /language-([\w-]+)/.exec(String(childProps.className ?? ""))?.[1];
const text = codeText(childProps.children);
if (lang === "mermaid") {
// mermaid 的 mindmap 解析器对中文 / <br/> / 数字容易报 NODE_DEND 错误,
// 所以 mindmap 块不交给 mermaid,直接用 MindMap 组件渲染成可交互思维导图。
if (isMermaidMindMapSource(text)) return <MindMap code={text} />;
return <MermaidDiagram code={text} />;
}
return <CodeBlock code={text} language={lang} />;
},
table: ({ children }) => (
<div className="overflow-x-auto my-2">
<table className={`min-w-full border-collapse border text-sm ${dark ? "border-white/20" : "border-[var(--border)]"}`}>
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className={dark ? "bg-white/10" : "bg-[var(--bg-raised)]"}>{children}</thead>
),
th: ({ children }) => (
<th className={`border px-3 py-1.5 text-left font-semibold ${dark ? "border-white/20" : "border-[var(--border)]"}`}>
{children}
</th>
),
td: ({ children }) => (
<td className={`border px-3 py-1.5 ${dark ? "border-white/20" : "border-[var(--border)]"}`}>{children}</td>
),
hr: () => <hr className={`my-3 ${dark ? "border-white/20" : "border-[var(--border)]"}`} />,
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={dark ? "text-blue-200 hover:underline" : "text-[var(--accent-hover)] hover:underline"}
>
{children}
</a>
),
strong: ({ children }) => (
<strong className={`font-bold ${dark ? "text-white" : "text-[var(--fg)]"}`}>{children}</strong>
),
em: ({ children }) => <em className="italic">{children}</em>,
}}
>
{content}
</ReactMarkdown>
);
}
@@ -0,0 +1,55 @@
"use client";
import { useEffect, useRef, useState } from "react";
let idCounter = 0;
/**
* Renders a ```mermaid code block into an actual diagram. mermaid is loaded
* lazily in an effect (SSR-safe); on parse failure it falls back to showing
* the raw source so the content is never lost.
*/
export default function MermaidDiagram({ code }: { code: string }) {
const [svg, setSvg] = useState<string | null>(null);
const [error, setError] = useState(false);
const idRef = useRef<string>(`mmd-${++idCounter}-${Date.now().toString(36)}`);
useEffect(() => {
let cancelled = false;
import("mermaid")
.then(async (mod) => {
if (cancelled) return;
const mermaid = mod.default;
mermaid.initialize({ startOnLoad: false });
const { svg: out } = await mermaid.render(idRef.current, code);
if (!cancelled) setSvg(out);
})
.catch(() => {
if (!cancelled) setError(true);
});
return () => {
cancelled = true;
};
}, [code]);
if (svg) {
return (
<div
className="my-2 rounded-lg border border-[var(--border)] bg-[var(--bg-card)] p-3 overflow-x-auto"
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
}
if (error) {
return (
<pre className="my-2 p-3 rounded-lg text-[0.85em] font-mono overflow-x-auto bg-[#0d1117] text-green-300">
<code>{code}</code>
</pre>
);
}
return <div className="my-2 p-3 text-xs text-[var(--fg-muted)]">图表渲染中…</div>;
}
+494
View File
@@ -0,0 +1,494 @@
"use client";
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { Brain, Check, Copy, Download, Image as ImageIcon, Loader2, Maximize2, Minus, Plus } from "lucide-react";
import CodeBlock from "./CodeBlock";
interface MindNode {
text: string;
children: MindNode[];
level: number;
}
interface LineItem {
indent: number;
text: string;
}
/** 布局常量:节点尺寸按中文字符估算,保证中文能自然换行。 */
const CHAR_W = 15; // 平均每字宽度(text-sm 中文约 14px)
const NODE_MIN_W = 64;
const NODE_MAX_W = 200;
const LINE_H = 22;
const PAD_X = 12;
const PAD_Y = 9;
const H_GAP = 56; // 父子两列之间的水平间距
const V_GAP = 14; // 兄弟节点之间的垂直间距
const PAD = 24; // 画布内边距
const VIEW_H = 320; // 渲染视图可视高度
const DEPTH_STYLES = [
"bg-indigo-600 text-white border-indigo-700 shadow-md font-semibold", // 根节点
"bg-indigo-950/80 text-indigo-200 border-indigo-800 shadow-sm",
"bg-blue-950/80 text-blue-200 border-blue-800 shadow-sm",
"bg-emerald-950/80 text-emerald-200 border-emerald-800 shadow-sm",
"bg-amber-950/80 text-amber-200 border-amber-800 shadow-sm",
"bg-purple-950/80 text-purple-200 border-purple-800 shadow-sm",
];
/** Shared indent-stack builder: nests by comparing indents directly. */
function buildTree(items: LineItem[]): MindNode | null {
if (items.length === 0) return null;
const root: MindNode = { text: items[0].text, children: [], level: 0 };
const stack: { node: MindNode; indent: number }[] = [{ node: root, indent: items[0].indent }];
for (let i = 1; i < items.length; i++) {
const { indent, text } = items[i];
const node: MindNode = { text, children: [], level: 0 };
while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop();
const parent = stack.length > 0 ? stack[stack.length - 1].node : root;
node.level = parent.level + 1;
parent.children.push(node);
stack.push({ node, indent });
}
return root;
}
function parseMarkdownList(content: string): MindNode | null {
const items: LineItem[] = [];
let rootFromHeading: string | null = null;
for (const raw of content.split("\n")) {
const line = raw;
const list = line.match(/^([ \t]*)[-*+]\s+(.+)/);
if (list) {
items.push({ indent: list[1].replace(/\t/g, " ").length, text: list[2].trim() });
} else if (!rootFromHeading) {
const h = line.match(/^#+\s*(.+)/);
if (h) rootFromHeading = h[1].trim();
}
}
if (items.length === 0) return null;
const tree = buildTree(items);
// 如果列表前有标题,用它作为根节点(原文顶层的并列内容都挂到标题下)。
if (tree && rootFromHeading) {
return { text: rootFromHeading, children: [tree], level: 0 };
}
return tree;
}
/** Strip mermaid mindmap shape wrappers like ((..)) [..] {..} and icons. */
function cleanMermaidNode(line: string): string {
const mirror: Record<string, string> = { "(": ")", "[": "]", "{": "}", "<": ">" };
let text = line
.replace(/^root\b\s*/, "") // mermaid 要求根节点带 root 关键字
.replace(/::icon\([^)]*\)/g, "")
.replace(/<br\s*\/?>/gi, " ")
.trim();
// 剥掉包围形状的括号:((..)) / [[..]] / [..] / (..) / {..} / ([..]) …
// 从两端按「相同开括号连续出现的次数」剥掉等量的闭括号,天然支持嵌套成对形状。
let guard = 0;
while (guard++ < 5) {
const first = text[0];
const expected = mirror[first];
if (!expected) break;
let openRun = 0;
while (openRun < text.length && text[openRun] === first) openRun++;
if (openRun > 0 && openRun * 2 < text.length && text.endsWith(expected.repeat(openRun))) {
text = text.slice(openRun, text.length - openRun).trim();
} else {
break;
}
}
return text.trim();
}
function parseMermaidMindMap(src: string): MindNode | null {
const items: LineItem[] = [];
let headerSkipped = false;
for (const raw of src.split("\n")) {
if (!raw.trim()) continue;
if (!headerSkipped) {
// 第一行是 mindmap 声明,跳过;若已是 root((…)) 则不跳。
headerSkipped = true;
if (/^\s*mindmap\s*$/i.test(raw)) continue;
}
const indent = (raw.match(/^(\s*)/)?.[1] ?? "").replace(/\t/g, " ").length;
const text = cleanMermaidNode(raw.trim());
if (!text) continue;
items.push({ indent, text });
}
return buildTree(items);
}
/** True when the text looks like a mermaid mindmap source (header line = mindmap). */
export function isMermaidMindMapSource(text: string): boolean {
const first = text.split("\n").find((l) => l.trim()) ?? "";
return /^\s*mindmap\s*$/i.test(first);
}
/** 主解析入口:优先按 mermaid mindmap,其次按 Markdown 列表。 */
function parseMindMap(code: string): MindNode | null {
if (isMermaidMindMapSource(code)) return parseMermaidMindMap(code);
return parseMarkdownList(code);
}
interface LayoutNode {
node: MindNode;
x: number;
y: number;
w: number;
h: number;
parent: LayoutNode | null;
children: LayoutNode[];
}
interface Edge {
x1: number;
y1: number;
x2: number;
y2: number;
}
function nodeMetrics(text: string): { w: number; h: number } {
const w = Math.min(Math.max(text.length * CHAR_W + PAD_X * 2, NODE_MIN_W), NODE_MAX_W);
const perLine = Math.max(1, Math.floor((w - PAD_X * 2) / CHAR_W));
const lines = Math.max(1, Math.ceil(text.length / perLine));
return { w, h: lines * LINE_H + PAD_Y * 2 };
}
/** 递归布局:同深度的节点共用一列,保证任意兄弟子树永不重叠。 */
function layoutRoot(root: MindNode): { nodes: LayoutNode[]; edges: Edge[]; width: number; height: number } {
const size = new Map<MindNode, { w: number; h: number; subtreeH: number }>();
const maxW: number[] = [];
(function measure(n: MindNode, d: number) {
const m = nodeMetrics(n.text);
maxW[d] = Math.max(maxW[d] ?? 0, m.w);
let sh = 0;
for (const c of n.children) {
measure(c, d + 1);
sh += size.get(c)!.subtreeH;
}
if (n.children.length > 1) sh += V_GAP * (n.children.length - 1);
size.set(n, { ...m, subtreeH: Math.max(m.h, sh) });
})(root, 0);
const colX: number[] = [];
for (let d = 0; d < maxW.length; d++) colX[d] = (colX[d - 1] ?? 0) + maxW[d] + H_GAP;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const all: LayoutNode[] = [];
function place(n: MindNode, d: number, blockCenterY: number, parent: LayoutNode | null): void {
const m = size.get(n)!;
const x = colX[d];
const y = blockCenterY - m.h / 2;
const ln: LayoutNode = { node: n, x, y, w: m.w, h: m.h, parent, children: [] };
if (parent) parent.children.push(ln);
all.push(ln);
let childrenH = 0;
for (const c of n.children) childrenH += size.get(c)!.subtreeH;
if (n.children.length > 1) childrenH += V_GAP * (n.children.length - 1);
let top = blockCenterY - childrenH / 2;
for (const c of n.children) {
const ch = size.get(c)!.subtreeH;
place(c, d + 1, top + ch / 2, ln);
top += ch + V_GAP;
}
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + m.w);
maxY = Math.max(maxY, y + m.h);
}
place(root, 0, 0, null);
const edges: Edge[] = [];
for (const ln of all) {
if (!ln.parent) continue;
edges.push({
x1: ln.parent.x + ln.parent.w,
y1: ln.parent.y + ln.parent.h / 2,
x2: ln.x,
y2: ln.y + ln.h / 2,
});
}
// 平移到带内边距的坐标系
const dx = PAD - minX;
const dy = PAD - minY;
for (const ln of all) {
ln.x += dx;
ln.y += dy;
}
for (const e of edges) {
e.x1 += dx;
e.y1 += dy;
e.x2 += dx;
e.y2 += dy;
}
return {
nodes: all,
edges,
width: maxX - minX + PAD * 2,
height: maxY - minY + PAD * 2,
};
}
export default function MindMap({ code }: { code: string }) {
const [view, setView] = useState<"render" | "code">("render");
const [copied, setCopied] = useState(false);
const [exporting, setExporting] = useState(false);
const [scale, setScale] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const wrapRef = useRef<HTMLDivElement>(null); // 可视窗口(overflow hidden)
const stageRef = useRef<HTMLDivElement>(null); // 被平移/缩放的画布
const dragRef = useRef<{ startX: number; startY: number; panX: number; panY: number } | null>(null);
const stateRef = useRef({ scale, pan });
stateRef.current = { scale, pan };
const root = useMemo(() => parseMindMap(code), [code]);
const layout = useMemo(() => (root ? layoutRoot(root) : null), [root]);
// 挂载时把整张图放进可视窗口
useLayoutEffect(() => {
const el = wrapRef.current;
if (!el || !layout) return;
const vw = el.clientWidth;
const vh = el.clientHeight;
const s = Math.min((vw - 16) / layout.width, (vh - 16) / layout.height, 1);
setScale(Math.max(s, 0.05));
setPan({ x: 0, y: 0 });
}, [layout]);
// 滚轮缩放(原生非 passive 监听,避免页面跟着滚动)
useLayoutEffect(() => {
const el = wrapRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const rect = el.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
const { scale: s, pan: p } = stateRef.current;
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
const next = Math.min(3, Math.max(0.2, s * factor));
const wx = (cx - p.x) / s;
const wy = (cy - p.y) / s;
setScale(next);
setPan({ x: cx - wx * next, y: cy - wy * next });
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, []);
const onPointerDown = (e: React.PointerEvent) => {
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
dragRef.current = { startX: e.clientX, startY: e.clientY, panX: pan.x, panY: pan.y };
};
const onPointerMove = (e: React.PointerEvent) => {
const d = dragRef.current;
if (!d) return;
setPan({ x: d.panX + (e.clientX - d.startX), y: d.panY + (e.clientY - d.startY) });
};
const onPointerUp = () => {
dragRef.current = null;
};
const zoomBy = (f: number) => setScale((s) => Math.min(3, Math.max(0.2, s * f)));
const fit = () => {
const el = wrapRef.current;
if (!el || !layout) return;
const vw = el.clientWidth;
const vh = el.clientHeight;
const s = Math.min((vw - 16) / layout.width, (vh - 16) / layout.height, 1);
setScale(Math.max(s, 0.05));
setPan({ x: 0, y: 0 });
};
const copySource = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
alert("复制失败");
}
};
const exportMd = () => {
const content = "```mermaid\n" + code.replace(/\n+$/, "") + "\n```\n";
const blob = new Blob([content], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "思维导图.md";
a.click();
URL.revokeObjectURL(url);
};
const exportImage = async () => {
const outer = wrapRef.current;
const inner = stageRef.current;
if (!outer || !inner || !layout || exporting) return;
const prevOuter = { width: outer.style.width, height: outer.style.height, overflow: outer.style.overflow };
const prevTransform = inner.style.transform;
outer.style.width = `${layout.width}px`;
outer.style.height = `${layout.height}px`;
outer.style.overflow = "visible";
inner.style.transform = "translate(0px, 0px) scale(1)";
setExporting(true);
try {
const { toPng } = await import("html-to-image");
const dataUrl = await toPng(outer, { pixelRatio: 2, backgroundColor: "#10151f", cacheBust: true });
const a = document.createElement("a");
a.href = dataUrl;
a.download = "思维导图.png";
a.click();
} catch {
alert("导出图片失败");
} finally {
outer.style.width = prevOuter.width;
outer.style.height = prevOuter.height;
outer.style.overflow = prevOuter.overflow;
inner.style.transform = prevTransform;
setExporting(false);
}
};
if (!root || !layout) return null;
return (
<div className="my-4 rounded-xl bg-[var(--bg-card)] shadow-[inset_0_1px_0_rgba(255,255,255,0.06)] overflow-hidden">
{/* 工具栏:无边框控制台风,视图切换 pill + 纯图标按钮 */}
<div className="flex items-center gap-1 flex-wrap px-3 py-1.5 border-b border-[var(--border)]">
<div className="rounded-lg bg-white/5 p-0.5 flex overflow-hidden text-xs shrink-0">
<button
onClick={() => setView("render")}
className={`px-2.5 py-1 rounded-md transition-colors ${
view === "render" ? "bg-[var(--accent)] text-white" : "text-[var(--fg-muted)] hover:text-[var(--fg)]"
}`}
>
渲染
</button>
<button
onClick={() => setView("code")}
className={`px-2.5 py-1 rounded-md transition-colors ${
view === "code" ? "bg-[var(--accent)] text-white" : "text-[var(--fg-muted)] hover:text-[var(--fg)]"
}`}
>
代码
</button>
</div>
<div className="flex-1" />
<button
onClick={copySource}
className={`p-1.5 rounded-md transition-colors shrink-0 ${
copied ? "text-[var(--success)]" : "text-[var(--fg-muted)] hover:text-[var(--fg)] hover:bg-white/5"
}`}
title="复制 mermaid 源码"
aria-label="复制 mermaid 源码"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
<button
onClick={exportMd}
className="p-1.5 rounded-md text-[var(--fg-muted)] hover:text-[var(--fg)] hover:bg-white/5 transition-colors shrink-0"
title="导出为 Markdown"
aria-label="导出为 Markdown"
>
<Download size={14} />
</button>
{view === "render" && (
<button
onClick={exportImage}
disabled={exporting}
className="p-1.5 rounded-md text-[var(--fg-muted)] hover:text-[var(--fg)] hover:bg-white/5 transition-colors shrink-0 disabled:opacity-50"
title="导出为图片"
aria-label="导出为图片"
>
{exporting ? <Loader2 size={14} className="animate-spin" /> : <ImageIcon size={14} />}
</button>
)}
{view === "render" && (
<div className="flex items-center rounded-md bg-white/5 overflow-hidden text-xs shrink-0">
<button onClick={() => zoomBy(1 / 1.2)} className="p-1.5 text-[var(--fg-muted)] hover:text-[var(--fg)] hover:bg-white/5" title="缩小">
<Minus size={13} />
</button>
<span className="w-9 text-center text-[10px] text-[var(--fg-muted)]">{Math.round(scale * 100)}%</span>
<button onClick={() => zoomBy(1.2)} className="p-1.5 text-[var(--fg-muted)] hover:text-[var(--fg)] hover:bg-white/5" title="放大">
<Plus size={13} />
</button>
<button onClick={fit} className="p-1.5 text-[var(--fg-muted)] hover:text-[var(--fg)] hover:bg-white/5" title="适应窗口">
<Maximize2 size={13} />
</button>
</div>
)}
</div>
{/* 视图内容 */}
{view === "render" ? (
<div
ref={wrapRef}
className="relative overflow-hidden select-none bg-gradient-to-br from-[#0d1526] to-[#0a1120]"
style={{ height: VIEW_H }}
title="滚轮缩放 · 拖动平移"
>
<div
ref={stageRef}
className="absolute left-0 top-0 origin-top-left cursor-grab active:cursor-grabbing"
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`, touchAction: "none" }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<svg className="absolute left-0 top-0" width={layout.width} height={layout.height} style={{ overflow: "visible" }}>
{layout.edges.map((e, i) => {
const midX = e.x1 + (e.x2 - e.x1) * 0.5;
return (
<path
key={i}
d={`M ${e.x1} ${e.y1} C ${midX} ${e.y1}, ${midX} ${e.y2}, ${e.x2} ${e.y2}`}
stroke="#818cf8"
strokeWidth="1.5"
fill="none"
/>
);
})}
</svg>
{layout.nodes.map((ln, i) => {
const depthStyle = DEPTH_STYLES[Math.min(ln.node.level, DEPTH_STYLES.length - 1)];
return (
<div
key={i}
className={`absolute rounded-lg border px-3 py-1.5 text-sm leading-tight ${depthStyle}`}
style={{ left: ln.x, top: ln.y, width: ln.w }}
>
{ln.node.text}
</div>
);
})}
</div>
<span className="pointer-events-none absolute bottom-1 right-2 text-[10px] text-white/30 hidden sm:inline">
滚轮缩放 · 拖动平移
</span>
</div>
) : (
<CodeBlock code={code} language="mermaid" />
)}
</div>
);
}
@@ -0,0 +1,469 @@
"use client";
import { useMemo, useState } from "react";
import {
ArrowLeft, Check, FlaskConical, KeyRound, Loader2, Pencil, Plus,
Search, Settings2, Trash2, Upload, X,
} from "lucide-react";
import { api, AiModelEntry } from "@/lib/api";
interface Props {
open: boolean;
onClose: () => void;
models: AiModelEntry[];
onChanged: () => void;
}
interface Preset {
key: string;
label: string;
kind: "openai_compatible" | "anthropic";
base_url: string;
model_id: string;
hint: string;
}
const PRESETS: Preset[] = [
{ key: "openrouter", label: "OpenRouter", kind: "openai_compatible", base_url: "https://openrouter.ai/api/v1", model_id: "", hint: "一个 key 用全部模型,如 deepseek/deepseek-r1" },
{ key: "deepseek", label: "DeepSeek", kind: "openai_compatible", base_url: "https://api.deepseek.com/v1", model_id: "deepseek-chat", hint: "deepseek-chat(V3) / deepseek-reasoner(R1)" },
{ key: "sensenova", label: "SenseNova", kind: "openai_compatible", base_url: "https://token.sensenova.cn/v1", model_id: "", hint: "商汤日日新" },
{ key: "siliconflow", label: "硅基流动", kind: "openai_compatible", base_url: "https://api.siliconflow.cn/v1", model_id: "", hint: "国产模型市场,低价" },
{ key: "claude", label: "Claude", kind: "anthropic", base_url: "", model_id: "claude-sonnet-4-20250514", hint: "需 Anthropic API Key" },
];
// 批量导入模式的平台快捷选择
const IMPORT_PROVIDERS: { label: string; base_url: string }[] = [
{ label: "OpenRouter", base_url: "https://openrouter.ai/api/v1" },
{ label: "硅基流动", base_url: "https://api.siliconflow.cn/v1" },
{ label: "SenseNova", base_url: "https://token.sensenova.cn/v1" },
{ label: "自定义", base_url: "" },
];
const inputCls =
"w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--bg-raised)] text-sm text-[var(--fg)] placeholder-[var(--fg-muted)] focus:outline-none focus:ring-2 focus:ring-[var(--accent)]";
export default function ModelManagerDialog({ open, onClose, models, onChanged }: Props) {
const [mode, setMode] = useState<"list" | "form" | "import">("list");
// 表单
const [form, setForm] = useState({ name: "", label: "", kind: "openai_compatible", base_url: "", api_key: "", model_id: "" });
const [editingName, setEditingName] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState<string | null>(null);
// 测试
const [testingName, setTestingName] = useState<string | null>(null);
const [testResult, setTestResult] = useState<Record<string, { ok: boolean; text: string }>>({});
// 批量导入(任意 OpenAI 兼容平台)
const [importBase, setImportBase] = useState("https://openrouter.ai/api/v1");
const [importKey, setImportKey] = useState("");
const [fetching, setFetching] = useState(false);
const [providerModels, setProviderModels] = useState<{ id: string; name: string }[]>([]);
const [importSearch, setImportSearch] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [importMsg, setImportMsg] = useState<string | null>(null);
const filtered = useMemo(() => {
const q = importSearch.trim().toLowerCase();
if (!q) return providerModels;
return providerModels.filter((m) => m.id.toLowerCase().includes(q) || (m.name || "").toLowerCase().includes(q));
}, [providerModels, importSearch]);
if (!open) return null;
const applyPreset = (p: Preset) => {
setForm({
name: "", label: p.label, kind: p.kind,
base_url: p.base_url, api_key: "", model_id: p.model_id,
});
setEditingName(null);
setMode("form");
};
const startAdd = () => {
setForm({ name: "", label: "", kind: "openai_compatible", base_url: "", api_key: "", model_id: "" });
setEditingName(null);
setSaveMsg(null);
setMode("form");
};
const startEdit = (m: AiModelEntry) => {
setForm({ name: m.name, label: m.label, kind: m.kind, base_url: m.base_url, api_key: "", model_id: m.model_id });
setEditingName(m.name);
setSaveMsg(null);
setMode("form");
};
const save = async () => {
if (!form.model_id.trim() || !form.label.trim()) {
setSaveMsg("请填写模型名称(显示名)和模型 ID");
return;
}
if (!form.kind.startsWith("anthropic") && !form.base_url.trim()) {
setSaveMsg("OpenAI 兼容模型需要 base_url(可点上方预设自动填入)");
return;
}
setSaving(true);
setSaveMsg(null);
try {
if (editingName) {
await api.updateModel(editingName, {
label: form.label, kind: form.kind, base_url: form.base_url, model_id: form.model_id,
...(form.api_key.trim() ? { api_key: form.api_key } : {}),
});
} else {
await api.addModel({
label: form.label, kind: form.kind,
base_url: form.base_url, api_key: form.api_key, model_id: form.model_id,
});
}
onChanged();
setMode("list");
setSaveMsg(null);
} catch (e) {
setSaveMsg(e instanceof Error ? e.message : "保存失败");
}
setSaving(false);
};
const remove = async (m: AiModelEntry) => {
if (!confirm(`删除模型「${m.label}」?`)) return;
try {
await api.deleteModel(m.name);
onChanged();
} catch {
alert("删除失败");
}
};
const test = async (m: AiModelEntry) => {
setTestingName(m.name);
setTestResult((prev) => ({ ...prev, [m.name]: prev[m.name] }));
try {
const r = await api.testModel(m.name);
setTestResult((prev) => ({
...prev,
[m.name]: { ok: r.ok, text: r.ok ? `✅ 连通正常 · ${r.reply?.slice(0, 40) ?? ""}` : `❌ ${r.error ?? "失败"}` },
}));
} catch (e) {
setTestResult((prev) => ({ ...prev, [m.name]: { ok: false, text: `❌ ${e instanceof Error ? e.message : "请求失败"}` } }));
}
setTestingName(null);
};
const fetchModels = async () => {
if (!importBase.trim() || !importKey.trim()) return;
setFetching(true);
setImportMsg(null);
try {
const r = await api.importModels(importBase.trim(), importKey.trim());
setProviderModels(r.models);
setSelected(new Set());
} catch (e) {
setImportMsg(`❌ ${e instanceof Error ? e.message : "拉取失败"}`);
}
setFetching(false);
};
const toggleSelect = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const importSelected = async () => {
const ids = [...selected];
if (!ids.length) return;
setSaving(true);
setImportMsg(null);
let ok = 0;
for (const id of ids) {
try {
await api.addModel({
label: id, kind: "openai_compatible",
base_url: importBase, api_key: importKey, model_id: id,
});
ok += 1;
} catch {
// 单个失败跳过(重名等)
}
}
setImportMsg(`已导入 ${ok}/${ids.length} 个模型`);
onChanged();
setSaving(false);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
<div
className="bg-[var(--bg-card)] border border-[var(--border)] rounded-2xl shadow-2xl w-full max-w-[600px] max-h-[80vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* 头部 */}
<div className="flex items-center gap-2 px-5 py-4 border-b border-[var(--border)]">
{mode !== "list" && (
<button onClick={() => setMode("list")} className="p-1 rounded-md text-[var(--fg-muted)] hover:bg-white/10 hover:text-[var(--fg)] transition-colors" title="返回">
<ArrowLeft size={16} />
</button>
)}
<Settings2 size={17} className="text-[var(--accent)]" />
<h2 className="flex-1 text-base font-semibold text-[var(--fg)]">
{mode === "form" ? (editingName ? "编辑模型" : "添加模型") : mode === "import" ? "从 OpenRouter 导入" : "管理模型"}
</h2>
<button onClick={onClose} className="p-1 rounded-md text-[var(--fg-muted)] hover:bg-white/10 hover:text-[var(--fg)] transition-colors" title="关闭">
<X size={16} />
</button>
</div>
{/* 列表模式 */}
{mode === "list" && (
<div className="flex-1 overflow-y-auto p-4 space-y-2">
<div className="flex gap-2 mb-3">
<button
onClick={startAdd}
className="flex-1 py-2 rounded-lg text-sm bg-[var(--accent)] text-white font-medium hover:bg-[var(--accent-hover)] transition-colors flex items-center justify-center gap-1.5"
>
<Plus size={15} />
添加模型
</button>
<button
onClick={() => setMode("import")}
className="flex-1 py-2 rounded-lg text-sm border border-[var(--border)] text-[var(--fg)] hover:bg-white/5 transition-colors flex items-center justify-center gap-1.5"
>
<Upload size={15} />
导入模型
</button>
</div>
{models.length === 0 && (
<p className="text-sm text-[var(--fg-muted)] text-center py-8">还没有可用模型,点上方「添加模型」配置一个</p>
)}
{models.map((m) => (
<div key={m.name} className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-[var(--border)] bg-[var(--bg-raised)]">
<div className="w-8 h-8 rounded-lg bg-[var(--accent-soft)] flex items-center justify-center shrink-0">
{m.kind === "anthropic" ? <KeyRound size={15} className="text-[var(--accent)]" /> : <Settings2 size={15} className="text-[var(--accent)]" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[var(--fg)] truncate">{m.label}</span>
{m.builtin && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-white/10 text-[var(--fg-muted)]">内置</span>
)}
</div>
<div className="text-xs text-[var(--fg-muted)] truncate">
{m.model_id}
{m.base_url ? ` · ${m.base_url.replace(/^https?:\/\//, "").split("/")[0]}` : ""}
{m.api_key_masked ? ` · ${m.api_key_masked}` : ""}
</div>
{testResult[m.name] && (
<div className={`text-xs mt-1 ${testResult[m.name].ok ? "text-[var(--success)]" : "text-[var(--danger)]"}`}>
{testResult[m.name].text}
</div>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => test(m)}
disabled={testingName === m.name}
className="p-1.5 rounded-lg text-[var(--fg-muted)] hover:bg-white/10 hover:text-[var(--accent)] transition-colors disabled:opacity-40"
title="测试连通"
>
{testingName === m.name ? <Loader2 size={15} className="animate-spin" /> : <FlaskConical size={15} />}
</button>
{!m.builtin && (
<>
<button
onClick={() => startEdit(m)}
className="p-1.5 rounded-lg text-[var(--fg-muted)] hover:bg-white/10 hover:text-[var(--accent)] transition-colors"
title="编辑"
>
<Pencil size={15} />
</button>
<button
onClick={() => remove(m)}
className="p-1.5 rounded-lg text-[var(--fg-muted)] hover:bg-white/10 hover:text-[var(--danger)] transition-colors"
title="删除"
>
<Trash2 size={15} />
</button>
</>
)}
</div>
</div>
))}
</div>
)}
{/* 表单模式 */}
{mode === "form" && (
<div className="flex-1 overflow-y-auto p-4 space-y-3">
<div>
<p className="text-xs text-[var(--fg-muted)] mb-1.5 font-medium">快捷预设(点一下自动填,只需补 API Key)</p>
<div className="flex flex-wrap gap-1.5">
{PRESETS.map((p) => (
<button
key={p.key}
onClick={() => applyPreset(p)}
className={`px-2.5 py-1 rounded-lg text-xs border transition-colors ${
form.label === p.label
? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]"
: "border-[var(--border)] text-[var(--fg-muted)] hover:bg-white/5 hover:text-[var(--fg)]"
}`}
title={p.hint}
>
{p.label}
</button>
))}
</div>
<div className="mt-1.5 text-xs text-[var(--fg-muted)]">
{PRESETS.find((p) => p.label === form.label)?.hint ?? "预设只是帮填字段;你也可以完全手动填写任意 OpenAI 兼容 API"}
</div>
</div>
<div>
<label className="text-xs text-[var(--fg-muted)] font-medium">显示名</label>
<input className={inputCls} value={form.label} onChange={(e) => setForm({ ...form, label: e.target.value })} placeholder="如 DeepSeek V3 / Qwen2.5-72B" />
</div>
<div>
<label className="text-xs text-[var(--fg-muted)] font-medium">类型</label>
<select className={inputCls} value={form.kind} onChange={(e) => setForm({ ...form, kind: e.target.value })}>
<option value="openai_compatible">OpenAI 兼容(OpenRouter / DeepSeek / 硅基流动等)</option>
<option value="anthropic">Anthropic(Claude)</option>
</select>
</div>
<div>
<label className="text-xs text-[var(--fg-muted)] font-medium">Base URL(openai_compatible 需要)</label>
<input className={inputCls} value={form.base_url} onChange={(e) => setForm({ ...form, base_url: e.target.value })} placeholder="https://openrouter.ai/api/v1" />
</div>
<div>
<label className="text-xs text-[var(--fg-muted)] font-medium">API Key(网页只存不显示)</label>
<input className={inputCls} type="password" value={form.api_key} onChange={(e) => setForm({ ...form, api_key: e.target.value })} placeholder={editingName ? "留空保持不变" : "sk-..."} autoComplete="off" />
</div>
<div>
<label className="text-xs text-[var(--fg-muted)] font-medium">模型 ID</label>
<input className={inputCls} value={form.model_id} onChange={(e) => setForm({ ...form, model_id: e.target.value })} placeholder="deepseek-chat / deepseek/deepseek-r1 / qwen2.5-72b-instruct" />
</div>
{saveMsg && <div className="text-xs text-[var(--danger)]">{saveMsg}</div>}
<div className="flex gap-2 pt-1">
<button
onClick={save}
disabled={saving}
className="flex-1 py-2 rounded-lg text-sm bg-[var(--accent)] text-white font-medium hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors flex items-center justify-center gap-1.5"
>
{saving ? <Loader2 size={15} className="animate-spin" /> : <Check size={15} />}
保存
</button>
<button
onClick={() => setMode("list")}
className="px-4 py-2 rounded-lg text-sm border border-[var(--border)] text-[var(--fg-muted)] hover:bg-white/5 transition-colors"
>
取消
</button>
</div>
</div>
)}
{/* 批量导入模式(任意 OpenAI 兼容平台) */}
{mode === "import" && (
<div className="flex-1 overflow-y-auto p-4 space-y-3">
<div>
<label className="text-xs text-[var(--fg-muted)] font-medium">平台</label>
<div className="flex flex-wrap gap-1.5 mt-1.5">
{IMPORT_PROVIDERS.map((p) => (
<button
key={p.label}
onClick={() => setImportBase(p.base_url)}
className={`px-2.5 py-1 rounded-lg text-xs border transition-colors ${
importBase === p.base_url
? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]"
: "border-[var(--border)] text-[var(--fg-muted)] hover:bg-white/5 hover:text-[var(--fg)]"
}`}
>
{p.label}
</button>
))}
</div>
<label className="text-xs text-[var(--fg-muted)] font-medium mt-3 block">Base URL</label>
<input
className={`${inputCls} mt-1`}
value={importBase}
onChange={(e) => setImportBase(e.target.value)}
placeholder="https://openrouter.ai/api/v1"
/>
<label className="text-xs text-[var(--fg-muted)] font-medium mt-3 block">API Key</label>
<div className="flex gap-2 mt-1">
<input
className={inputCls}
type="password"
value={importKey}
onChange={(e) => setImportKey(e.target.value)}
placeholder="sk-..."
autoComplete="off"
/>
<button
onClick={fetchModels}
disabled={fetching || !importBase.trim() || !importKey.trim()}
className="px-4 py-2 rounded-lg text-sm bg-[var(--accent)] text-white font-medium hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors shrink-0 flex items-center gap-1.5"
>
{fetching ? <Loader2 size={14} className="animate-spin" /> : <Search size={14} />}
拉取模型
</button>
</div>
{importMsg && <div className={`text-xs mt-1.5 ${importMsg.startsWith("❌") ? "text-[var(--danger)]" : "text-[var(--fg-muted)]"}`}>{importMsg}</div>}
</div>
{providerModels.length > 0 && (
<>
<div className="flex items-center gap-2">
<Search size={14} className="text-[var(--fg-muted)] shrink-0" />
<input
className={inputCls}
value={importSearch}
onChange={(e) => setImportSearch(e.target.value)}
placeholder={`搜索 ${providerModels.length} 个模型,如 deepseek / qwen / claude`}
/>
</div>
<div className="max-h-56 overflow-y-auto border border-[var(--border)] rounded-xl divide-y divide-[var(--border)]">
{filtered.map((m) => (
<button
key={m.id}
onClick={() => toggleSelect(m.id)}
className={`w-full flex items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${
selected.has(m.id) ? "bg-[var(--accent-soft)] text-[var(--fg)]" : "text-[var(--fg)] hover:bg-white/5"
}`}
>
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${
selected.has(m.id) ? "border-[var(--accent)] bg-[var(--accent)]" : "border-[var(--border)]"
}`}>
{selected.has(m.id) && <Check size={11} className="text-white" />}
</div>
<div className="min-w-0">
<div className="font-mono text-xs truncate">{m.id}</div>
<div className="text-xs text-[var(--fg-muted)] truncate">{m.name}</div>
</div>
</button>
))}
{filtered.length === 0 && <p className="px-3 py-4 text-xs text-[var(--fg-muted)] text-center">没有匹配的模型</p>}
</div>
<button
onClick={importSelected}
disabled={selected.size === 0 || saving}
className="w-full py-2 rounded-lg text-sm bg-[var(--accent)] text-white font-medium hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors flex items-center justify-center gap-1.5"
>
{saving ? <Loader2 size={15} className="animate-spin" /> : <Plus size={15} />}
导入已选({selected.size})个模型
</button>
</>
)}
</div>
)}
</div>
</div>
);
}
+183
View File
@@ -0,0 +1,183 @@
export interface Book {
id: string;
filename: string;
title: string | null;
author: string | null;
language: string;
char_count: number;
status: string;
created_at: string;
}
export interface BookDetail extends Book {
chapter_count: number;
segment_count: number;
}
export interface Chapter {
id: string;
book_id: string;
order_idx: number;
title: string | null;
level: number;
heading_path: string | null;
cfi_start: string | null;
cfi_end: string | null;
char_count: number;
}
export interface Segment {
id: string;
chapter_id: string;
book_id: string;
order_idx: number;
text: string;
char_count: number;
cfi: string | null;
}
export interface Citation {
segment_id: string;
cfi: string | null;
excerpt: string;
}
export interface Conversation {
id: string;
book_id: string;
title: string | null;
model: string;
created_at: string;
}
export interface AiModelEntry {
name: string;
label: string;
kind: "openai_compatible" | "anthropic";
base_url: string;
api_key_masked: string;
model_id: string;
builtin: boolean;
}
export interface Message {
id: string;
conversation_id: string;
parent_message_id: string | null;
role: "user" | "assistant" | "system";
content: string;
citations: Citation[] | null;
model: string | null;
created_at: string;
}
const API_BASE = "http://localhost:8000/api";
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
headers: { "Content-Type": "application/json" },
...options,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || "Request failed");
}
return res.json();
}
export const api = {
// Books
listBooks: () => request<Book[]>("/books"),
getBook: (id: string) => request<BookDetail>(`/books/${id}`),
getBookFile: async (id: string, signal?: AbortSignal) => {
const response = await fetch(`${API_BASE}/books/${encodeURIComponent(id)}/file`, { signal });
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "无法打开 EPUB 文件" }));
throw new Error(error.detail || "无法打开 EPUB 文件");
}
return response.arrayBuffer();
},
getChapters: (bookId: string) => request<Chapter[]>(`/books/${bookId}/chapters`),
getSegments: (bookId: string, chapterId?: string) =>
request<Segment[]>(`/books/${bookId}/segments${chapterId ? `?chapter_id=${chapterId}` : ""}`),
uploadBook: async (file: File) => {
const form = new FormData();
form.append("file", file);
const res = await fetch(`${API_BASE}/books`, { method: "POST", body: form });
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: "Upload failed" }));
throw new Error(err.detail || "Upload failed");
}
return res.json() as Promise<Book>;
},
deleteBook: (id: string) => request<{ deleted: string }>(`/books/${id}`, { method: "DELETE" }),
// Conversations
listConversations: (bookId?: string) =>
request<Conversation[]>(`/chat/conversations${bookId ? `?book_id=${bookId}` : ""}`),
createConversation: (bookId: string, model: string = "claude") =>
request<Conversation>("/chat/conversations", {
method: "POST",
body: JSON.stringify({ book_id: bookId, model }),
}),
deleteConversation: (convId: string) =>
request<{ deleted: string }>(`/chat/conversations/${convId}`, { method: "DELETE" }),
getMessages: (convId: string) => request<Message[]>(`/chat/conversations/${convId}/messages`),
// Chat (SSE)
sendMessage: (convId: string, content: string, model?: string, replyTo?: string) => {
return fetch(`${API_BASE}/chat/conversations/${convId}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content, model, ...(replyTo ? { reply_to: replyTo } : {}) }),
});
},
// Search
search: (bookId: string, q: string) =>
request<Segment[]>(`/chat/search?book_id=${bookId}&q=${encodeURIComponent(q)}`),
// 引用段落的阅读上下文(「阅读原文」弹层)
getSegmentContext: (segmentId: string) =>
request<{
segment: { id: string; text: string; order_idx: number; cfi: string | null };
chapter: { id: string; title: string | null; level: number } | null;
before: { id: string; text: string; order_idx: number; cfi: string | null }[];
after: { id: string; text: string; order_idx: number; cfi: string | null }[];
}>(`/chat/segments/${segmentId}/context`),
// Message actions
deleteMessage: (msgId: string) =>
request<{ deleted: string }>(`/chat/messages/${msgId}`, { method: "DELETE" }),
retryMessage: (msgId: string) => {
return fetch(`${API_BASE}/chat/messages/${msgId}/retry`, { method: "POST" });
},
branchConversation: (convId: string, fromMessageId: string) =>
request<Conversation>(`/chat/conversations/${convId}/branch?from_message_id=${fromMessageId}`, {
method: "POST",
}),
exportConversation: (convId: string) =>
request<{ content: string; format: string; title: string }>(`/chat/conversations/${convId}/export`),
// Models
listModels: () => request<AiModelEntry[]>("/models"),
addModel: (data: { label: string; kind?: string; base_url?: string; api_key?: string; model_id: string }) =>
request<AiModelEntry>("/models", { method: "POST", body: JSON.stringify(data) }),
updateModel: (name: string, data: Partial<{ label: string; kind: string; base_url: string; api_key: string; model_id: string }>) =>
request<AiModelEntry>(`/models/${name}`, { method: "PUT", body: JSON.stringify(data) }),
deleteModel: (name: string) =>
request<{ deleted: string }>(`/models/${name}`, { method: "DELETE" }),
testModel: (name: string) =>
request<{ ok: boolean; error?: string; model?: string; reply?: string }>(`/models/${name}/test`, { method: "POST" }),
importModels: (baseUrl: string, apiKey: string) =>
request<{ models: { id: string; name: string }[]; count: number }>("/models/import", {
method: "POST",
body: JSON.stringify({ base_url: baseUrl, api_key: apiKey }),
}),
};
export function formatCharCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M 字`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K 字`;
return `${n} 字`;
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}