feat: 全局「第二大脑」AI 助手(跨书跨对话 + bm_* 工具 + DSML 兜底)

- Conversation 加 kind(book/assistant)、book_id 可空;SQLite 老表迁移保留数据
- rag/engine.py: search_all_books 跨书混合检索;citations 带 book_title 出处
- app/assistant/service.py: 知识库概览 + 第二大脑 persona + 原生工具
  (bm_list_conversations / bm_read_conversation / bm_search_books),并入 MCP agent 循环
- chat.py: create/list 支持 kind;send_message 委托助手管线
- llm/hub.py: 解析 DeepSeek DSML 文本工具调用(模型非确定性输出标准/DSML)
- mcp/agent.py: 工具轮中段追加强制文本收尾,防模型反复调工具不出答案
- 前端: 侧栏「AI 助手」入口 + 抽取共享 ChatPanel 复用书/助手两套对话 UI

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-25 03:21:22 +08:00
co-authored by Claude Code
parent fcdc3d4f93
commit 2cda214170
11 changed files with 1414 additions and 471 deletions
+41 -16
View File
@@ -16,6 +16,7 @@ from app.rag.engine import (
estimate_tokens, match_chapters, _trim_messages, estimate_tokens, match_chapters, _trim_messages,
) )
from app.mcp.agent import agent_stream, build_mcp_context, execute_mcp_tool from app.mcp.agent import agent_stream, build_mcp_context, execute_mcp_tool
from app.assistant.service import stream_assistant
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -23,7 +24,9 @@ router = APIRouter(prefix="/chat", tags=["chat"])
class CreateConversationRequest(BaseModel): class CreateConversationRequest(BaseModel):
book_id: str # kind="book" 绑定单本书(book_id 必填);kind="assistant" 为全局「第二大脑」对话
kind: str = "book"
book_id: str | None = None
title: str | None = None title: str | None = None
model: str = "claude" model: str = "claude"
@@ -38,7 +41,8 @@ class SendMessageRequest(BaseModel):
class ConversationOut(BaseModel): class ConversationOut(BaseModel):
id: str id: str
book_id: str kind: str
book_id: str | None
title: str | None title: str | None
model: str model: str
created_at: str created_at: str
@@ -62,29 +66,42 @@ class MessageOut(BaseModel):
@router.get("/conversations", response_model=list[ConversationOut]) @router.get("/conversations", response_model=list[ConversationOut])
async def list_conversations(book_id: str | None = None, db: AsyncSession = Depends(get_db)): async def list_conversations(
book_id: str | None = None,
kind: str | None = None,
db: AsyncSession = Depends(get_db),
):
q = select(Conversation).order_by(Conversation.updated_at.desc()) q = select(Conversation).order_by(Conversation.updated_at.desc())
if book_id: if book_id:
q = q.where(Conversation.book_id == book_id) q = q.where(Conversation.book_id == book_id)
if kind:
q = q.where(Conversation.kind == kind)
result = await db.execute(q) result = await db.execute(q)
convs = result.scalars().all() convs = result.scalars().all()
return [ConversationOut( return [ConversationOut(
id=c.id, book_id=c.book_id, title=c.title, model=c.model, id=c.id, kind=c.kind, book_id=c.book_id, title=c.title, model=c.model,
created_at=c.created_at.isoformat() if c.created_at else "", created_at=c.created_at.isoformat() if c.created_at else "",
) for c in convs] ) for c in convs]
@router.post("/conversations", response_model=ConversationOut) @router.post("/conversations", response_model=ConversationOut)
async def create_conversation(req: CreateConversationRequest, db: AsyncSession = Depends(get_db)): async def create_conversation(req: CreateConversationRequest, db: AsyncSession = Depends(get_db)):
book = await db.get(Book, req.book_id) if req.kind == "assistant":
if not book: # 全局「第二大脑」对话:不绑定书
raise HTTPException(404, "书不存在") book_id = None
conv = Conversation(book_id=req.book_id, title=req.title or f"与《{book.title}》的对话", model=req.model) title = req.title or "与 AI 助手的对话"
else:
book = await db.get(Book, req.book_id)
if not book:
raise HTTPException(404, "书不存在")
book_id = req.book_id
title = req.title or f"与《{book.title}》的对话"
conv = Conversation(book_id=book_id, title=title, model=req.model, kind=req.kind)
db.add(conv) db.add(conv)
await db.commit() await db.commit()
await db.refresh(conv) await db.refresh(conv)
return ConversationOut( return ConversationOut(
id=conv.id, book_id=conv.book_id, title=conv.title, model=conv.model, id=conv.id, kind=conv.kind, book_id=conv.book_id, title=conv.title, model=conv.model,
created_at=conv.created_at.isoformat() if conv.created_at else "", created_at=conv.created_at.isoformat() if conv.created_at else "",
) )
@@ -107,10 +124,6 @@ async def send_message(conv_id: str, req: SendMessageRequest, db: AsyncSession =
if not conv: if not conv:
raise HTTPException(404, "会话不存在") raise HTTPException(404, "会话不存在")
book = await db.get(Book, conv.book_id)
if not book:
raise HTTPException(404, "书不存在")
model_name = req.model or conv.model model_name = req.model or conv.model
# 重试:复用被重试的用户消息(不新建),新回答作为它的新版本 # 重试:复用被重试的用户消息(不新建),新回答作为它的新版本
@@ -125,6 +138,17 @@ async def send_message(conv_id: str, req: SendMessageRequest, db: AsyncSession =
db.add(user_msg) db.add(user_msg)
await db.commit() await db.commit()
# 全局「第二大脑」对话:book_id 为空,走独立管线(跨书检索 + 知识库概览 + 原生工具 + MCP)
if conv.kind == "assistant":
provider = await resolve_provider(db, model_name)
if provider is None:
raise HTTPException(400, f"模型 {model_name} 未配置,请先在页面「管理模型」中添加或选择可用模型")
return await stream_assistant(conv, user_msg, req.content, db, provider)
book = await db.get(Book, conv.book_id)
if not book:
raise HTTPException(404, "书不存在")
chapters = (await db.execute( chapters = (await db.execute(
select(Chapter).where(Chapter.book_id == book.id).order_by(Chapter.order_idx) select(Chapter).where(Chapter.book_id == book.id).order_by(Chapter.order_idx)
)).scalars().all() )).scalars().all()
@@ -325,7 +349,7 @@ async def branch_conversation(conv_id: str, from_message_id: str = Query(...), d
conv = await db.get(Conversation, conv_id) conv = await db.get(Conversation, conv_id)
if not conv: if not conv:
raise HTTPException(404, "会话不存在") raise HTTPException(404, "会话不存在")
branch = Conversation(book_id=conv.book_id, title=f"{conv.title or '对话'} (分支)", model=conv.model) branch = Conversation(book_id=conv.book_id, title=f"{conv.title or '对话'} (分支)", model=conv.model, kind=conv.kind)
db.add(branch) db.add(branch)
await db.flush() await db.flush()
@@ -364,9 +388,10 @@ async def export_conversation(conv_id: str, fmt: str = "markdown", db: AsyncSess
select(Message).where(Message.conversation_id == conv_id).order_by(Message.created_at) select(Message).where(Message.conversation_id == conv_id).order_by(Message.created_at)
) )
msgs = result.scalars().all() msgs = result.scalars().all()
book = await db.get(Book, conv.book_id) book = await db.get(Book, conv.book_id) if conv.book_id else None
lines = [f"# {conv.title or '对话'}", f"书籍: {book.title if book else '未知'}\n"] book_label = "全局助手" if conv.kind == "assistant" else (book.title if book else "未知")
lines = [f"# {conv.title or '对话'}", f"书籍: {book_label}\n"]
for m in msgs: for m in msgs:
role_label = "🧑 用户" if m.role == "user" else "🤖 AI" role_label = "🧑 用户" if m.role == "user" else "🤖 AI"
# 把 mermaid mindmap 转成 flowchart LR:老版阅读器渲染 mindmap 是放射状粗线,很难读 # 把 mermaid mindmap 转成 flowchart LR:老版阅读器渲染 mindmap 是放射状粗线,很难读
View File
+466
View File
@@ -0,0 +1,466 @@
"""assistant — 全局 AI 助手(第二大脑)管线
与单书对话相对:不绑定某本书。每次请求注入「知识库概览」+ 跨书检索段,
并给模型提供三个原生内部工具(bm_*,走与 MCP 工具同一套 function-calling 循环):
- bm_list_conversations:遍历全部对话(摸清时间/精力去向)
- bm_read_conversation:读取某次对话完整原文
- bm_search_books:跨全部书籍检索原文
外部信息继续走 MCP 工具。不支持的 provider(Anthropic 未接工具循环)则纯流式回答,
行为与单书对话一致。
"""
from __future__ import annotations
import json
import logging
from typing import AsyncGenerator, Awaitable
from fastapi.responses import StreamingResponse
from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.llm.hub import LLMProvider
from app.mcp.agent import build_mcp_context, execute_mcp_tool, agent_stream
from app.models.book import Book, Chapter, Segment
from app.models.chat import Conversation, Message
from app.rag.engine import (
search_all_books, parse_citations, estimate_tokens, _trim_messages,
)
logger = logging.getLogger(__name__)
NATIVE_PREFIX = "bm_"
NATIVE_TOOL_RESULT_CHARS = 6000 # 单条原生工具结果上限,与 MCP 的 TOOL_RESULT_CHARS 一致
DIGEST_BUDGET_TOKENS = 6000 # 「知识库概览」system 注入预算
# ======================= 知识库概览 =======================
async def _conversation_stats(db: AsyncSession, conv_ids: list[str]) -> dict[str, dict]:
"""每会话的消息数与最后一条消息时间。"""
if not conv_ids:
return {}
rows = await db.execute(
select(Message.conversation_id, func.count(Message.id), func.max(Message.created_at))
.where(Message.conversation_id.in_(conv_ids))
.group_by(Message.conversation_id)
)
return {r[0]: {"count": r[1], "last": r[2]} for r in rows.all()}
async def _recent_message_digest(db: AsyncSession, conv_id: str, limit: int = 2) -> str:
"""会话末尾最近几条消息的紧凑摘要(预算内注入,帮模型感知聊了什么)。"""
rows = await db.execute(
select(Message)
.where(Message.conversation_id == conv_id)
.order_by(Message.created_at.desc())
.limit(limit)
)
msgs = list(rows.scalars().all())
msgs.reverse()
parts = []
for m in msgs:
who = "🧑 用户" if m.role == "user" else "🤖 AI"
text = (m.content or "").replace("\n", " ")[:120]
parts.append(f" {who}:{text}")
return "\n".join(parts)
def _fmt_time(dt) -> str:
return dt.strftime("%Y-%m-%d %H:%M") if dt else ""
async def build_assistant_context(db: AsyncSession) -> tuple[str, list[Book]]:
"""生成「知识库概览」文本 + 全书列表。
概览包含:书库规模、每本书的对话/消息/活跃情况、最近对话摘要、模型使用统计。
全程按 token 预算截断,避免每次请求塞大量原文。
"""
books = (await db.execute(select(Book).order_by(Book.created_at.desc()))).scalars().all()
book_map = {b.id: b for b in books}
convs = (await db.execute(
select(Conversation).order_by(Conversation.updated_at.desc())
)).scalars().all()
conv_stats = await _conversation_stats(db, [c.id for c in convs])
# 每本书聚合:会话数 / 消息数 / 最后活跃
per_book: dict[str, dict] = {}
for b in books:
per_book[b.id] = {"convs": 0, "msgs": 0, "last": None}
for c in convs:
st = per_book.setdefault(c.book_id, {"convs": 0, "msgs": 0, "last": None})
st["convs"] += 1
cs = conv_stats.get(c.id)
if cs:
st["msgs"] += cs["count"]
if cs["last"] and (st["last"] is None or cs["last"] > st["last"]):
st["last"] = cs["last"]
model_rows = (await db.execute(
select(Message.model, func.count(Message.id))
.group_by(Message.model)
.order_by(func.count(Message.id).desc())
)).all()
model_usage = [(r[0], r[1]) for r in model_rows if r[0]]
total_msgs = sum(st["msgs"] for st in per_book.values())
lines: list[str] = []
lines.append("## 知识库概览(自动生成)")
lines.append(f"- 共 {len(books)} 本书、{len(convs)} 次对话、{total_msgs} 条消息。")
if books:
lines.append("\n### 书籍")
for b in books:
st = per_book.get(b.id, {})
last = _fmt_time(st.get("last"))
suffix = f",最后活跃 {last}" if last else ""
lines.append(
f"- 《{b.title or b.filename}》({b.author or '未知作者'},约 {b.char_count:,} 字):"
f"{st.get('convs', 0)} 次对话 · {st.get('msgs', 0)} 条消息{suffix}"
)
used = estimate_tokens("\n".join(lines))
if convs:
conv_lines: list[str] = ["\n### 最近对话"]
for c in convs:
cs = conv_stats.get(c.id)
if not cs:
continue
book = book_map.get(c.book_id)
src = f"《{book.title or book.filename}》" if book else "全局助手"
head = f"- {src} / {c.title or '对话'}({cs['count']} 条消息,最后活跃 {_fmt_time(cs['last'])})"
recent = await _recent_message_digest(db, c.id)
if used + estimate_tokens(head) + estimate_tokens(recent) > DIGEST_BUDGET_TOKENS:
break # 预算内从新到旧追加,超预算即停
conv_lines.append(head)
if recent:
conv_lines.append(recent)
used += estimate_tokens(head) + estimate_tokens(recent)
if len(conv_lines) > 1:
lines.extend(conv_lines)
if model_usage:
lines.append("\n### 模型使用")
for name, cnt in model_usage:
lines.append(f"- {name or '未知'} × {cnt}")
return "\n".join(lines), books
# ======================= Prompt 组装 =======================
def build_assistant_prompt(
digest: str,
segments: list[Segment],
history: list[dict],
query: str,
) -> list[dict]:
system = (
"# 你是 BookMate 的第二大脑助手,用户跨书籍、跨对话的综合思考伙伴。\n\n"
"你的职责:\n"
"1. 梳理总结:帮用户看清时间和精力消耗在哪些书、哪些主题、哪些对话上,以及究竟获得了什么;\n"
"2. 连接知识:跨书籍、跨对话建立概念联系,把零散内容串成体系;\n"
"3. 回顾沉淀:复盘最近读了什么、讨论了什么、收获了什么,指出待办与存疑之处;\n"
"4. 需要书中具体内容时检索并引用原文;需要外部信息(时事/数据/网络)时调用外部工具。\n\n"
)
system += digest + "\n"
if segments:
system += "\n## 与问题直接相关的全书检索段落(含出处)\n"
for i, seg in enumerate(segments, 1):
bt = getattr(seg, "_book_title", None)
ct = getattr(seg, "_chapter_title", None)
src = f"{bt}{'·' + ct if ct else ''}" if bt else seg.book_id
system += f"\n[[ref:{i}]] [{src}] {seg.text[:800].replace(chr(10), ' ')}\n"
else:
system += (
"\n## 检索说明\n"
"本次没有检索到与问题直接匹配的原文。请基于上方知识库概览与你的判断回答,"
"并明确说明哪些是推断。\n"
)
system += (
"\n## 回答要求\n"
"1. 区分信息来源:「书中原文」带 [[ref:N]] 引用;「对话记录」说明是哪次对话;「外部工具」注明来源与时效\n"
"2. 引用原文时用 [[ref:N]](N 对应上方段落编号),同一段只引用一次,整体引用控制在 5 条左右\n"
"3. 用中文回答;检索不到就诚实说明,不要编造统计数字或原文\n"
"4. 涉及精力/时间/收获的梳理时,尽量给出有依据的归纳,标注哪些来自对话记录、哪些是推断"
)
messages = [{"role": "system", "content": system}]
if history:
messages.extend(history[-10:])
messages.append({"role": "user", "content": query})
return messages
# ======================= 原生内部工具 =======================
NATIVE_TOOLS: list[dict] = [
{
"type": "function",
"function": {
"name": "bm_search_books",
"description": "在用户的全部书籍中做全文检索,返回相关段落(每条带书名与章节)。当用户问题需要书中具体原文/出处时调用。",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "要检索的问题或关键词(中文亦可)"},
"top_k": {"type": "integer", "description": "返回段落条数,默认 6,最大 15"},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "bm_read_conversation",
"description": "读取某一次完整对话的原文(该对话所有用户与 AI 消息)。conversation_id 从 bm_list_conversations 或知识库概览中获得。",
"parameters": {
"type": "object",
"properties": {
"conversation_id": {"type": "string", "description": "要读取的对话 id"},
},
"required": ["conversation_id"],
},
},
},
{
"type": "function",
"function": {
"name": "bm_list_conversations",
"description": "列出用户的全部对话(可按书名/标题/消息关键词过滤),含每段对话的书名、消息数与最后活跃时间。用于梳理时间和精力都花在哪些书、哪些话题上。",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "可选过滤关键词(匹配书名、标题、消息内容)"},
"limit": {"type": "integer", "description": "最多返回的对话数,默认 20,最大 50"},
},
},
},
},
]
async def _tool_search_books(db: AsyncSession, args: dict) -> str:
query = str(args.get("query") or "").strip()
if not query:
return "[bm_search_books] 缺少 query 参数"
top_k = max(1, min(int(args.get("top_k") or 6), 15))
books = (await db.execute(select(Book))).scalars().all()
segs = await search_all_books(db, query, top_k=top_k, books=books)
if not segs:
return "[bm_search_books] 没有检索到相关段落"
ch_ids = {s.chapter_id for s in segs}
chapters = (await db.execute(select(Chapter).where(Chapter.id.in_(ch_ids)))).scalars().all()
ch_map = {c.id: c for c in chapters}
book_map = {b.id: b for b in books}
lines = []
for i, s in enumerate(segs, 1):
book = book_map.get(s.book_id)
ch = ch_map.get(s.chapter_id)
if book:
src = f"《{book.title or book.filename}》{(' / ' + ch.title) if ch and ch.title else ''}"
else:
src = s.book_id
lines.append(f"[{i}] {src}:{s.text[:300].replace(chr(10), ' ')}")
return "\n".join(lines)
async def _tool_read_conversation(db: AsyncSession, args: dict) -> str:
conv_id = str(args.get("conversation_id") or "").strip()
conv = await db.get(Conversation, conv_id)
if not conv:
return "[bm_read_conversation] 对话不存在"
book = await db.get(Book, conv.book_id) if conv.book_id else None
msgs = (await db.execute(
select(Message).where(Message.conversation_id == conv_id).order_by(Message.created_at)
)).scalars().all()
title = f"《{book.title or book.filename}》" if book else "全局助手"
lines = [f"# {title} / {conv.title or '对话'}(共 {len(msgs)} 条消息)"]
for m in msgs:
who = "🧑 用户" if m.role == "user" else "🤖 AI"
lines.append(f"{who}:{(m.content or '')[:600].replace(chr(10), ' ')}")
return "\n".join(lines)[:NATIVE_TOOL_RESULT_CHARS]
async def _tool_list_conversations(db: AsyncSession, args: dict) -> str:
query = str(args.get("query") or "").strip()
limit = max(1, min(int(args.get("limit") or 20), 50))
q = select(Conversation).order_by(Conversation.updated_at.desc())
if query:
books_match = select(Book.id).where(
or_(Book.title.contains(query), Book.filename.contains(query))
)
msgs_match = select(Message.conversation_id).where(Message.content.contains(query))
q = q.where(or_(
Conversation.title.contains(query),
Conversation.book_id.in_(books_match),
Conversation.id.in_(msgs_match),
))
convs = (await db.execute(q.limit(limit))).scalars().all()
if not convs:
return "[bm_list_conversations] 没有匹配的对话"
book_map = {b.id: b for b in (await db.execute(select(Book))).scalars().all()}
stats = await _conversation_stats(db, [c.id for c in convs])
lines = []
for c in convs:
book = book_map.get(c.book_id)
src = f"《{book.title or book.filename}》" if book else "全局助手"
st = stats.get(c.id, {})
last = _fmt_time(st.get("last"))
lines.append(
f"- [{c.id}] {src} / {c.title or '对话'}({st.get('count', 0)} 条消息"
+ (f",最后活跃 {last}" if last else "")
+ ")"
)
return "\n".join(lines)
async def execute_native_tool(db: AsyncSession, name: str, args: dict) -> str:
if name == "bm_search_books":
return await _tool_search_books(db, args)
if name == "bm_read_conversation":
return await _tool_read_conversation(db, args)
if name == "bm_list_conversations":
return await _tool_list_conversations(db, args)
return f"[内部工具] 未知工具:{name}"
def _dispatch_tool(db: AsyncSession, name: str, args: dict, mcp_ctx) -> Awaitable[str]:
"""按前缀分发:bm_ 内部工具,其余交给 MCP。返回可等待的工具结果。"""
if name.startswith(NATIVE_PREFIX):
return execute_native_tool(db, name, args)
if mcp_ctx is not None:
return execute_mcp_tool(mcp_ctx, name, args)
return _noop_tool_result(f"[工具] 无法执行:{name}")
async def _noop_tool_result(text: str) -> str:
return text
def _describe_tool(name: str, mcp_ctx) -> str:
if name.startswith(NATIVE_PREFIX):
return name
return mcp_ctx.display_name(name) if mcp_ctx is not None else name
# ======================= 流式回答 =======================
async def _annotate_segments(db: AsyncSession, books: list[Book], segments: list[Segment]) -> list[Segment]:
"""给跨书检索段临时标注书名/章节名,供 prompt 出处与 citations 使用。"""
if not segments:
return segments
book_map = {b.id: b for b in books}
ch_ids = {s.chapter_id for s in segments}
chapters = (await db.execute(select(Chapter).where(Chapter.id.in_(ch_ids)))).scalars().all()
ch_map = {c.id: c for c in chapters}
for s in segments:
b = book_map.get(s.book_id)
s._book_title = (b.title or b.filename) if b else None
c = ch_map.get(s.chapter_id)
s._chapter_title = c.title if c else None
return segments
async def stream_assistant(
conv: Conversation,
user_msg: Message,
content: str,
db: AsyncSession,
provider: LLMProvider,
) -> StreamingResponse:
"""全局助手对话的 SSE 流式回答。SSE 事件格式与单书对话完全一致。"""
digest, books = await build_assistant_context(db)
segments = await _annotate_segments(
db, books, await search_all_books(db, content, top_k=10, books=books)
)
# 本会话历史:正常发送时当前 user 消息是最后一条,去掉它;
# 重试时它可能是中间某条,历史只取它之前(旧版本回答不进 prompt)
history_rows = (await db.execute(
select(Message).where(Message.conversation_id == conv.id).order_by(Message.created_at)
)).scalars().all()
ids = {m.id for m in history_rows}
if user_msg.id in ids:
idx = next(i for i, m in enumerate(history_rows) if m.id == user_msg.id)
history = [{"role": m.role, "content": m.content} for m in history_rows[:idx]]
else:
history = [{"role": m.role, "content": m.content} for m in history_rows[:-1]]
messages = build_assistant_prompt(digest, segments, history, content)
mcp_ctx = await build_mcp_context(db) if provider.supports_tools else None
tools: list[dict] = []
if provider.supports_tools:
tools = list(NATIVE_TOOLS)
if mcp_ctx is not None:
tools.extend(mcp_ctx.ai_tools)
system = messages[0]["content"]
system += (
"\n\n## 内部工具\n"
"你有三个内部工具可以调用:\n"
"- bm_list_conversations:列出全部对话(可按关键词过滤),摸清时间精力去向;\n"
"- bm_read_conversation:读取某次对话的完整原文;\n"
"- bm_search_books:跨全部书籍检索原文段落。\n"
"涉及「读了什么 / 收获 / 时间精力去向」的梳理时,先调用工具核实再回答。\n"
)
if mcp_ctx is not None:
system += (
"\n## 外部信息工具\n"
f"需要书中/对话之外的外部信息(最新动态、时事、网络资料、行业数据等)时,"
f"可调用:{', '.join(mcp_ctx.names)}\n"
"- 外部信息要说明来源与时效;回答主体仍以书中内容与对话记录为准。\n"
)
messages[0]["content"] = system
total_tokens = estimate_tokens("".join(m["content"] for m in messages))
if total_tokens > provider.max_context * 0.8:
messages = _trim_messages(messages, int(provider.max_context * 0.8))
async def stream_response() -> AsyncGenerator[str, None]:
full_content = ""
try:
if provider.supports_tools:
async for kind, payload in agent_stream(
messages,
provider,
tools,
execute=lambda name, args: _dispatch_tool(db, name, args, mcp_ctx),
describe=lambda name: _describe_tool(name, mcp_ctx),
):
if kind == "token":
full_content += payload
yield f"data: {json.dumps({'type': 'token', 'content': payload}, ensure_ascii=False)}\n\n"
elif kind == "tool":
yield f"data: {json.dumps({'type': 'tool', 'name': payload, 'status': 'start'}, ensure_ascii=False)}\n\n"
elif kind == "tool_done":
yield f"data: {json.dumps({'type': 'tool', 'name': payload, 'status': 'done'}, ensure_ascii=False)}\n\n"
else:
async for token in provider.chat_stream(messages, temperature=0.7):
full_content += token
yield f"data: {json.dumps({'type': 'token', 'content': token}, ensure_ascii=False)}\n\n"
except Exception as e:
logger.exception("助手流式生成失败(conversation=%s)", conv.id)
yield f"data: {json.dumps({'type': 'error', 'content': str(e)}, ensure_ascii=False)}\n\n"
return
parsed = parse_citations(full_content, segments)
assistant_msg = Message(
conversation_id=conv.id,
parent_message_id=user_msg.id,
role="assistant",
content=parsed["text"],
citations=json.dumps(parsed["citations"], ensure_ascii=False),
model=user_msg.model or conv.model,
)
db.add(assistant_msg)
await db.commit()
await db.refresh(assistant_msg)
yield f"data: {json.dumps({'type': 'done', 'message_id': assistant_msg.id, 'citations': parsed['citations']}, ensure_ascii=False)}\n\n"
return StreamingResponse(stream_response(), media_type="text/event-stream")
+27 -1
View File
@@ -20,13 +20,39 @@ async def init_db():
async def _migrate(): async def _migrate():
"""轻量迁移:SQLite 下给已有 segments 表补 embedding 列(create_all 不会改老表)。""" """轻量迁移:SQLite 下补列 / 重建表(create_all 不会改老表)。"""
async with engine.begin() as conn: async with engine.begin() as conn:
rows = (await conn.exec_driver_sql("PRAGMA table_info(segments)")).all() rows = (await conn.exec_driver_sql("PRAGMA table_info(segments)")).all()
cols = {row[1] for row in rows} cols = {row[1] for row in rows}
if rows and "embedding" not in cols: if rows and "embedding" not in cols:
await conn.exec_driver_sql("ALTER TABLE segments ADD COLUMN embedding TEXT") await conn.exec_driver_sql("ALTER TABLE segments ADD COLUMN embedding TEXT")
# conversations:全局「第二大脑」对话需要 book_id 可空 + kind 列(book/assistant)。
# SQLite 无法 ALTER 去掉 NOT NULL,这里整表重建(保留数据,旧会话统一 kind='book');
# 新建库由 create_all 直接按新 ORM schema 建表,走到这里时 kind 已存在 → 跳过。
rows = (await conn.exec_driver_sql("PRAGMA table_info(conversations)")).all()
cols = {row[1] for row in rows}
if rows and "kind" not in cols:
await conn.exec_driver_sql("ALTER TABLE conversations RENAME TO conversations_old")
await conn.exec_driver_sql(
"CREATE TABLE conversations ("
" id VARCHAR(36) NOT NULL PRIMARY KEY,"
" book_id VARCHAR(36),"
" title VARCHAR(500),"
" model VARCHAR(50) NOT NULL,"
" created_at DATETIME DEFAULT CURRENT_TIMESTAMP,"
" updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,"
" kind VARCHAR(20) DEFAULT 'book',"
" FOREIGN KEY (book_id) REFERENCES books (id) ON DELETE CASCADE"
")"
)
await conn.exec_driver_sql(
"INSERT INTO conversations (id, book_id, title, model, created_at, updated_at, kind)"
" SELECT id, book_id, title, model, created_at, updated_at, 'book'"
" FROM conversations_old"
)
await conn.exec_driver_sql("DROP TABLE conversations_old")
async def get_db(): async def get_db():
async with async_session() as session: async with async_session() as session:
+77 -3
View File
@@ -1,11 +1,49 @@
import json import json
import os import os
import re
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import AsyncGenerator from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
# DeepSeek 部分模型会以「纯文本 content」输出 DSML 工具调用而不是标准 tool_calls:
# <||DSML|| calls>
# <||DSML|| invoke name="tool">
# <||DSML|| parameter name="k" string="true">v</||DSML|| parameter>
# </||DSML|| invoke>
# </||DSML|| calls>
# 这里把 DSML 块解析成 OpenAI 兼容的 tool_calls,保证工具循环不中断(模型选型不确定时也能兜底)。
DSML_CALLS_START = "<||DSML|| calls>"
DSML_CALLS_END = "</||DSML|| calls>"
_DSML_CALLS_RE = re.compile(r"<||DSML|| calls>(.*?)</||DSML|| calls>", re.DOTALL)
_DSML_INVOKE_RE = re.compile(r'<||DSML|| invoke name="([^"]+)">(.*?)</||DSML|| invoke>', re.DOTALL)
_DSML_PARAM_RE = re.compile(r'<||DSML|| parameter name="([^"]+)"(?: string="true")?>(.*?)</||DSML|| parameter>', re.DOTALL)
def _parse_dsml_calls(block: str) -> list[dict]:
"""把一段 DSML calls 块解析成 OpenAI 兼容的 tool_calls 列表(参数统一按字符串)。
id 必须生成真实值:后续第二轮会带 role="tool" 的消息(tool_call_id 指向它),
空 id 会导致模型 API 拒绝、工具循环第二轮中断。
"""
calls: list[dict] = []
m = _DSML_CALLS_RE.search(block)
if not m:
return calls
for n, inv in enumerate(_DSML_INVOKE_RE.finditer(m.group(1))):
name = inv.group(1).strip()
args: dict = {}
for pm in _DSML_PARAM_RE.finditer(inv.group(2)):
key = pm.group(1).strip()
args[key] = pm.group(2).strip()
calls.append({
"id": f"call_{name}_{n}",
"type": "function",
"function": {"name": name, "arguments": json.dumps(args, ensure_ascii=False)},
})
return calls
@dataclass @dataclass
class ChatResponse: class ChatResponse:
@@ -83,6 +121,8 @@ class OpenAIProvider(LLMProvider):
json=payload, json=payload,
) as resp: ) as resp:
tool_calls_acc: dict[int, dict] = {} tool_calls_acc: dict[int, dict] = {}
dsml_buf = ""
in_dsml = False
async for line in resp.aiter_lines(): async for line in resp.aiter_lines():
if line.startswith("data: "): if line.startswith("data: "):
data_str = line[6:] data_str = line[6:]
@@ -97,8 +137,38 @@ class OpenAIProvider(LLMProvider):
delta = choices[0].get("delta", {}) delta = choices[0].get("delta", {})
content = delta.get("content", "") content = delta.get("content", "")
if content: if content:
self.last_content += content dsml_buf += content
yield content if in_dsml:
# 正在 DSML 块内:等结束标记后解析工具调用
end = dsml_buf.find(DSML_CALLS_END)
if end != -1:
block = dsml_buf[: end + len(DSML_CALLS_END)]
self.tool_calls.extend(_parse_dsml_calls(block))
tail = dsml_buf[end + len(DSML_CALLS_END):]
in_dsml = False
dsml_buf = tail
# 块后的残余文本(罕见)按正常内容继续
if dsml_buf:
self.last_content += dsml_buf
yield dsml_buf
dsml_buf = ""
else:
idx = dsml_buf.find(DSML_CALLS_START)
if idx != -1:
before = dsml_buf[:idx]
if before:
self.last_content += before
yield before
dsml_buf = dsml_buf[idx:]
in_dsml = True
else:
# 未发现 DSML 标记:yield 除检测窗口外的文本(跨 chunk 检测需保留尾部)
keep = len(DSML_CALLS_START) - 1
if len(dsml_buf) > keep:
head = dsml_buf[:-keep]
self.last_content += head
yield head
dsml_buf = dsml_buf[-keep:]
# 流式 tool_calls:每个 index 首个 delta 带 id/name,后续增量拼 arguments # 流式 tool_calls:每个 index 首个 delta 带 id/name,后续增量拼 arguments
for tc in delta.get("tool_calls") or []: for tc in delta.get("tool_calls") or []:
idx = tc.get("index", 0) idx = tc.get("index", 0)
@@ -114,8 +184,12 @@ class OpenAIProvider(LLMProvider):
entry["function"]["arguments"] += fn["arguments"] entry["function"]["arguments"] += fn["arguments"]
except json.JSONDecodeError: except json.JSONDecodeError:
continue continue
# 流结束:把检测窗口残留的文本冲出来,避免丢失内容
if dsml_buf:
self.last_content += dsml_buf
yield dsml_buf
if tool_calls_acc: if tool_calls_acc:
self.tool_calls = [tool_calls_acc[i] for i in sorted(tool_calls_acc)] self.tool_calls.extend(tool_calls_acc[i] for i in sorted(tool_calls_acc))
class AnthropicProvider(LLMProvider): class AnthropicProvider(LLMProvider):
+13 -2
View File
@@ -154,7 +154,18 @@ async def agent_stream(
- ("tool", "<服务器>/<工具>") —— 工具调用开始 - ("tool", "<服务器>/<工具>") —— 工具调用开始
- ("tool_done", "<服务器>/<工具>") —— 工具执行完成 - ("tool_done", "<服务器>/<工具>") —— 工具执行完成
""" """
for _round in range(MAX_TOOL_ROUNDS): # 前 MAX_TOOL_ROUNDS 轮正常工具循环;若模型持续调工具仍不收尾(DeepSeek 用 DSML
# 反复输出工具调用尤甚),中段追加「强制收尾」指令再续同样轮数,之后兜底无工具流式。
total_rounds = MAX_TOOL_ROUNDS * 2
for _round in range(total_rounds):
if _round == MAX_TOOL_ROUNDS:
messages.append({
"role": "user",
"content": (
"(工具调用已结束)请直接基于以上已有信息,用中文给出最终回答,"
"不要再调用任何工具,也不要输出任何工具调用标记。"
),
})
provider.tool_calls = [] provider.tool_calls = []
provider.last_content = "" provider.last_content = ""
async for token in provider.chat_stream(messages, temperature=0.7, tools=tools): async for token in provider.chat_stream(messages, temperature=0.7, tools=tools):
@@ -192,6 +203,6 @@ async def agent_stream(
"tool_call_id": call["id"], "tool_call_id": call["id"],
"content": (out or "[工具无返回内容]")[:TOOL_RESULT_CHARS], "content": (out or "[工具无返回内容]")[:TOOL_RESULT_CHARS],
}) })
# 达到最大轮数仍未见收尾:用已收集上下文走无工具流式生成,保证必出最终答案 # 兜底:无工具流式生成,保证必出最终答案
async for token in provider.chat_stream(messages, temperature=0.7): async for token in provider.chat_stream(messages, temperature=0.7):
yield ("token", token) yield ("token", token)
+3 -1
View File
@@ -13,7 +13,9 @@ class Conversation(Base):
__tablename__ = "conversations" __tablename__ = "conversations"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid)
book_id: Mapped[str] = mapped_column(String(36), ForeignKey("books.id", ondelete="CASCADE")) # kind="book" 绑定具体书(book_id 必填);kind="assistant" 为全局「第二大脑」对话(book_id 为 NULL)
kind: Mapped[str] = mapped_column(String(20), default="book")
book_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("books.id", ondelete="CASCADE"), nullable=True)
title: Mapped[str | None] = mapped_column(String(500)) title: Mapped[str | None] = mapped_column(String(500))
model: Mapped[str] = mapped_column(String(50), default="claude") model: Mapped[str] = mapped_column(String(50), default="claude")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+97 -5
View File
@@ -65,15 +65,18 @@ def match_chapters(chapters: list[Chapter], query: str, tokens: list[str] | None
return hits return hits
async def _keyword_candidates(db: AsyncSession, book_id: str, tokens: list[str], limit: int) -> list[Segment]: async def _keyword_candidates(db: AsyncSession, book_ids: list[str], tokens: list[str], limit: int) -> list[Segment]:
"""关键词候选:SQL LIKE 召回(含整句子串),按命中词数 + 长词加权打分。""" """关键词候选:SQL LIKE 召回(含整句子串),按命中词数 + 长词加权打分。
if not tokens:
book_ids 传单个书名 id 的列表(单书检索)或多个 id(跨书检索)。
"""
if not tokens or not book_ids:
return [] return []
ordered = sorted(tokens, key=len, reverse=True)[:8] ordered = sorted(tokens, key=len, reverse=True)[:8]
conds = [Segment.text.contains(w) for w in ordered] conds = [Segment.text.contains(w) for w in ordered]
q = ( q = (
select(Segment) select(Segment)
.where(Segment.book_id == book_id, or_(*conds)) .where(Segment.book_id.in_(book_ids), or_(*conds))
.order_by(Segment.order_idx) .order_by(Segment.order_idx)
.limit(limit * 4) .limit(limit * 4)
) )
@@ -124,7 +127,7 @@ async def search_segments(
status = "failed" status = "failed"
# 1. 关键词候选 # 1. 关键词候选
kw_candidates = await _keyword_candidates(db, book_id, tokens, top_k * 3) kw_candidates = await _keyword_candidates(db, [book_id], tokens, top_k * 3)
# 2. 向量候选 # 2. 向量候选
vec_candidates: list[Segment] = [] vec_candidates: list[Segment] = []
@@ -163,6 +166,93 @@ async def search_segments(
return merged[:top_k] return merged[:top_k]
async def _empty_fallback_all(db: AsyncSession, books: list[Book], top_k: int) -> list[Segment]:
"""跨书空检索兜底:每本书取最先的非空开头段,保证 AI 至少有所依据。"""
out: list[Segment] = []
for b in books:
result = await db.execute(
select(Segment)
.where(Segment.book_id == b.id)
.order_by(Segment.order_idx)
.limit(top_k)
)
for s in result.scalars().all():
if (s.text or "").strip():
out.append(s)
break
return out[:top_k]
async def search_all_books(
db: AsyncSession,
query: str,
top_k: int = 12,
books: list[Book] | None = None,
) -> list[Segment]:
"""跨书混合检索(全局「第二大脑」用):关键词跨书一次 SQL + 每书向量余弦 → RRF → rerank。
与 search_segments 的区别:
- 不调用 ensure_embeddings(全局检索不阻塞向量生成,缺向量的书靠关键词兜底);
- 向量部分逐书扫描后合并(query embedding 只算一次)。
"""
tokens = _tokenize(query)
if books is None:
result = await db.execute(select(Book).order_by(Book.created_at))
books = list(result.scalars().all())
book_ids = [b.id for b in books]
if not book_ids:
return []
# 1. 关键词候选(一次 SQL 跨全部书)
kw_candidates = await _keyword_candidates(db, book_ids, tokens, top_k * 4)
# 2. 向量候选(每书全量余弦,合并)
vec_candidates: list[Segment] = []
if tokens:
qvecs = await embed_texts([query])
qvec = qvecs[0] if qvecs else None
if qvec:
for bid in book_ids:
mat, vec_segments = await load_vectors(db, bid)
if mat is not None and len(mat) > 0:
idxs = vector_search(qvec, mat, top_k * 2)
vec_candidates.extend(vec_segments[i] for i in idxs)
# 3. RRF 融合
if kw_candidates and vec_candidates:
by_id = {s.id: s for s in kw_candidates + vec_candidates}
fused = rrf_fuse(
[s.id for s in kw_candidates],
[s.id for s in vec_candidates],
)
merged = [by_id[sid] for sid in fused if sid in by_id]
elif vec_candidates:
merged = vec_candidates
else:
merged = kw_candidates
# 4. rerank 精排(对融合后的 top50 段打分)
if merged and RERANK_ENABLED:
docs = [s.text[:800] for s in merged[:50]]
idxs = await rerank_documents(query, docs, top_n=top_k)
if idxs is not None:
merged = [merged[i] for i in idxs if i < len(merged)]
# 5. 兜底:完全没召回时给各书开头段
if not merged:
merged = await _empty_fallback_all(db, books, top_k)
# 去重(跨书向量合并可能撞段)
seen: set[str] = set()
out: list[Segment] = []
for s in merged:
if s.id in seen:
continue
seen.add(s.id)
out.append(s)
return out[:top_k]
def _chapter_full_text(chapter_segments: list[Segment], budget_tokens: int = 6000) -> str: def _chapter_full_text(chapter_segments: list[Segment], budget_tokens: int = 6000) -> str:
"""整章注入:按 token 预算截取命中章节的原文。""" """整章注入:按 token 预算截取命中章节的原文。"""
parts = [] parts = []
@@ -274,6 +364,8 @@ def parse_citations(content: str, segments: list[Segment]) -> dict:
"segment_id": seg.id, "segment_id": seg.id,
"cfi": seg.cfi, "cfi": seg.cfi,
"excerpt": seg.text[:200], "excerpt": seg.text[:200],
# 跨书检索时由调用方在段上临时标注 _book_title,供前端显示出处
"book_title": getattr(seg, "_book_title", None),
}) })
clean_text = CITATION_RE.sub("", content) clean_text = CITATION_RE.sub("", content)
return {"text": clean_text, "citations": citations} return {"text": clean_text, "citations": citations}
+175 -438
View File
@@ -2,35 +2,67 @@
import { useState, useEffect, useMemo, useRef, useContext } from "react"; import { useState, useEffect, useMemo, useRef, useContext } from "react";
import { import {
BookMarked, BookOpen, Bot, Check, ChevronDown, ChevronLeft, ChevronRight, Copy, BookMarked, BookOpen, Bot, ChevronRight, Clock,
Download, GitBranch, Globe, Loader2, LogOut, Paperclip, Plus, RotateCcw, Send, Settings2, Download, GitBranch, Globe, Loader2, LogOut, Plus, RotateCcw, Send, Settings2,
Sparkles, Trash2, Upload, User, Wrench, X, ArrowUpRight, Library, Search, Menu, MessageSquare, ArrowLeft, Sparkles, Trash2, Upload, X, ArrowUpRight, Library, Search, Menu, MessageSquare, ArrowLeft,
} from "lucide-react"; } from "lucide-react";
import { api, Book, Conversation, Message, Citation, AiModelEntry, McpServer, formatCharCount } from "@/lib/api"; import { api, Book, Conversation, Message, Citation, AiModelEntry, McpServer, formatCharCount } from "@/lib/api";
import MarkdownRenderer from "@/components/MarkdownRenderer"; import ChatPanel, { buildTurns, WelcomeConfig, ActionBtn } from "@/components/ChatPanel";
import ModelManagerDialog from "@/components/ModelManagerDialog"; import ModelManagerDialog from "@/components/ModelManagerDialog";
import McpManagerDialog from "@/components/McpManagerDialog"; import McpManagerDialog from "@/components/McpManagerDialog";
import AuthGate, { AuthContext } from "@/components/AuthGate"; import AuthGate, { AuthContext } from "@/components/AuthGate";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import CitationReaderDialog from "@/components/CitationReaderDialog";
import EpubReader from "@/components/EpubReader"; import EpubReader from "@/components/EpubReader";
const SIDEBAR_MIN = 180; const SIDEBAR_MIN = 180;
const SIDEBAR_MAX = 480; const SIDEBAR_MAX = 480;
// 书模式欢迎屏基础配置(副标题按当前书动态生成)
const BOOK_WELCOME_BASE: Omit<WelcomeConfig, "subtitle"> = {
Icon: BookOpen,
eyebrow: "READ · THINK · DISCOVER",
title: "从一个好问题开始。",
placeholder: "问这本书的任何问题...",
listLabel: "对话历史",
newButtonText: "新建对话",
prompts: [
{ Icon: BookOpen, title: "读懂这本书", prompt: "请梳理这本书的核心观点与整体结构。" },
{ Icon: MessageSquare, title: "深入一个观点", prompt: "这本书中有哪些值得深入思考的观点?请结合原文展开。" },
{ Icon: GitBranch, title: "连接知识", prompt: "请整理这本书中重要概念之间的联系。" },
],
};
// 全局「第二大脑」助手欢迎屏
const ASSISTANT_WELCOME: WelcomeConfig = {
Icon: Sparkles,
eyebrow: "SECOND BRAIN · 第二大脑",
title: "问关于你读过的一切。",
subtitle: "跨书籍、跨对话的综合思考伙伴:帮你梳理时间和精力都花在了哪,究竟获得了什么。",
placeholder: "问我关于你读过的一切...",
listLabel: "助手对话",
newButtonText: "新建助手对话",
prompts: [
{ Icon: BookOpen, title: "复盘最近的阅读", prompt: "梳理我最近读了什么、讨论了什么、收获了什么。" },
{ Icon: Clock, title: "时间精力去向", prompt: "我的时间和精力主要消耗在哪些书和哪些话题上?" },
{ Icon: GitBranch, title: "待办与存疑", prompt: "列出我在对话中提到的待办、疑问和未完成的想法。" },
],
};
function BookmateApp() { function BookmateApp() {
const [books, setBooks] = useState<Book[]>([]); const [books, setBooks] = useState<Book[]>([]);
const [selectedBook, setSelectedBook] = useState<Book | null>(null); const [selectedBook, setSelectedBook] = useState<Book | null>(null);
// 全局「第二大脑」助手模式:与单书对话并列,不绑定书
const [assistantMode, setAssistantMode] = useState(false);
const [conversations, setConversations] = useState<Conversation[]>([]); const [conversations, setConversations] = useState<Conversation[]>([]);
const [activeConv, setActiveConv] = useState<Conversation | null>(null); const [activeConv, setActiveConv] = useState<Conversation | null>(null);
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState(""); // EpubReader「解读原文」→ 输入框的交接:ChatPanel 消费后清空
const [pendingInput, setPendingInput] = useState("");
const [streaming, setStreaming] = useState(false); const [streaming, setStreaming] = useState(false);
const [streamContent, setStreamContent] = useState(""); const [streamContent, setStreamContent] = useState("");
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [model, setModel] = useState("deepseek"); const [model, setModel] = useState("deepseek");
const [aiModels, setAiModels] = useState<AiModelEntry[]>([]); const [aiModels, setAiModels] = useState<AiModelEntry[]>([]);
const [pickerOpen, setPickerOpen] = useState(false);
const [managerOpen, setManagerOpen] = useState(false); const [managerOpen, setManagerOpen] = useState(false);
const [mcpOpen, setMcpOpen] = useState(false); const [mcpOpen, setMcpOpen] = useState(false);
const [mcpServers, setMcpServers] = useState<McpServer[]>([]); const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
@@ -42,7 +74,6 @@ function BookmateApp() {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [booksLoading, setBooksLoading] = useState(true); const [booksLoading, setBooksLoading] = useState(true);
const [booksError, setBooksError] = useState(false); const [booksError, setBooksError] = useState(false);
const chatEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [sidebarWidth, setSidebarWidth] = useState(256); const [sidebarWidth, setSidebarWidth] = useState(256);
const sidebarDragRef = useRef<{ startX: number; startW: number } | null>(null); const sidebarDragRef = useRef<{ startX: number; startW: number } | null>(null);
@@ -96,8 +127,6 @@ function BookmateApp() {
api.listMcpServers().then(setMcpServers).catch(() => setMcpServers([])); api.listMcpServers().then(setMcpServers).catch(() => setMcpServers([]));
}, []); }, []);
const currentModel = aiModels.find((m) => m.name === model) ?? aiModels[0];
// 恢复/保存侧栏宽度(localStorage) // 恢复/保存侧栏宽度(localStorage)
useEffect(() => { useEffect(() => {
const saved = localStorage.getItem("bookmate.sidebarWidth"); const saved = localStorage.getItem("bookmate.sidebarWidth");
@@ -116,8 +145,6 @@ function BookmateApp() {
if (activeConv) api.getMessages(activeConv.id).then(setMessages); if (activeConv) api.getMessages(activeConv.id).then(setMessages);
}, [activeConv]); }, [activeConv]);
useEffect(() => { chatEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, streamContent]);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
@@ -125,6 +152,7 @@ function BookmateApp() {
try { try {
const book = await api.uploadBook(file); const book = await api.uploadBook(file);
setBooks((prev) => [book, ...prev]); setBooks((prev) => [book, ...prev]);
setAssistantMode(false);
setSelectedBook(book); setSelectedBook(book);
} catch (err: unknown) { } catch (err: unknown) {
alert(err instanceof Error ? err.message : "Upload failed"); alert(err instanceof Error ? err.message : "Upload failed");
@@ -133,8 +161,29 @@ function BookmateApp() {
if (fileInputRef.current) fileInputRef.current.value = ""; if (fileInputRef.current) fileInputRef.current.value = "";
}; };
// 全局「第二大脑」助手模式
const enableAssistant = async () => {
setAssistantMode(true);
setSelectedBook(null);
setActiveConv(null);
setMessages([]);
setSidebarOpen(false);
const convs = await api.listConversations(undefined, "assistant");
setConversations(convs);
if (convs.length > 0) setActiveConv(convs[0]);
};
const enableLibrary = () => {
setAssistantMode(false);
setSelectedBook(null);
setActiveConv(null);
setMessages([]);
setSidebarOpen(false);
};
const startChat = async (book: Book) => { const startChat = async (book: Book) => {
setSidebarOpen(false); setSidebarOpen(false);
setAssistantMode(false);
setSelectedBook(book); setSelectedBook(book);
setActiveConv(null); setActiveConv(null);
setMessages([]); setMessages([]);
@@ -149,18 +198,19 @@ function BookmateApp() {
} }
}; };
const handleSend = async () => { const handleSend = async (content: string) => {
if (!input.trim() || streaming) return; if (!content.trim() || streaming) return;
let conv = activeConv; let conv = activeConv;
if (!conv && selectedBook) { if (!conv) {
conv = await api.createConversation(selectedBook.id, model); if (assistantMode) conv = await api.createConversation(null, model, "assistant");
else if (selectedBook) conv = await api.createConversation(selectedBook.id, model, "book");
if (!conv) return;
setConversations((prev) => [conv!, ...prev]); setConversations((prev) => [conv!, ...prev]);
setActiveConv(conv); setActiveConv(conv);
} }
if (!conv) return; if (!conv) return;
const userContent = input; const userContent = content;
setInput("");
const tempId = `temp-${Date.now()}`; 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() }]); 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); setStreaming(true);
@@ -299,10 +349,6 @@ function BookmateApp() {
clearTools(); clearTools();
}; };
const handleCopy = async (text: string) => {
await navigator.clipboard.writeText(text);
};
const handleDelete = async (msgId: string) => { const handleDelete = async (msgId: string) => {
if (!confirm("删除这条消息?其名下的回答版本也会一并删除。")) return; if (!confirm("删除这条消息?其名下的回答版本也会一并删除。")) return;
try { try {
@@ -326,9 +372,13 @@ function BookmateApp() {
}; };
const handleNewConversation = async () => { const handleNewConversation = async () => {
if (!selectedBook) return;
try { try {
const conv = await api.createConversation(selectedBook.id, model); const conv = assistantMode
? await api.createConversation(null, model, "assistant")
: selectedBook
? await api.createConversation(selectedBook.id, model, "book")
: null;
if (!conv) return;
setConversations((prev) => [conv, ...prev]); setConversations((prev) => [conv, ...prev]);
setActiveConv(conv); setActiveConv(conv);
setMessages([]); setMessages([]);
@@ -430,7 +480,8 @@ function BookmateApp() {
</div> </div>
<div className="px-4 pt-2 pb-5"> <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> <button className={`nav-library ${assistantMode ? "active" : ""}`} onClick={enableAssistant}><Bot size={18} /> AI 助手</button>
<button className={`nav-library ${!assistantMode && !selectedBook ? "active" : ""}`} onClick={enableLibrary}><Library size={18} /> 我的书库 <span>{books.length}</span></button>
<input ref={fileInputRef} type="file" accept=".epub" onChange={handleUpload} className="hidden" /> <input ref={fileInputRef} type="file" accept=".epub" onChange={handleUpload} className="hidden" />
<button <button
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
@@ -494,224 +545,108 @@ function BookmateApp() {
{/* Main */} {/* Main */}
<main className="main-panel flex-1 flex flex-col min-w-0"> <main className="main-panel flex-1 flex flex-col min-w-0">
{selectedBook ? ( {assistantMode ? (
<> <ChatPanel
<header className="reader-header border-b border-[var(--border)] flex items-center gap-3 shrink-0"> header={
<button className="back-library" onClick={() => setSelectedBook(null)} aria-label="返回书库"><ArrowLeft size={18} /></button> <header className="reader-header border-b border-[var(--border)] flex items-center gap-3 shrink-0">
<div className="flex-1 min-w-0"> <button className="back-library" onClick={enableLibrary} aria-label="返回书库"><ArrowLeft size={18} /></button>
<h2 className="text-[15px] font-semibold truncate text-[var(--fg)]">{selectedBook.title || selectedBook.filename}</h2> <div className="flex-1 min-w-0">
<p className="text-[11px] text-[var(--fg-muted)]">{selectedBook.author}</p> <h2 className="text-[15px] font-semibold truncate text-[var(--fg)]">AI 助手</h2>
</div> <p className="text-[11px] text-[var(--fg-muted)]">第二大脑 · 跨书籍跨对话的思考伙伴</p>
<button </div>
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 <button
onClick={handleNewConversation} onClick={handleExport}
disabled={!selectedBook} disabled={!activeConv}
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" 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="导出对话"
> >
<Plus size={12} /> <Download size={13} />
新建对话 导出
</button> </button>
{conversations.map((c) => ( </header>
<div key={c.id} className="flex items-center group"> }
<button conversations={conversations}
onClick={() => selectConv(c)} activeConv={activeConv}
className={`flex-1 min-w-0 text-left px-3 py-2 rounded-xl text-[13px] truncate text-[var(--fg)] transition-colors ${ messages={messages}
activeConv?.id === c.id ? "bg-[var(--accent-soft)]" : "hover:bg-[var(--bg-hover)]" turns={turns}
}`} streaming={streaming}
> streamContent={streamContent}
{c.title || "对话"} streamTurnId={streamTurnId}
</button> activeTools={activeTools}
<ActionBtn danger title="删除对话" onClick={() => handleDeleteConversation(c.id)}> activeVersions={activeVersions}
<X size={14} /> setActiveVersions={setActiveVersions}
</ActionBtn> aiModels={aiModels}
</div> model={model}
))} setModel={setModel}
</div> welcome={ASSISTANT_WELCOME}
onSelectConv={selectConv}
<div className="flex-1 flex flex-col min-w-0"> onNewConversation={handleNewConversation}
<div className="flex-1 overflow-y-auto px-6 pt-6 pb-10 space-y-6"> onDeleteConversation={handleDeleteConversation}
<div className="max-w-3xl mx-auto w-full space-y-6"> onSend={handleSend}
{turns.length === 0 && !streaming && ( onRetry={handleRetry}
<div className="chat-welcome"> onDelete={handleDelete}
<div className="chat-welcome-icon"><BookOpen size={30} strokeWidth={1.4} /></div> onBranch={handleBranch}
<span className="eyebrow">READ · THINK · DISCOVER</span> onManage={() => setManagerOpen(true)}
<h2>从一个好问题开始。</h2> externalInput={pendingInput}
<p>关于《{selectedBook.title || selectedBook.filename}》,你想了解什么?</p> onConsumeExternalInput={() => setPendingInput("")}
<div className="prompt-grid"> />
{[{ icon: BookOpen, title: "读懂这本书", prompt: "请梳理这本书的核心观点与整体结构。" }, { icon: MessageSquare, title: "深入一个观点", prompt: "这本书中有哪些值得深入思考的观点?请结合原文展开。" }, { icon: GitBranch, title: "连接知识", prompt: "请整理这本书中重要概念之间的联系。" }].map(({ icon: Icon, title, prompt }) => ( ) : selectedBook ? (
<button key={title} onClick={() => setInput(prompt)}><Icon size={19} /><strong>{title}</strong><span>{prompt}</span><ArrowUpRight size={15} /></button> <ChatPanel
))} header={
</div> <header className="reader-header border-b border-[var(--border)] flex items-center gap-3 shrink-0">
</div> <button className="back-library" onClick={enableLibrary} aria-label="返回书库"><ArrowLeft size={18} /></button>
)} <div className="flex-1 min-w-0">
{turns.map((turn) => { <h2 className="text-[15px] font-semibold truncate text-[var(--fg)]">{selectedBook.title || selectedBook.filename}</h2>
const isLastTurn = turn.user.id === turns[turns.length - 1]?.user.id; <p className="text-[11px] text-[var(--fg-muted)]">{selectedBook.author}</p>
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 || activeTools.length > 0);
return (
<div key={turn.user.id} className="space-y-1.5">
<ChatBubble
message={turn.user}
onCopy={handleCopy}
onRetry={isLastTurn ? handleRetry : undefined}
onDelete={handleDelete}
/>
{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 && (
<div className="pl-10 space-y-1">
{activeTools.length > 0 && <ToolChips tools={activeTools} />}
{streamContent && (
<ChatBubble
message={{ id: "stream", conversation_id: "", parent_message_id: turn.user.id, role: "assistant", content: streamContent, citations: null, model, created_at: "" }}
isStreaming={true}
/>
)}
</div>
)}
</div>
);
})}
{streaming && !streamTurnId && (streamContent || activeTools.length > 0) && (
<div className="max-w-3xl mx-auto space-y-1">
{activeTools.length > 0 && <ToolChips tools={activeTools} />}
{streamContent && (
<ChatBubble
message={{ id: "stream", conversation_id: "", parent_message_id: null, role: "assistant", content: streamContent, citations: null, model, created_at: "" }}
isStreaming={true}
/>
)}
</div>
)}
<div ref={chatEndRef} />
</div>
</div> </div>
<button
<div className="composer-area px-4 pt-3 pb-4"> onClick={() => setReaderBook(selectedBook)}
{/* 输入胶囊:模型选择 + 输入合并(Perplexity 风) */} className="open-full-book"
<div className="max-w-3xl mx-auto"> title="打开完整 EPUB"
<div className="composer rounded-2xl px-4 py-3"> >
<div className="flex items-center justify-between mb-1"> <BookOpen size={16} />
<div className="relative"> <span>阅读全书</span>
{pickerOpen && <div className="fixed inset-0 z-10" onClick={() => setPickerOpen(false)} />} </button>
<button <button
onClick={() => setPickerOpen((v) => !v)} onClick={handleExport}
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" disabled={!activeConv}
title={currentModel ? `${currentModel.model_id} · ${currentModel.base_url || currentModel.kind}` : (aiModels.length ? "" : "尚未配置模型")} 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="导出对话"
<Sparkles size={12} className="text-[var(--accent)]" /> >
<span className="max-w-40 truncate">{currentModel?.label ?? (aiModels.length ? model : "未配置模型")}</span> <Download size={13} />
<ChevronDown size={12} /> 导出
</button> </button>
{pickerOpen && ( </header>
<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> conversations={conversations}
{aiModels.map((m) => ( activeConv={activeConv}
<button messages={messages}
key={m.name} turns={turns}
onClick={() => { setModel(m.name); setPickerOpen(false); }} streaming={streaming}
className={`w-full flex items-center justify-between gap-2 px-2.5 py-2 rounded-lg text-left text-sm transition-colors ${ streamContent={streamContent}
model === m.name ? "bg-[var(--accent-soft)] text-[var(--accent)]" : "text-[var(--fg)] hover:bg-[var(--bg-hover)]" streamTurnId={streamTurnId}
}`} activeTools={activeTools}
> activeVersions={activeVersions}
<span className="truncate">{m.label}</span> setActiveVersions={setActiveVersions}
<span className="text-[10px] text-[var(--fg-muted)] shrink-0">{m.builtin ? "内置" : "自定义"}</span> aiModels={aiModels}
</button> model={model}
))} setModel={setModel}
{aiModels.length === 0 && ( welcome={{
<p className="px-2 py-3 text-xs text-[var(--fg-muted)] text-center">尚未配置模型,点「管理」添加</p> ...BOOK_WELCOME_BASE,
)} subtitle: `关于《${selectedBook.title || selectedBook.filename}》,你想了解什么?`,
</div> }}
)} onSelectConv={selectConv}
</div> onNewConversation={handleNewConversation}
<button onDeleteConversation={handleDeleteConversation}
onClick={() => setManagerOpen(true)} onSend={handleSend}
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" onRetry={handleRetry}
> onDelete={handleDelete}
<Settings2 size={12} /> onBranch={handleBranch}
管理模型 onManage={() => setManagerOpen(true)}
</button> composerDisabled={!selectedBook}
</div> externalInput={pendingInput}
onConsumeExternalInput={() => setPendingInput("")}
<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"> <div className="library-page">
<header className="library-topbar"><span>我的阅读空间 <span className="breadcrumb-slash">/</span> <strong>书库</strong></span><span className="topbar-note"><span /> 与好书,慢慢相处</span></header> <header className="library-topbar"><span>我的阅读空间 <span className="breadcrumb-slash">/</span> <strong>书库</strong></span><span className="topbar-note"><span /> 与好书,慢慢相处</span></header>
@@ -757,7 +692,7 @@ function BookmateApp() {
} }
try { try {
if (selectedBook?.id !== readerBook.id || !activeConv) await startChat(readerBook); if (selectedBook?.id !== readerBook.id || !activeConv) await startChat(readerBook);
setInput(text); setPendingInput(text);
setReaderBook(null); setReaderBook(null);
} catch { } catch {
alert("暂时无法打开对话,请稍后重试。"); alert("暂时无法打开对话,请稍后重试。");
@@ -790,201 +725,3 @@ export default function Home() {
); );
} }
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 ToolChips({ tools }: { tools: string[] }) {
return (
<div className="flex items-center gap-1.5 text-xs text-[var(--fg-muted)]">
{tools.map((t) => (
<span key={t} className="px-2 py-1 rounded-lg bg-[var(--accent-soft)] text-[var(--accent)] flex items-center gap-1.5">
<Wrench size={12} />
{t}
</span>
))}
<span className="text-[11px]">调用外部工具中…</span>
</div>
);
}
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>
);
}
+502
View File
@@ -0,0 +1,502 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import {
BookOpen, Bot, Check, ChevronDown, ChevronLeft, ChevronRight, Copy,
GitBranch, Loader2, Paperclip, Plus, RotateCcw, Send, Settings2,
Sparkles, Trash2, User, Wrench, X, ArrowUpRight,
} from "lucide-react";
import { Conversation, Message, Citation, AiModelEntry } from "@/lib/api";
import MarkdownRenderer from "@/components/MarkdownRenderer";
import CitationReaderDialog from "@/components/CitationReaderDialog";
/* ======================= 消息 → 回合 ======================= */
export interface Turn {
user: Message;
versions: Message[];
}
/**
* 把平铺的消息流聚合成「回合」列表:每个 user 消息 + 它名下的所有 AI 回答版本。
* assistant 按 parent_message_id 归属;旧数据 parent 为 null 时,挂到它之前最近的 user。
*/
export 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;
}
/* ======================= 共享 UI 小件 ======================= */
function ToolChips({ tools }: { tools: string[] }) {
return (
<div className="flex items-center gap-1.5 text-xs text-[var(--fg-muted)]">
{tools.map((t) => (
<span key={t} className="px-2 py-1 rounded-lg bg-[var(--accent-soft)] text-[var(--accent)] flex items-center gap-1.5">
<Wrench size={12} />
{t}
</span>
))}
<span className="text-[11px]">调用外部工具中…</span>
</div>
);
}
export 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>
);
}
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.book_title && <em className="not-italic text-[var(--accent)] mr-1">《{c.book_title}》</em>}
{(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)}
/>
</>
);
}
/* ======================= 欢迎屏配置 ======================= */
export interface WelcomePrompt {
Icon: React.ComponentType<{ size?: number; strokeWidth?: number }>;
title: string;
prompt: string;
}
export interface WelcomeConfig {
Icon: React.ComponentType<{ size?: number; strokeWidth?: number }>;
eyebrow: string;
title: string;
subtitle: string;
placeholder: string;
listLabel: string;
newButtonText: string;
prompts: WelcomePrompt[];
}
/* ======================= 对话面板(书模式 / 助手模式共用) ======================= */
interface ChatPanelProps {
header: React.ReactNode;
conversations: Conversation[];
activeConv: Conversation | null;
messages: Message[];
turns: Turn[];
streaming: boolean;
streamContent: string;
streamTurnId: string | null;
activeTools: string[];
activeVersions: Record<string, number>;
setActiveVersions: (updater: (prev: Record<string, number>) => Record<string, number>) => void;
aiModels: AiModelEntry[];
model: string;
setModel: (m: string) => void;
welcome: WelcomeConfig;
onSelectConv: (c: Conversation) => void;
onNewConversation: () => void;
onDeleteConversation: (id: string) => void;
onSend: (content: string) => void;
onRetry: (msg: Message) => void;
onDelete: (id: string) => void;
onBranch: (id: string) => void;
onManage: () => void;
composerDisabled?: boolean;
// 外部传入的初始输入(如 EpubReader「解读原文」):非空时填入输入框并消费
externalInput?: string;
onConsumeExternalInput?: () => void;
}
export default function ChatPanel(props: ChatPanelProps) {
const {
conversations, activeConv, messages, turns, streaming, streamContent, streamTurnId,
activeTools, activeVersions, setActiveVersions, aiModels, model, setModel,
welcome, onSelectConv, onNewConversation, onDeleteConversation,
onSend, onRetry, onDelete, onBranch, onManage, composerDisabled,
externalInput, onConsumeExternalInput,
} = props;
const [input, setInput] = useState("");
const [pickerOpen, setPickerOpen] = useState(false);
const chatEndRef = useRef<HTMLDivElement>(null);
useEffect(() => { chatEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, streamContent]);
// 外部初始输入(EpubReader「解读原文」):填入输入框后消费掉,避免重复触发
useEffect(() => {
if (externalInput) {
setInput(externalInput);
onConsumeExternalInput?.();
}
}, [externalInput, onConsumeExternalInput]);
const currentModel = aiModels.find((m) => m.name === model) ?? aiModels[0];
const handleCopy = async (text: string) => {
await navigator.clipboard.writeText(text);
};
const handleSendClick = () => {
if (!input.trim() || streaming) return;
onSend(input);
setInput("");
};
const submit = () => {
if (input.trim() && !streaming && !composerDisabled) handleSendClick();
};
return (
<>
{props.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">{welcome.listLabel}</p>
<button
onClick={onNewConversation}
disabled={composerDisabled}
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} />
{welcome.newButtonText}
</button>
{conversations.map((c) => (
<div key={c.id} className="flex items-center group">
<button
onClick={() => onSelectConv(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={() => onDeleteConversation(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"><welcome.Icon size={30} strokeWidth={1.4} /></div>
<span className="eyebrow">{welcome.eyebrow}</span>
<h2>{welcome.title}</h2>
<p>{welcome.subtitle}</p>
<div className="prompt-grid">
{welcome.prompts.map(({ 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 || activeTools.length > 0);
return (
<div key={turn.user.id} className="space-y-1.5">
<ChatBubble
message={turn.user}
onCopy={handleCopy}
onRetry={isLastTurn ? onRetry : undefined}
onDelete={onDelete}
/>
{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={onDelete}
onBranch={onBranch}
/>
)}
{streamingHere && (
<div className="pl-10 space-y-1">
{activeTools.length > 0 && <ToolChips tools={activeTools} />}
{streamContent && (
<ChatBubble
message={{ id: "stream", conversation_id: "", parent_message_id: turn.user.id, role: "assistant", content: streamContent, citations: null, model, created_at: "" }}
isStreaming={true}
/>
)}
</div>
)}
</div>
);
})}
{streaming && !streamTurnId && (streamContent || activeTools.length > 0) && (
<div className="max-w-3xl mx-auto space-y-1">
{activeTools.length > 0 && <ToolChips tools={activeTools} />}
{streamContent && (
<ChatBubble
message={{ id: "stream", conversation_id: "", parent_message_id: null, role: "assistant", content: streamContent, citations: null, model, created_at: "" }}
isStreaming={true}
/>
)}
</div>
)}
<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={onManage}
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 && submit()}
placeholder={welcome.placeholder}
disabled={composerDisabled || 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={handleSendClick}
disabled={!input.trim() || streaming || composerDisabled}
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>
</>
);
}
+13 -5
View File
@@ -40,11 +40,13 @@ export interface Citation {
segment_id: string; segment_id: string;
cfi: string | null; cfi: string | null;
excerpt: string; excerpt: string;
book_title?: string | null;
} }
export interface Conversation { export interface Conversation {
id: string; id: string;
book_id: string; kind: "book" | "assistant";
book_id: string | null;
title: string | null; title: string | null;
model: string; model: string;
created_at: string; created_at: string;
@@ -141,12 +143,18 @@ export const api = {
deleteBook: (id: string) => request<{ deleted: string }>(`/books/${id}`, { method: "DELETE" }), deleteBook: (id: string) => request<{ deleted: string }>(`/books/${id}`, { method: "DELETE" }),
// Conversations // Conversations
listConversations: (bookId?: string) => listConversations: (bookId?: string, kind?: string) => {
request<Conversation[]>(`/chat/conversations${bookId ? `?book_id=${bookId}` : ""}`), const params = new URLSearchParams();
createConversation: (bookId: string, model: string = "claude") => if (bookId) params.set("book_id", bookId);
if (kind) params.set("kind", kind);
const qs = params.toString();
return request<Conversation[]>(`/chat/conversations${qs ? `?${qs}` : ""}`);
},
// kind="book"(默认)绑定单本书;kind="assistant" 为全局「第二大脑」对话(bookId 传 null)
createConversation: (bookId: string | null, model: string = "claude", kind: string = "book") =>
request<Conversation>("/chat/conversations", { request<Conversation>("/chat/conversations", {
method: "POST", method: "POST",
body: JSON.stringify({ book_id: bookId, model }), body: JSON.stringify({ book_id: bookId, model, kind }),
}), }),
deleteConversation: (convId: string) => deleteConversation: (convId: string) =>
request<{ deleted: string }>(`/chat/conversations/${convId}`, { method: "DELETE" }), request<{ deleted: string }>(`/chat/conversations/${convId}`, { method: "DELETE" }),