Skip to content

Latest commit

 

History

History
236 lines (172 loc) · 10.6 KB

File metadata and controls

236 lines (172 loc) · 10.6 KB

💬 AI 问询浮窗(可选)

模板内置一个顶部导航栏的 "问问 AI" 按钮:读者点开后输入问题,前端把问题、当前页面 URL/标题,以及最近若干轮对话一起 POST 给你自己搭的后端,后端返回一段 Markdown 答复显示在面板里。支持多轮上下文、本地会话恢复、停止生成、引用源文档等能力。

默认 不显示。需要在配置里填入后端 URL 才会启用。


1. 启用方法

编辑 assets/partials/ai-ask.html,把 ai-ask-endpointcontent 改成你的后端地址:

<meta name="ai-ask-endpoint" content="https://api.your-domain.com/ask">

重新渲染(./scripts/render.sh full),顶部导航栏右侧会出现 "问问 AI" 按钮(默认快捷键 ⌘/Ctrl + J)。

关闭,把 content="" 改回空字符串即可,前端 JS 检测到空值就不挂载,零 DOM 注入、零监听器。


2. 可选 meta 配置(六个旋钮)

所有可选项都在 assets/partials/ai-ask.html 里以 <meta> 标签形式提供,启动时一次性读入并冻结到内部 CONFIG 对象。

meta 名 默认值 说明 / 校验
ai-ask-endpoint "" 必填。空字符串 = 不挂载(静默退出)。
ai-ask-trigger-label "问问 AI" 触发按钮的可见文字 + aria-label。空 → 回退默认。
ai-ask-shortcut "j" 单个 [a-z] 字符。Cmd/Ctrl + 此键切换。非法 → 回退 j
ai-ask-book (由 <title> 推断) sessionStorage scoping key。同一域名下多本书时显式区分。
ai-ask-history-cap 20 提交给后端的 history 数组与本地存储的最大轮数。clamp 到 [1, 100]。
ai-ask-debug "" 设为 1 / 任意非空非零值 → 暴露 window.__aiAsk 调试 API。

3. 请求 / 响应契约(多轮)

前端发出:

POST {endpoint}
Content-Type: application/json

{
  "question":   "用户输入的问题",
  "page_url":   "https://book.example.com/chapters/ch1/section-1.html",
  "page_title": "1.1 引言 – 我的书",
  "history":    [
    { "role": "user",      "content": "上一轮问题" },
    { "role": "assistant", "content": "上一轮回答" }
    /* … 最多 historyCap 条,按时间顺序,**不含**当前 question */
  ]
}

history 字段只包含 已完成 的轮次(pending / streaming / error / cancelled 都被过滤)。当前 question 永远作为独立字段传,不会重复出现在 history 末尾。后端通常的拼接方式:system + history + user(question)

后端必须以 SSE(Server-Sent Events) 的形式流式返回:

HTTP/1.1 200 OK
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache
X-Accel-Buffering: no

event: source
data: {"source": "chapters/ch1/section-1.md"}

event: delta
data: {"text": "**Markdown** "}

event: delta
data: {"text": "片段"}

event: done
data: {}

四种事件类型:

event data 字段 触发时机
source {"source": "path or marker"} 流的起点,先于任何 delta。前端用此渲染底部 "查看原文" 胶囊或顶部 "未找到本页源文档" 提示。
delta {"text": "增量文本"} 每收到一个 LLM token chunk 触发一次。前端把所有 text 拼接后整体当 Markdown 重新渲染,光标 跟随末尾闪烁。
done {} 正常结束。前端移除光标,固化为最终消息,渲染复制 / 重新生成 / 查看原文按钮,写入 sessionStorage。
error(可选) {"message": "用户友好的错误说明"} 致命错误后立刻收尾。前端把当前消息替换为系统错误气泡(带 "重试" 按钮)。

约定:source 字段值若等于 (无法定位源文档)(中文括号),前端把它视为"未找到源"标记并渲染顶部黄色提示栏;其他非空值则视为相对仓库根的源文件路径,渲染为 "查看原文 · {basename}" 胶囊链接。

每条 data: 都是 单行 JSON(前端按 \n\n 切帧、按 event: / data: 字段解析)。后端不要把一个 JSON 跨多行写。

非 2xx 状态码会被前端识别并渲染为系统错误气泡(带 "重试" 按钮)。前端的"停止生成"按钮会触发 AbortController.abort() 并取消正在进行的 reader.read();后端只需要正常处理 client disconnected,无需特殊协议。

向后兼容:若后端响应头是 application/json 而非 text/event-stream,前端会回落到旧的一次性 JSON 解析({answer, source} / {error})。新部署建议直接走 SSE。

引用 chip 自动识别

回答正文中如出现形如 [源:section-1.md][源:附录B](参见 第二章) 的标记,前端会自动包裹为 <span class="aia-cite-chip">(小号胶囊样式)。代码块(fenced / inline)内的同名标记 不会 被替换。


4. sessionStorage 与隐私

启用后,前端会把已完成的对话轮次以 sessionStorage 持久化,key 为 aia-history:<host>:<book>

  • TTL:24 小时。读取时若 Date.now() - savedAt > 24h,自动清除。
  • 容量上限:historyCap(默认 20,超出时丢弃最早的)。
  • 跨页保留:在同一站点内翻章,对话不丢;关闭整个浏览器标签则清空(sessionStorage 行为)。
  • 重新加载提示:刷新后打开浮窗,顶部会出现一行 "已恢复上次对话(N 个问题) · 清空 · ×" 横幅;点 "清空" 立即重置;点 × 仅关闭横幅;任意新提问也会自动收起横幅。
  • 手动清空:点对话框头部的 + 按钮("新对话")、调用 __aiAsk.clearThread()、或在 DevTools 里删 sessionStorage 里对应的 key。

仅 URL + 标题 + history 会发给后端;本页正文 / 用户身份等信息默认不上传。


5. 后端示例(FastAPI · 多轮 + RAG · SSE 流式)

下面这版与仓库 scripts/_test_ai_server.py 等价(精简),消费 history,先发 source,再边生成边推 delta

# server.py
import json
from typing import Iterator, Literal
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from openai import OpenAI

app = FastAPI()
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["POST"], allow_headers=["*"])
client = OpenAI()  # 任何 OpenAI-兼容客户端均可

class Turn(BaseModel):
    role: Literal["user", "assistant"]
    content: str

class Ask(BaseModel):
    question: str
    page_url: str = ""
    page_title: str = ""
    history: list[Turn] = []

def sse(event: str, data: dict) -> bytes:
    return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n".encode("utf-8")

@app.post("/ask")
def ask(body: Ask):
    # 1) 用 page_url 反查本页 Markdown 源文(你的 RAG / 文件映射逻辑)
    source_text, source_label = lookup_page_source(body.page_url)

    system = (
        f"你是《{body.page_title}》的 AI 助教。源文档:{source_label}\n"
        "===== 源文档开始 =====\n"
        f"{source_text}\n"
        "===== 源文档结束 ====="
    )

    # 2) 拼 messages:system + history + 当前 question
    messages = [{"role": "system", "content": system}]
    for t in body.history[-20:]:
        messages.append({"role": t.role, "content": t.content})
    messages.append({"role": "user", "content": body.question})

    def gen() -> Iterator[bytes]:
        # 真实路径 → 前端渲染 "查看原文" 胶囊;"(无法定位源文档)" → 黄色提示栏
        yield sse("source", {"source": source_label})
        try:
            stream = client.chat.completions.create(
                model="gpt-4o-mini",
                max_tokens=600,
                messages=messages,
                stream=True,
            )
            for chunk in stream:
                text = chunk.choices[0].delta.content
                if text:
                    yield sse("delta", {"text": text})
            yield sse("done", {})
        except Exception as e:
            yield sse("error", {"message": f"{type(e).__name__}: {e}"})

    return StreamingResponse(
        gen(),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )

启动:

pip install fastapi uvicorn openai
uvicorn server:app --host 0.0.0.0 --port 8080

assets/partials/ai-ask.htmlai-ask-endpoint 设为 http://localhost:8080/ask 即可联通。生产部署记得加 CORS 白名单、限流、鉴权。

仓库内 scripts/_test_ai_server.py 是更完整的开发态后端,自动按 page_url 反查到本仓库 Markdown 文件并喂给 LLM,可直接 uvicorn scripts._test_ai_server:app --host 127.0.0.1 --port 8765 跑起来调试。


6. 调试 API(可选)

assets/partials/ai-ask.html 里设:

<meta name="ai-ask-debug" content="1">

刷新后浏览器控制台会打印 [ai-ask] debug API mounted (window.__aiAsk)。可用方法:

__aiAsk.open();                       // 打开浮窗
__aiAsk.close();                      // 关闭
__aiAsk.send("总结这一页");            // 触发一次提问,返回 Promise<string|null>
__aiAsk.getThread();                  // 当前线程深拷贝(数组)
__aiAsk.clearThread();                // 清空 + 抹掉 sessionStorage
__aiAsk.getEndpoint();                // 当前 endpoint 字符串
__aiAsk.getConfig();                  // 完整 frozen CONFIG
__aiAsk.version;                      // "3.0.0"

适合 Playwright / Cypress 等端到端测试,或手动跑一遍验证后端联通。生产构建务必把 ai-ask-debug 留空,避免泄露调试入口。


7. 进阶

  • RAG:把书中各章节切块写入向量库(Qdrant、Chroma、PG vector 等),后端先按 page_url 召回相关上下文再喂给 LLM。
  • 流式:默认契约即 SSE 流式(Response.body.getReader() + TextDecoder);想退回一次性 JSON 也行——前端会按 Content-Type: application/json 自动回落到 {answer, source} 解析。
  • Provider:示例用 OpenAI 兼容客户端,可换成 Anthropic、火山方舟、DeepSeek 等任意服务,前端契约保持不变。
  • 隐私:默认只发 URL + 标题 + history。需要把整页内容也发过去,可在后端按 URL 反查文件(推荐,避免前端体积膨胀);不建议在前端 fetchJSON.stringify(document.body.innerText)
  • 高对比度 / 打印:模板已内置 @media print 隐藏触发按钮和对话框;@media (forced-colors: active) 映射到 Windows 高对比度调色板。