用 Charles 看懂 AI 对话“逐字回复”:客户端视角的 SSE 流式渲染
用 Charles 看懂 AI 对话“逐字回复”客户端视角的 SSE 流式渲染┌──────────────────┐ ┌───────────────────┐ ┌──────────────────┐ ┌────────────────┐ │ App / WebView │ │ Charles测试环境│ │ Chat BFF / 网关 │ │ 模型服务 │ │ │ │ │ │ │ │ │ │ 空回答气泡 │ │ 观察请求时长、头 │ │ 鉴权并保持响应 │ │ 持续生成增量 │ │ 读取并渲染增量 │ │ 与响应事件 │ │ │ │ │ └────────┬─────────┘ └─────────┬─────────┘ └────────┬─────────┘ └───────┬────────┘ │ ① POST /api/chat │ │ │ ├──────────────────────────►├─────────────────────────►├──────────────────►│ │ │ │ │ ② 生成首段 │ ③ chat.delta │ │ data: {messageId:msg_demo_01,sequence:1,delta:你好}\n\n │ │◄──────────────────────────┼──────────────────────────┤ │ │ ④ 客户端立即追加“你好” │ │ │ │ │ ……持续重复…… │ │ │ ⑤ chat.completed │ │ │ │◄──────────────────────────┴──────────────────────────┘ │ └── 完整回答生成前客户端已经开始展示 ─────────────────────────────────────┘摘要AI 聊天的“逐字回复”通常不是客户端播放动画。更常见的实现是服务端把持续生成的内容编码为 SSEServer-Sent Events响应客户端边接收、边解析、边追加到回答气泡。对客户端而言关键不只是“能收到流”还包括确认传输没有被缓冲、处理超时与取消、并避免把服务端内部实现耦合进 UI。本文按实际排查顺序展开先用 Charles 识别流式请求再看懂一段真实事件流最后运行最小示例并补齐上线需要的边界。读者默认熟悉 HTTP 和基础异步编程。先用 Charles 确认这真的是流式响应吗在自己的测试环境中只对聊天 API 域名开启 Charles 的 SSL Proxying。要查看 HTTPS 明文调试设备需要信任 Charles 证书如果 App 使用证书绑定无法解密是正常安全行为不应为抓包削弱线上策略。发送一条聊天消息后在 Charles 中找到对应的POST /api/chat路径以项目为准。客户端和 Charles 应同时呈现以下现象观察点正常流式时的现象它说明什么客户端界面请求未结束第一段文字已出现用户不必等待完整回答Charles HeadersContent-Type: text/event-stream该响应采用 SSE 格式Charles Duration请求持续数秒而非首段到达后结束同一个响应体仍在持续写入客户端日志多次收到增量时间逐步推进客户端不是一次性拿到全文Charles 用来观察传输链路客户端埋点用来确认真正的到达与渲染时刻。建议让同一轮请求携带requestId它能把客户端日志、Charles、网关和服务端日志关联起来。SSE 是什么一条没有立刻结束的 HTTP 响应普通 HTTP 接口通常等服务端算完后再返回完整 JSONSSE 则让响应体保持打开服务端每产生一段内容就写入一条事件。它是服务端到客户端单向的文本事件流响应类型为text/event-stream。普通 HTTP客户端请求 ───────── 等待 ───────── 完整 JSON ─► 一次性更新 UI SSE 客户端请求 ─► 第一段 ─► 追加 ─► 下一段 ─► 追加 ─► 正常结束 ^ 已可阅读HTTP/1.1 的keep-alive主要是复用 TCP 连接SSE 的关键是当前这一次 HTTP 响应还没有结束。HTTP/2、HTTP/3 同样可以传输 SSE只是底层连接复用方式不同。方案适用场景AI 文本回复中的取舍普通 HTTP一次性查询、批处理简单但用户要等完整回答轮询低频状态刷新空请求多延迟与成本互相拉扯SSE单向文本、进度、日志与“提交一次、持续返回”高度匹配WebSocket双方高频互发消息适合协作、语音等对普通文本回复往往偏重这里的“逐字”只是视觉效果。一次网络读取不等于一个汉字也不等于一个模型 token它可能包含半个事件、一个完整事件或者多个事件。因此客户端必须按 SSE 事件边界解析不能按网络分块直接更新 UI。读懂一段 Charles 抓包一句话如何分段抵达下面是一次脱敏后的完整示意。文中的chat.*是团队自定义的 BFF 示例协议不是 SSE 标准也不是某个厂商的官方事件名。Charles 的 Response 原始文本会在请求完成后完整呈现不同网关和 HTTP 版本的显示细节会不同但data:行和空行的语义相同。Charles Session ────────────────────────────────────────────────────────────── Method: POST URL: https://test-api.example.com/api/chat Status: 200 OK Duration: 2.84 s Response: text/event-stream; charsetutf-8 Request Body开始 ────────────────────────────────────────────────────────────── {message:用一句话解释 SSE,stream:true,requestId:req_demo_01} Response Body按服务端写入顺序 ────────────────────────────────────────────────────────────── id: 1 event: chat.created data: {messageId:msg_demo_01} id: 2 event: chat.delta data: {messageId:msg_demo_01,sequence:1,delta:SSE} id: 3 event: chat.delta data: {messageId:msg_demo_01,sequence:2,delta: 让} : ping id: 4 event: chat.delta data: {messageId:msg_demo_01,sequence:3,delta:回答更快可见。} id: 5 event: chat.completed data: {messageId:msg_demo_01,finishReason:stop} HTTP 响应结束空白行意味着一条完整 SSE 事件结束。客户端的气泡会这样变化id: 2 → SSE id: 3 → SSE 让 id: 4 → SSE 让回答更快可见。 id: 5 → 停止 loading固定最终状态严格说上面按空行分隔的是SSE 事件不是 TCP 网络包一个事件可能被拆到多个网络包多个事件也可能合并在一次读取中。客户端永远按空行组装事件。从外到内看字段以这条事件为例id: 3 event: chat.delta data: {messageId:msg_demo_01,sequence:2,delta: 让}id、event、data是 SSE 协议字段JSON 中的messageId、sequence、delta是业务字段。两层不要混淆。字段代表什么客户端如何使用id: 3事件游标由服务端分配支持续传时标记最后收到的事件不是 token 序号event: chat.delta示例协议中的新增文本事件决定本次应追加文字而不是结束或报错data:事件正文本例约定为 JSON等完整事件到达后再解析messageId这一条助手回答的标识找到正确的回答气泡sequence同一回答内的增量序号发现重复、缺失或乱序属于业务约定delta本次新增的片段执行currentText delta不是完整回答finishReason本轮结束原因仅在完成事件中用于收尾或提示: ping是客户端忽略的注释心跳不能渲染到聊天气泡。超时、心跳与重连流为什么会中途断开SSE 没有统一的最长连接时间。一条流会被客户端网络库、应用服务、反向代理、负载均衡或 CDN 中最先超时的一层关闭。最容易忽略的是空闲超时它限制“多久没有任何字节通过”不是限制请求总时长。无心跳 文本事件 ───────────────长时间无字节──────────────► 网关关闭 有心跳 文本事件 ─── : ping ─── : ping ───► 下一段文本事件 每次重置空闲计时器心跳是一个完整 SSE 注释事件: ping也就是: ping\n\n。它不会显示给用户但可让中间层知道连接仍活着。先找出链路中最短的 idle timeout再把心跳间隔设置得更短并留出余量。例如最短超时为 60 秒时可从 15–30 秒开始验证最终取决于网关、CDN 和移动网络配置。机制解决什么不解决什么: ping尽量避免空闲超时服务端崩溃、网络切换、总时长上限retry: 3000建议原生EventSource断线后的等待时间保活它不会主动发送字节idLast-Event-ID服务端可重放事件时的断点续传随机生成的回答一定能无缝续写chat.completed明确本轮正常结束异常断线的自动恢复GET EventSource可使用浏览器的自动重连机制fetchPOST、Android 和 iOS 的重连策略需要应用自己实现。没有收到约定的chat.completed的连接关闭都应被当作中断保留已显示文字并让用户重试或重新生成。客户端协议只关心展示不耦合服务端内部实现客户端不应该知道服务端有多少内部能力更不应该识别内部检索、数据库查询等名称。服务端负责把内部过程转译成稳定的展示协议客户端只处理回答文字、消息生命周期和可选的展示卡片。服务端内部实现可随时变化 │ 转译 ▼ 客户端协议chat.created → activity可选 → chat.delta* → chat.completed │ └── 客户端只认识固定事件和产品语义事件客户端动作chat.created创建空助手气泡显示 loadingchat.delta按messageId找到气泡并追加deltaactivity可选显示“正在查找资料”等临时、脱敏提示card可选渲染已约定的商品、地图或文件卡片未知类型安全降级chat.completed停止 loading保存最终状态chat.failed/chat.cancelled保留已有片段并展示失败或已停止下面是一个可选展示事件。为说明字段使用 JSONC实际传输时删除注释{ messageId: msg_01J..., // 要更新的回答气泡 kind: retrieving, // 客户端约定的展示类型 label: 正在查找资料 // 用户可见文案 }服务端以后替换内部实现客户端只要仍收到retrieving就不需要修改。只有用户授权、客户端原生能力或专属卡片交互等场景客户端才需要理解更具体的能力协议。跑通最小示例服务端写事件客户端读事件下面的服务端示例只保留三件事写出id event data 空行、最后结束响应。保存为server.mjs后运行node server.mjs再执行curl -N http://localhost:3000/stream。-N会关闭curl自身缓冲。// server.mjsimporthttpfromnode:http;// 写一条完整 SSE 事件id 定位事件末尾空行表示事件结束。constsend(res,id,event,data)res.write(id:${id}\nevent:${event}\ndata:${JSON.stringify(data)}\n\n);http.createServer(async(req,res){// 这个最小示例只提供流式接口。if(req.url!/stream)returnres.writeHead(404).end();// 声明 SSE跨域头仅方便本地浏览器控制台试验。res.writeHead(200,{Content-Type:text/event-stream; charsetutf-8,Cache-Control:no-cache, no-transform,Access-Control-Allow-Origin:*,});res.flushHeaders();// 同一轮回答的业务标识客户端据此定位回答气泡。constmessageIdmsg_demo_01;send(res,1,chat.created,{messageId});// 模拟模型逐段产出文字。for(const[index,delta]of[流式,响应,不是,前端动画。].entries()){send(res,index2,chat.delta,{messageId,sequence:index1,delta});awaitnewPromise((resolve)setTimeout(resolve,180));}// 明确完成再结束 HTTP 响应。send(res,6,chat.completed,{messageId,finishReason:stop});res.end();}).listen(3000,()console.log(http://localhost:3000/stream));预期输出形状如下每个空白行都不可省略id: 1 event: chat.created data: {messageId:msg_demo_01} id: 2 event: chat.delta data: {messageId:msg_demo_01,sequence:1,delta:流式} id: 3 event: chat.delta data: {messageId:msg_demo_01,sequence:2,delta:响应} id: 6 event: chat.completed data: {messageId:msg_demo_01,finishReason:stop}Android 客户端POST 流用 OkHttp 读取响应体Android Chat 通常需要 POST、鉴权和请求体因此直接用 OkHttp 读取ResponseBody。下面示例基于 OkHttp 5.3.0dependencies{// Android 网络层版本应由项目统一管理。implementation(com.squareup.okhttp3:okhttp:5.3.0)}Callback运行在 OkHttp 工作线程示例只负责读流和解析调用方收到回调后应通过 ViewModel/协程切回主线程更新 UI。importokhttp3.Callimportokhttp3.Callbackimportokhttp3.MediaType.Companion.toMediaTypeimportokhttp3.OkHttpClientimportokhttp3.Requestimportokhttp3.RequestBody.Companion.toRequestBodyimportokhttp3.Responseimportorg.json.JSONObjectimportjava.io.IOExceptionimportjava.util.concurrent.TimeUnit// 服务端有心跳时可关闭读超时否则应配置大于心跳间隔的超时。privatevalsseClientOkHttpClient.Builder().readTimeout(0,TimeUnit.MILLISECONDS).build()funstreamChat(prompt:String,onDelta:(eventId:String,messageId:String,sequence:Int,delta:String)-Unit,onCompleted:(eventId:String,messageId:String)-Unit,onError:(Throwable)-Unit,):Call{// 构造 POST 请求真实项目从安全存储读取 Authorization。valbodyJSONObject().put(prompt,prompt).toString().toRequestBody(application/json; charsetutf-8.toMediaType())valrequestRequest.Builder().url(https://test-api.example.com/api/chat).post(body).build()valcallsseClient.newCall(request)call.enqueue(object:Callback{overridefunonFailure(call:Call,error:IOException)onError(error)overridefunonResponse(call:Call,response:Response){response.use{streamResponse-// 先处理非 2xx再逐行读取尚未结束的响应体。if(!streamResponse.isSuccessful)returnonError(IOException(HTTP${streamResponse.code}))valfieldsmutableListOfString()valsourcestreamResponse.body?.source()?:returnonError(IOException(empty body))while(!source.exhausted()){vallinesource.readUtf8Line()?:break// 空行表示一个完整 SSE 事件其余行暂存。if(line.isEmpty()){dispatch(fields,onDelta,onCompleted)fields.clear()}elsefieldsline}}}})returncall}privatefundispatch(fields:ListString,onDelta:(String,String,Int,String)-Unit,onCompleted:(String,String)-Unit,){// 提取标准 id/event/data 字段心跳只有冒号行会自然被忽略。valeventIdfields.firstOrNull{it.startsWith(id:)}?.removePrefix(id:)?.trim()valeventfields.firstOrNull{it.startsWith(event:)}?.removePrefix(event:)?.trim()valdatafields.filter{it.startsWith(data:)}.joinToString(\n){it.removePrefix(data:).trimStart()}if(eventIdnull||data.isBlank())return// 按团队自定义 chat.* 协议分发eventId 可用于日志和断线诊断。valjsonJSONObject(data)when(event){chat.delta-onDelta(eventId,json.getString(messageId),json.getInt(sequence),json.getString(delta))chat.completed-onCompleted(eventId,json.getString(messageId))}}调用时把 UI 更新切回主线程停止按钮保存Call并调用cancel()// 保存 Call供“停止生成”按钮取消本轮请求。valchatCallstreamChat(prompt解释 SSE,onDelta{eventId,messageId,_,delta-viewModel.appendDelta(messageId,delta)// eventId 可同时写入日志。},onCompleted{_,messageId-viewModel.finishMessage(messageId)},onError{error-viewModel.markInterrupted(error)},)// 用户点击“停止生成”时调用。// chatCall.cancel()解析器按空行组装 SSE 事件因此不会把一次 OkHttp 读取误当成一个事件readUtf8Line()也避免了手动处理半行文本。生产实现还应为sequence做去重/缺失检测并将eventId写入日志以便和 Charles 抓包对应。上线时重点检查四件事问题客户端应做什么与 Charles 配合如何定位用户停止生成调用 OkHttpCall.cancel()保留已显示片段确认请求结束服务端还应取消后续后台工作异常断流未收到完成事件就显示“连接中断”提供重试/重新生成对照结束时间、状态码和网关日志高频增量渲染网络层立即读取UI 层按帧或 16–50 ms 批量提交Charles 正常而 UI 卡顿时重点查渲染与线程切换首段慢或被缓冲记录 TTFT 和最后收字节时间检查请求时长、响应头、心跳与网关 idle timeout至少记录这些指标TTFT提交到首个文本增量、相邻事件间隔、完成率、取消率、异常断流率、端到端总时长以及messageId/requestId。对流式接口而言HTTP 200 不等于体验正常也可能是 30 秒后才吐出第一段。结论理解 SSE 最有效的路径是把客户端界面、Charles 抓包和事件文本放在一起看客户端早于请求结束显示文字Charles 显示text/event-stream与持续响应体事件以空行分隔并逐段追加。随后再补上心跳、取消、重试、渲染节流与埋点流式 AI Chat 才能从“本地看起来能跑”变成稳定的线上体验。

相关新闻

FAE-7与NP-7商洗应用性能对比

FAE-7与NP-7商洗应用性能对比

在商业清洗领域,非离子表面活性剂凭借良好的除油、乳化及润湿渗透性能,成为除油脱脂、重油污清洗等工艺的核心原料。NP-7与FAE-7是该领域应用广泛的两款非离子表面活性剂,其中NP-7作为传统烷基酚聚氧乙烯醚类表活,市场应用时间久、…

2026/10/13 23:10:07 阅读更多 →
JSP运行原理深度解析:从Servlet到动态网页的完整生命周期

JSP运行原理深度解析:从Servlet到动态网页的完整生命周期

1. 从静态HTML到动态JSP:一个老兵的视角如果你在2000年代初期开始接触Java Web开发,那么JSP(JavaServer Pages)对你来说,可能就像一位熟悉的老朋友,或者是一段绕不开的“历史债务”。今天,我想从…

2026/10/2 13:16:43 阅读更多 →
如何用二维码实现跨设备文件传输:终极零网络解决方案

如何用二维码实现跨设备文件传输:终极零网络解决方案

如何用二维码实现跨设备文件传输:终极零网络解决方案 【免费下载链接】qr-filetransfer Transfer files over WiFi between your computer and your smartphone from the terminal 项目地址: https://gitcode.com/gh_mirrors/qr/qr-filetransfer 在数字时代&…

2026/10/15 20:55:51 阅读更多 →

最新新闻

从Excel/SQL到Python:数据分析与科学计算完整工作流

从Excel/SQL到Python:数据分析与科学计算完整工作流

这件事我反复验证过:当数据量还停留在几千行时,Excel 和 SQL 能解决绝大多数问题;一旦量级上了百万,或者分析里开始掺杂复杂的统计检验、特征衍生和建模逻辑,“数据分析”就不只是跑数,而是需要和“科学计算…

2026/10/15 22:30:53 阅读更多 →
AI编程助手上下文压缩阈值调优:从96万到40万,省下近三成额度

AI编程助手上下文压缩阈值调优:从96万到40万,省下近三成额度

1. 从一次额度告急说起:为什么上下文压缩阈值值得较真上个月帮一个做后端的朋友看他的开发工作流,他抱怨说这个月的用量额度又提前见底了,明明每天写的代码量跟之前差不多,怎么消耗速度突然快了一截。我让他把最近几天的会话记录拉…

2026/10/15 22:30:53 阅读更多 →
SDRAM控制器实战:读写数据通路、多Bank调度与刷新仲裁设计要点

SDRAM控制器实战:读写数据通路、多Bank调度与刷新仲裁设计要点

如果_你的SDRAM控制器已经能完成初始化、能发出正确的行激活和读写命令,那么在仿真里看到正确的波形并不是什么难事。但一旦把控制器放到真实工程里,接到读写数据流上,问题就开始冒出来了:为什么连续读的时候数据会断流&#xff1…

2026/10/15 22:30:53 阅读更多 →
从三个字母到实时数据管道:轻量级架构设计与性能调优实战

从三个字母到实时数据管道:轻量级架构设计与性能调优实战

1. 当标题只剩下三个字母:一次极简输入下的内容重构实验拿到这个项目的时候,我盯着屏幕愣了几秒。项目标题只有三个字母——“rea”,项目正文是空的,关键词是空的,摘要描述也是空的。没有背景交代,没有需求…

2026/10/15 22:30:53 阅读更多 →
上下文压缩阈值调优:从96万到40万,token消耗省近三成

上下文压缩阈值调优:从96万到40万,token消耗省近三成

1. 上下文窗口与压缩机制的核心逻辑1.1 从“聊到96万token才压缩”说起先把标题里的数字拆开看。96万token这个量级,指的是模型在单次会话里累积的上下文长度达到某个阈值后,系统才会触发一次“压缩”动作——把前面的历史对话做摘要、裁剪或重写&#x…

2026/10/15 22:30:53 阅读更多 →
Unity URP几何波水体:函数驱动水面顶点位移实现方案

Unity URP几何波水体:函数驱动水面顶点位移实现方案

1. 项目概述:为什么“几何波”是水体材质进阶的关键突破口在Unity URP管线中做水体效果,很多人卡在第一个坎:用现成Shader Graph节点拼出来的水面,总显得“塑料感”太重——波纹生硬、起伏缺乏层次、边缘没有自然衰减、风吹过时的…

2026/10/15 22:29:51 阅读更多 →

日新闻

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

1. 为什么选择容器化方式部署 LoRa 网络服务LoRa 物联网项目落地时,网络服务器是整个链路的核心枢纽。它负责接收网关上报的数据包、完成去重与解密、再把有效载荷分发给上层应用。早期大家习惯把网络服务器直接装在物理机上,依赖一堆系统库和特定版本的…

2026/10/15 0:00:46 阅读更多 →
共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

1. 共享推理服务里,扩缩容到底难在哪做过大模型推理服务部署的人,大概都经历过这样的场景:一个团队共用一套推理集群,A 业务跑的是 7B 的小模型,B 业务跑的是 72B 的大模型,C 业务白天流量高、晚上几乎归零…

2026/10/15 0:00:46 阅读更多 →
Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

1. 引子:一次内核恐慌引发的追查先从一个真实场景说起。某次我给一台老旧的嵌入式设备折腾系统裁减,为了把启动时间压进两秒以内,我手贱地把一串自认为“没用”的 systemd 服务给 disable 了,其中就包括systemd-remount-fs.servic…

2026/10/15 0:00:46 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/15 16:18:54 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/15 19:41:51 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/15 13:48:15 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 0:14:36 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 22:45:47 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 9:49:14 阅读更多 →