模板内置一个顶部导航栏的 "问问 AI" 按钮:读者点开后输入问题,前端把问题、当前页面 URL/标题,以及最近若干轮对话一起 POST 给你自己搭的后端,后端返回一段 Markdown 答复显示在面板里。支持多轮上下文、本地会话恢复、停止生成、引用源文档等能力。
默认 不显示。需要在配置里填入后端 URL 才会启用。
编辑 assets/partials/ai-ask.html,把 ai-ask-endpoint 的 content 改成你的后端地址:
<meta name="ai-ask-endpoint" content="https://api.your-domain.com/ask">重新渲染(./scripts/render.sh full),顶部导航栏右侧会出现 "问问 AI" 按钮(默认快捷键 ⌘/Ctrl + J)。
要 关闭,把 content="" 改回空字符串即可,前端 JS 检测到空值就不挂载,零 DOM 注入、零监听器。
所有可选项都在 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。 |
前端发出:
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。
回答正文中如出现形如 [源:section-1.md]、[源:附录B]、(参见 第二章) 的标记,前端会自动包裹为 <span class="aia-cite-chip">(小号胶囊样式)。代码块(fenced / inline)内的同名标记 不会 被替换。
启用后,前端会把已完成的对话轮次以 sessionStorage 持久化,key 为 aia-history:<host>:<book>。
- TTL:24 小时。读取时若
Date.now() - savedAt > 24h,自动清除。 - 容量上限:
historyCap条(默认 20,超出时丢弃最早的)。 - 跨页保留:在同一站点内翻章,对话不丢;关闭整个浏览器标签则清空(sessionStorage 行为)。
- 重新加载提示:刷新后打开浮窗,顶部会出现一行 "已恢复上次对话(N 个问题) · 清空 · ×" 横幅;点 "清空" 立即重置;点 × 仅关闭横幅;任意新提问也会自动收起横幅。
- 手动清空:点对话框头部的 + 按钮("新对话")、调用
__aiAsk.clearThread()、或在 DevTools 里删sessionStorage里对应的 key。
仅 URL + 标题 + history 会发给后端;本页正文 / 用户身份等信息默认不上传。
下面这版与仓库 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.html 里 ai-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跑起来调试。
在 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 留空,避免泄露调试入口。
- 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 反查文件(推荐,避免前端体积膨胀);不建议在前端
fetch里JSON.stringify(document.body.innerText)。 - 高对比度 / 打印:模板已内置
@media print隐藏触发按钮和对话框;@media (forced-colors: active)映射到 Windows 高对比度调色板。