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:
+41
-16
@@ -16,6 +16,7 @@ from app.rag.engine import (
|
||||
estimate_tokens, match_chapters, _trim_messages,
|
||||
)
|
||||
from app.mcp.agent import agent_stream, build_mcp_context, execute_mcp_tool
|
||||
from app.assistant.service import stream_assistant
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,7 +24,9 @@ router = APIRouter(prefix="/chat", tags=["chat"])
|
||||
|
||||
|
||||
class CreateConversationRequest(BaseModel):
|
||||
book_id: str
|
||||
# kind="book" 绑定单本书(book_id 必填);kind="assistant" 为全局「第二大脑」对话
|
||||
kind: str = "book"
|
||||
book_id: str | None = None
|
||||
title: str | None = None
|
||||
model: str = "claude"
|
||||
|
||||
@@ -38,7 +41,8 @@ class SendMessageRequest(BaseModel):
|
||||
|
||||
class ConversationOut(BaseModel):
|
||||
id: str
|
||||
book_id: str
|
||||
kind: str
|
||||
book_id: str | None
|
||||
title: str | None
|
||||
model: str
|
||||
created_at: str
|
||||
@@ -62,29 +66,42 @@ class MessageOut(BaseModel):
|
||||
|
||||
|
||||
@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())
|
||||
if book_id:
|
||||
q = q.where(Conversation.book_id == book_id)
|
||||
if kind:
|
||||
q = q.where(Conversation.kind == kind)
|
||||
result = await db.execute(q)
|
||||
convs = result.scalars().all()
|
||||
return [ConversationOut(
|
||||
id=c.id, book_id=c.book_id, title=c.title, model=c.model,
|
||||
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 "",
|
||||
) for c in convs]
|
||||
|
||||
|
||||
@router.post("/conversations", response_model=ConversationOut)
|
||||
async def create_conversation(req: CreateConversationRequest, db: AsyncSession = Depends(get_db)):
|
||||
book = await db.get(Book, req.book_id)
|
||||
if not book:
|
||||
raise HTTPException(404, "书不存在")
|
||||
conv = Conversation(book_id=req.book_id, title=req.title or f"与《{book.title}》的对话", model=req.model)
|
||||
if req.kind == "assistant":
|
||||
# 全局「第二大脑」对话:不绑定书
|
||||
book_id = None
|
||||
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)
|
||||
await db.commit()
|
||||
await db.refresh(conv)
|
||||
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 "",
|
||||
)
|
||||
|
||||
@@ -107,10 +124,6 @@ async def send_message(conv_id: str, req: SendMessageRequest, db: AsyncSession =
|
||||
if not conv:
|
||||
raise HTTPException(404, "会话不存在")
|
||||
|
||||
book = await db.get(Book, conv.book_id)
|
||||
if not book:
|
||||
raise HTTPException(404, "书不存在")
|
||||
|
||||
model_name = req.model or conv.model
|
||||
|
||||
# 重试:复用被重试的用户消息(不新建),新回答作为它的新版本
|
||||
@@ -125,6 +138,17 @@ async def send_message(conv_id: str, req: SendMessageRequest, db: AsyncSession =
|
||||
db.add(user_msg)
|
||||
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(
|
||||
select(Chapter).where(Chapter.book_id == book.id).order_by(Chapter.order_idx)
|
||||
)).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)
|
||||
if not conv:
|
||||
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)
|
||||
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)
|
||||
)
|
||||
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:
|
||||
role_label = "🧑 用户" if m.role == "user" else "🤖 AI"
|
||||
# 把 mermaid mindmap 转成 flowchart LR:老版阅读器渲染 mindmap 是放射状粗线,很难读
|
||||
|
||||
@@ -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")
|
||||
@@ -20,13 +20,39 @@ async def init_db():
|
||||
|
||||
|
||||
async def _migrate():
|
||||
"""轻量迁移:SQLite 下给已有 segments 表补 embedding 列(create_all 不会改老表)。"""
|
||||
"""轻量迁移:SQLite 下补列 / 重建表(create_all 不会改老表)。"""
|
||||
async with engine.begin() as conn:
|
||||
rows = (await conn.exec_driver_sql("PRAGMA table_info(segments)")).all()
|
||||
cols = {row[1] for row in rows}
|
||||
if rows and "embedding" not in cols:
|
||||
await conn.exec_driver_sql("ALTER TABLE segments ADD COLUMN embedding TEXT")
|
||||
|
||||
# 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 with async_session() as session:
|
||||
|
||||
+77
-3
@@ -1,11 +1,49 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import AsyncGenerator
|
||||
|
||||
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
|
||||
class ChatResponse:
|
||||
@@ -83,6 +121,8 @@ class OpenAIProvider(LLMProvider):
|
||||
json=payload,
|
||||
) as resp:
|
||||
tool_calls_acc: dict[int, dict] = {}
|
||||
dsml_buf = ""
|
||||
in_dsml = False
|
||||
async for line in resp.aiter_lines():
|
||||
if line.startswith("data: "):
|
||||
data_str = line[6:]
|
||||
@@ -97,8 +137,38 @@ class OpenAIProvider(LLMProvider):
|
||||
delta = choices[0].get("delta", {})
|
||||
content = delta.get("content", "")
|
||||
if content:
|
||||
self.last_content += content
|
||||
yield content
|
||||
dsml_buf += 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
|
||||
for tc in delta.get("tool_calls") or []:
|
||||
idx = tc.get("index", 0)
|
||||
@@ -114,8 +184,12 @@ class OpenAIProvider(LLMProvider):
|
||||
entry["function"]["arguments"] += fn["arguments"]
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
# 流结束:把检测窗口残留的文本冲出来,避免丢失内容
|
||||
if dsml_buf:
|
||||
self.last_content += dsml_buf
|
||||
yield dsml_buf
|
||||
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):
|
||||
|
||||
@@ -154,7 +154,18 @@ async def agent_stream(
|
||||
- ("tool", "<服务器>/<工具>") —— 工具调用开始
|
||||
- ("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.last_content = ""
|
||||
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"],
|
||||
"content": (out or "[工具无返回内容]")[:TOOL_RESULT_CHARS],
|
||||
})
|
||||
# 达到最大轮数仍未见收尾:用已收集上下文走无工具流式生成,保证必出最终答案
|
||||
# 兜底:无工具流式生成,保证必出最终答案
|
||||
async for token in provider.chat_stream(messages, temperature=0.7):
|
||||
yield ("token", token)
|
||||
|
||||
@@ -13,7 +13,9 @@ class Conversation(Base):
|
||||
__tablename__ = "conversations"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid)
|
||||
book_id: Mapped[str] = mapped_column(String(36), ForeignKey("books.id", ondelete="CASCADE"))
|
||||
# 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))
|
||||
model: Mapped[str] = mapped_column(String(50), default="claude")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
@@ -65,15 +65,18 @@ def match_chapters(chapters: list[Chapter], query: str, tokens: list[str] | None
|
||||
return hits
|
||||
|
||||
|
||||
async def _keyword_candidates(db: AsyncSession, book_id: str, tokens: list[str], limit: int) -> list[Segment]:
|
||||
"""关键词候选:SQL LIKE 召回(含整句子串),按命中词数 + 长词加权打分。"""
|
||||
if not tokens:
|
||||
async def _keyword_candidates(db: AsyncSession, book_ids: list[str], tokens: list[str], limit: int) -> list[Segment]:
|
||||
"""关键词候选:SQL LIKE 召回(含整句子串),按命中词数 + 长词加权打分。
|
||||
|
||||
book_ids 传单个书名 id 的列表(单书检索)或多个 id(跨书检索)。
|
||||
"""
|
||||
if not tokens or not book_ids:
|
||||
return []
|
||||
ordered = sorted(tokens, key=len, reverse=True)[:8]
|
||||
conds = [Segment.text.contains(w) for w in ordered]
|
||||
q = (
|
||||
select(Segment)
|
||||
.where(Segment.book_id == book_id, or_(*conds))
|
||||
.where(Segment.book_id.in_(book_ids), or_(*conds))
|
||||
.order_by(Segment.order_idx)
|
||||
.limit(limit * 4)
|
||||
)
|
||||
@@ -124,7 +127,7 @@ async def search_segments(
|
||||
status = "failed"
|
||||
|
||||
# 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. 向量候选
|
||||
vec_candidates: list[Segment] = []
|
||||
@@ -163,6 +166,93 @@ async def search_segments(
|
||||
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:
|
||||
"""整章注入:按 token 预算截取命中章节的原文。"""
|
||||
parts = []
|
||||
@@ -274,6 +364,8 @@ def parse_citations(content: str, segments: list[Segment]) -> dict:
|
||||
"segment_id": seg.id,
|
||||
"cfi": seg.cfi,
|
||||
"excerpt": seg.text[:200],
|
||||
# 跨书检索时由调用方在段上临时标注 _book_title,供前端显示出处
|
||||
"book_title": getattr(seg, "_book_title", None),
|
||||
})
|
||||
clean_text = CITATION_RE.sub("", content)
|
||||
return {"text": clean_text, "citations": citations}
|
||||
|
||||
+175
-438
@@ -2,35 +2,67 @@
|
||||
|
||||
import { useState, useEffect, useMemo, useRef, useContext } from "react";
|
||||
import {
|
||||
BookMarked, BookOpen, Bot, Check, ChevronDown, ChevronLeft, ChevronRight, Copy,
|
||||
Download, GitBranch, Globe, Loader2, LogOut, Paperclip, Plus, RotateCcw, Send, Settings2,
|
||||
Sparkles, Trash2, Upload, User, Wrench, X, ArrowUpRight, Library, Search, Menu, MessageSquare, ArrowLeft,
|
||||
BookMarked, BookOpen, Bot, ChevronRight, Clock,
|
||||
Download, GitBranch, Globe, Loader2, LogOut, Plus, RotateCcw, Send, Settings2,
|
||||
Sparkles, Trash2, Upload, X, ArrowUpRight, Library, Search, Menu, MessageSquare, ArrowLeft,
|
||||
} from "lucide-react";
|
||||
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 McpManagerDialog from "@/components/McpManagerDialog";
|
||||
import AuthGate, { AuthContext } from "@/components/AuthGate";
|
||||
import { auth } from "@/lib/auth";
|
||||
import CitationReaderDialog from "@/components/CitationReaderDialog";
|
||||
import EpubReader from "@/components/EpubReader";
|
||||
|
||||
const SIDEBAR_MIN = 180;
|
||||
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() {
|
||||
const [books, setBooks] = useState<Book[]>([]);
|
||||
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
|
||||
// 全局「第二大脑」助手模式:与单书对话并列,不绑定书
|
||||
const [assistantMode, setAssistantMode] = useState(false);
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [activeConv, setActiveConv] = useState<Conversation | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
// EpubReader「解读原文」→ 输入框的交接:ChatPanel 消费后清空
|
||||
const [pendingInput, setPendingInput] = 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 [mcpOpen, setMcpOpen] = useState(false);
|
||||
const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
|
||||
@@ -42,7 +74,6 @@ function BookmateApp() {
|
||||
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);
|
||||
@@ -96,8 +127,6 @@ function BookmateApp() {
|
||||
api.listMcpServers().then(setMcpServers).catch(() => setMcpServers([]));
|
||||
}, []);
|
||||
|
||||
const currentModel = aiModels.find((m) => m.name === model) ?? aiModels[0];
|
||||
|
||||
// 恢复/保存侧栏宽度(localStorage)
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("bookmate.sidebarWidth");
|
||||
@@ -116,8 +145,6 @@ function BookmateApp() {
|
||||
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;
|
||||
@@ -125,6 +152,7 @@ function BookmateApp() {
|
||||
try {
|
||||
const book = await api.uploadBook(file);
|
||||
setBooks((prev) => [book, ...prev]);
|
||||
setAssistantMode(false);
|
||||
setSelectedBook(book);
|
||||
} catch (err: unknown) {
|
||||
alert(err instanceof Error ? err.message : "Upload failed");
|
||||
@@ -133,8 +161,29 @@ function BookmateApp() {
|
||||
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) => {
|
||||
setSidebarOpen(false);
|
||||
setAssistantMode(false);
|
||||
setSelectedBook(book);
|
||||
setActiveConv(null);
|
||||
setMessages([]);
|
||||
@@ -149,18 +198,19 @@ function BookmateApp() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || streaming) return;
|
||||
const handleSend = async (content: string) => {
|
||||
if (!content.trim() || streaming) return;
|
||||
let conv = activeConv;
|
||||
if (!conv && selectedBook) {
|
||||
conv = await api.createConversation(selectedBook.id, model);
|
||||
if (!conv) {
|
||||
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]);
|
||||
setActiveConv(conv);
|
||||
}
|
||||
if (!conv) return;
|
||||
|
||||
const userContent = input;
|
||||
setInput("");
|
||||
const userContent = content;
|
||||
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);
|
||||
@@ -299,10 +349,6 @@ function BookmateApp() {
|
||||
clearTools();
|
||||
};
|
||||
|
||||
const handleCopy = async (text: string) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
const handleDelete = async (msgId: string) => {
|
||||
if (!confirm("删除这条消息?其名下的回答版本也会一并删除。")) return;
|
||||
try {
|
||||
@@ -326,9 +372,13 @@ function BookmateApp() {
|
||||
};
|
||||
|
||||
const handleNewConversation = async () => {
|
||||
if (!selectedBook) return;
|
||||
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]);
|
||||
setActiveConv(conv);
|
||||
setMessages([]);
|
||||
@@ -430,7 +480,8 @@ function BookmateApp() {
|
||||
</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>
|
||||
<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" />
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
@@ -494,224 +545,108 @@ function BookmateApp() {
|
||||
|
||||
{/* 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>
|
||||
{assistantMode ? (
|
||||
<ChatPanel
|
||||
header={
|
||||
<header className="reader-header border-b border-[var(--border)] flex items-center gap-3 shrink-0">
|
||||
<button className="back-library" onClick={enableLibrary} aria-label="返回书库"><ArrowLeft size={18} /></button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-[15px] font-semibold truncate text-[var(--fg)]">AI 助手</h2>
|
||||
<p className="text-[11px] text-[var(--fg-muted)]">第二大脑 · 跨书籍跨对话的思考伙伴</p>
|
||||
</div>
|
||||
<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"
|
||||
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="导出对话"
|
||||
>
|
||||
<Plus size={12} />
|
||||
新建对话
|
||||
<Download size={13} />
|
||||
导出
|
||||
</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 || 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>
|
||||
</header>
|
||||
}
|
||||
conversations={conversations}
|
||||
activeConv={activeConv}
|
||||
messages={messages}
|
||||
turns={turns}
|
||||
streaming={streaming}
|
||||
streamContent={streamContent}
|
||||
streamTurnId={streamTurnId}
|
||||
activeTools={activeTools}
|
||||
activeVersions={activeVersions}
|
||||
setActiveVersions={setActiveVersions}
|
||||
aiModels={aiModels}
|
||||
model={model}
|
||||
setModel={setModel}
|
||||
welcome={ASSISTANT_WELCOME}
|
||||
onSelectConv={selectConv}
|
||||
onNewConversation={handleNewConversation}
|
||||
onDeleteConversation={handleDeleteConversation}
|
||||
onSend={handleSend}
|
||||
onRetry={handleRetry}
|
||||
onDelete={handleDelete}
|
||||
onBranch={handleBranch}
|
||||
onManage={() => setManagerOpen(true)}
|
||||
externalInput={pendingInput}
|
||||
onConsumeExternalInput={() => setPendingInput("")}
|
||||
/>
|
||||
) : selectedBook ? (
|
||||
<ChatPanel
|
||||
header={
|
||||
<header className="reader-header border-b border-[var(--border)] flex items-center gap-3 shrink-0">
|
||||
<button className="back-library" onClick={enableLibrary} 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>
|
||||
|
||||
<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>
|
||||
</>
|
||||
<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>
|
||||
}
|
||||
conversations={conversations}
|
||||
activeConv={activeConv}
|
||||
messages={messages}
|
||||
turns={turns}
|
||||
streaming={streaming}
|
||||
streamContent={streamContent}
|
||||
streamTurnId={streamTurnId}
|
||||
activeTools={activeTools}
|
||||
activeVersions={activeVersions}
|
||||
setActiveVersions={setActiveVersions}
|
||||
aiModels={aiModels}
|
||||
model={model}
|
||||
setModel={setModel}
|
||||
welcome={{
|
||||
...BOOK_WELCOME_BASE,
|
||||
subtitle: `关于《${selectedBook.title || selectedBook.filename}》,你想了解什么?`,
|
||||
}}
|
||||
onSelectConv={selectConv}
|
||||
onNewConversation={handleNewConversation}
|
||||
onDeleteConversation={handleDeleteConversation}
|
||||
onSend={handleSend}
|
||||
onRetry={handleRetry}
|
||||
onDelete={handleDelete}
|
||||
onBranch={handleBranch}
|
||||
onManage={() => setManagerOpen(true)}
|
||||
composerDisabled={!selectedBook}
|
||||
externalInput={pendingInput}
|
||||
onConsumeExternalInput={() => setPendingInput("")}
|
||||
/>
|
||||
) : (
|
||||
<div className="library-page">
|
||||
<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 {
|
||||
if (selectedBook?.id !== readerBook.id || !activeConv) await startChat(readerBook);
|
||||
setInput(text);
|
||||
setPendingInput(text);
|
||||
setReaderBook(null);
|
||||
} catch {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -40,11 +40,13 @@ export interface Citation {
|
||||
segment_id: string;
|
||||
cfi: string | null;
|
||||
excerpt: string;
|
||||
book_title?: string | null;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
book_id: string;
|
||||
kind: "book" | "assistant";
|
||||
book_id: string | null;
|
||||
title: string | null;
|
||||
model: string;
|
||||
created_at: string;
|
||||
@@ -141,12 +143,18 @@ export const api = {
|
||||
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") =>
|
||||
listConversations: (bookId?: string, kind?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
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", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ book_id: bookId, model }),
|
||||
body: JSON.stringify({ book_id: bookId, model, kind }),
|
||||
}),
|
||||
deleteConversation: (convId: string) =>
|
||||
request<{ deleted: string }>(`/chat/conversations/${convId}`, { method: "DELETE" }),
|
||||
|
||||
Reference in New Issue
Block a user