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,
|
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 是放射状粗线,很难读
|
||||||
|
|||||||
@@ -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():
|
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
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
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" }),
|
||||||
|
|||||||
Reference in New Issue
Block a user