返回 DeepSeek-Reasonix
DESKTOP_HOST_PROTOCOL.zh-CN.md
根目录 / docs / DESKTOP_HOST_PROTOCOL.zh-CN.md
1 # 桌面宿主协议
2
3 [English](DESKTOP_HOST_PROTOCOL.md)
4
5 Electron 壳与 Go 桌面服务是两个进程,通过服务进程 stdio 上的一条私有、带版本的
6 JSON-RPC 2.0 连接协作。本文是双方共同实现的契约:Go 侧拥有全部桌面业务命令,
7 Electron 侧拥有全部原生界面。任何一方都不得绕过契约:React 界面不触碰 Electron
8 或 Go 全局对象,Go 业务代码不链接任何壳工具包。
9
10 ```text
11 React 渲染进程 ──preload 类型化 IPC──▶ Electron 主进程 ──stdio JSON-RPC──▶ Go 桌面服务
12 ▲ │
13 └────── host/* 反向请求 ────────────┘
14 ```
15
16 ## 传输
17
18 - 帧格式:按行分隔的 JSON-RPC 2.0(`rpcwire` 严格模式)。一行一帧,UTF-8,不允许批量数组。
19 - Go 服务以 `reasonix-desktop --host-rpc` 启动。stdout 只承载协议帧,stderr 承载日志。
20 壳仅在 `desktop/shutdown` 明确返回 `completed` 后关闭 stdin 作为退出兜底;
21 没有完成结果的 stdin 关闭按 `connection_lost` 进入有界收尾。
22 - 限制:双向单帧 64 MiB,服务端最多 512 个并发入站处理器,30 秒写入停滞看门狗。
23 大体积二进制数据从不进入帧,而是走下文的资源源。
24 - 壳发出的每个请求都在独立 goroutine 上执行,与已退役的进程内壳的绑定调用一致。只有
25 `desktop/event` 帧保证顺序,它们由服务端的单一队列写出。
26
27 ## 握手
28
29 新连接上的第一个请求必须是 `desktop/hello`,否则以 `-32002 not_ready` 失败。
30
31 ```jsonc
32 // 壳 → 服务
33 {"method":"desktop/hello","params":{
34 "protocolVersion": 11,
35 "contractDigest": "sha256:…", // 壳包内嵌的契约摘要
36 "build": {"version":"v1.30.0","channel":"stable","commit":"abc123"},
37 "host": {"name":"electron","version":"44.2.0","chrome":"152.0.0","platform":"darwin","arch":"arm64"},
38 "instance": {"home":"/Users/…/.reasonix","dev":false}
39 }}
40 // 服务 → 壳
41 {"result":{
42 "protocolVersion": 11,
43 "contractDigest": "sha256:…",
44 "service": {"version":"v1.30.0","channel":"stable","commit":"abc123","pid":4242},
45 "runtimeGeneration": "g-01J…", // 每个服务进程唯一
46 "instance": {"identityVersion":2,"identityDigest":"sha256:…","legacyId":"com.reasonix.desktop.…"},
47 "runId": "…", "incidentId": "…", "diagnosticsEnabled": true,
48 "resources": {"origin":"http://127.0.0.1:51234","token":"…"},
49 "window": {"width":1280,"height":820,"minWidth":760,"minHeight":480,"frameless":false,"zoomFactor":1}
50 }}
51 ```
52
53 `instance` 为跨版本兼容的可选字段。新服务会发布共享文件系统身份解析器生成的
54 版本化摘要以及旧实例 ID;壳只将这些不透明值用于诊断,不会把摘要当作文件路径。
55 旧壳会忽略该对象,新壳也接受对象缺失。
56
57 `window` 是 Go 根据保存状态和平台规则得到的主窗口初始几何。可选
58 `position: {x, y}` 传递保存的原点(零坐标和负坐标均有效),缺省表示居中。
59 壳选择匹配的显示器,按其 DIP 可用区域校正矩形后隐藏创建窗口。Go 随后在
60 `domReady` 中最大化并显示,不再覆盖壳校正后的位置。持久化始终采集普通状态
61 矩形,最大化标记单独保存;旧的超大矩形会按可用区域修正,不会将所有最大化
62 记录都重置为默认尺寸。
63 最小化期间,壳保留最后一次非最小化快照,避免原生普通矩形查询返回最大化外框。
64
65 持久化 JSON 结构不变。旧壳忽略握手中可选的位置字段,新壳接受字段缺失。
66 壳与服务应配套发布:开发模式混用不同版本不能提供完整的恢复修复。降级可能
67 重新引入旧几何问题,旧版读取器也可能拒绝低于其原有校验下限的负坐标。
68
69 失败码都是终止性的:壳显示真实错误,提供“打开日志”和“退出”,绝不回退到浏览器 mock。
70
71 | 代码 | 名称 | 含义 |
72 | --- | --- | --- |
73 | `-32001` | `protocol_mismatch` | `protocolVersion` 不一致 |
74 | `-32003` | `contract_mismatch` | 命令/事件摘要不一致(混装) |
75 | `-32004` | `build_mismatch` | 壳与服务版本不同且都不是 `dev` |
76 | `-32005` | `instance_mismatch` | 壳的规范数据目录与服务的不一致 |
77 | `-32002` | `not_ready` | hello 成功前的请求 |
78
79 `runtimeGeneration` 标记该服务进程发出的每个事件、每个审批和浏览器授权。服务重启
80 后产生新的世代;壳丢弃任何旧世代标记的内容。
81
82 `runId` 标识本次 service 运行,`incidentId` 用于关联同一故障链中的 service 与 shell
83 生命周期证据。两者都是随机诊断标识,不包含 PID、本地路径或用户内容。诊断关闭时,
84 `diagnosticsEnabled` 为 false,两个标识可以省略。
85
86 ## 生命周期请求(壳 → 服务)
87
88 | 方法 | 参数 | 结果 | Go 负责者 |
89 | --- | --- | --- | --- |
90 | `desktop/start` | `{}` | `{}` | `App.startup` |
91 | `desktop/domReady` | `{}` | `{}` | `App.domReady` |
92 | `desktop/rendererAttached` | `{"rendererGeneration":n}` | `{}` | 前端心跳/就绪 |
93 | `desktop/beforeClose` | `{"reason":"window"\|"quit"\|"tray"\|"updater"}` | `{"prevent":bool}` | `App.beforeClose` |
94 | `desktop/shutdown` | `{"requestId":string,"reason":string}` | 退出阶段与结果 | 可重试的统一退出协调器 |
95 | `desktop/shutdownStatus` | `{"requestId":string}` | 同一退出阶段与结果 | 超时或结果未知后查询 |
96 | `desktop/hostEvent` | `{"name":string,"payload":any}` | `{}` | 第二实例、托盘打开/退出、菜单动作 |
97 | `desktop/browserControl` | `{"enabled":bool}` | `{}` | 内置浏览器开关,构建会话时读取 |
98
99 顺序:`hello` → `start` → 窗口加载 → `domReady` →(每次渲染进程挂载后 `rendererAttached`)
100 → … → `beforeClose` →(`shutdown` 完成 → 关闭 stdin 兜底 → 退出)。shutdown RPC
101 超时只代表结果未知,壳会查询 `shutdownStatus`;可重试失败时保留窗口。stdin EOF
102 进入同一个协调器并记录 `connection_lost`,已完成正常退出后不会再启动第二次收尾。
103
104 壳在发送 shutdown RPC 前发布服务 `stopping` 阶段。该阶段公开 readiness 为 false,新的
105 业务调用会被拒绝,但 shutdown 与 shutdown-status 继续复用现有服务会话。正常退出会删除
106 `diagnostics/lifecycle` 下当前运行对应的临时文件,因此退出后 lifecycle 目录为空属于预期行为;
107 退出后的长期证据以轮转的 `logs/shell.log` 为准。详见
108 [Windows 关闭与 transcript 诊断验收说明](WINDOWS_CLOSE_TRANSCRIPT_VALIDATION.zh-CN.md)。
109
110 ## 业务命令
111
112 ```jsonc
113 {"method":"desktop/invoke","params":{"method":"OpenProjectTab","args":["/path", true]}}
114 {"result": {...}} // 方法的 JSON 结果,void 为 null
115 {"error":{"code":-32000,"message":"<错误文本>","data":{"method":"OpenProjectTab"}}}
116 ```
117
118 `method` 必须是契约注册表接受的 Go `App` 导出方法。签名沿用已退役壳的规则:任意可
119 JSON 序列化的参数,结果为 `()`、`(T)`、`(error)` 或 `(T, error)`。注册表在构建期拒绝
120 其他形态,因此接口面不可能出现壳无法调用的方法。壳在转发前按内嵌命令表校验
121 `method`;未知名称以 `-32601` 失败。
122
123 生成的契约(`cd desktop && go run . -emit-contract frontend/src/generated`)是唯一
124 事实来源:它输出 JSON 契约、摘要、TypeScript 命令表和渲染进程使用的 DTO 类型声明。
125 检入的输出漂移时桌面 Go 测试失败。
126
127 每个命令还记录源码模块 `domain`、准确的 `owner`(例如 `App.OpenProjectTab`)、
128 仓库相对路径 `sources`、`scope` 和 `cancellation`,这些字段共同参与摘要。
129 生成器扫描所有平台声明并输出 `desktop/host_command_owners.generated.json`;宿主内嵌
130 该文件,要求每个反射命令都有匹配元数据。Scope 记录原方法的命名 wire `inputs`
131 (遗留无名参数使用 `argN`)和 `resolver`;无参数命令使用 `owner-state`,其余使用
132 `owner-inputs`。这些字段描述来源和分派边界;输入校验、标签/会话选择及权限检查仍由
133 原有 App 方法负责。
134
135 当前 App 命令声明 `before-dispatch`:宿主在解码前及真正分派前检查取消;同步写入
136 开始后,即使收到取消也保留原方法的结果,不承诺中断已分派的方法。宿主方法可通过
137 首个 Go 参数 `context.Context` 声明 `cooperative-context`;宿主注入请求 context,
138 它不属于 JSON 参数或生成的 DTO。方法自身必须配合取消。业务 Stop/Cancel 命令继续
139 沿用已有 owner 和语义。
140
141 ## 事件(服务 → 壳 → 渲染进程)
142
143 ```jsonc
144 {"method":"desktop/event","params":{"seq":1093,"generation":"g-01J…","name":"agent:event","args":[{...}]}}
145 ```
146
147 `args` 保留原事件桥的可变参数载荷,多数事件只有一个元素。壳把该帧经
148 `reasonix:event` 通道转给渲染进程;preload 的 `on(name, cb)` 按 `name` 过滤并调用
149 `cb(...args)`。序号在同一世代内严格递增,重新挂载的渲染进程可据此发现缺口并重新
150 快照,而不是信任陈旧状态。
151
152 服务监督器和 preload 都拒绝重复、倒序帧;preload 还根据当前服务状态拒绝旧世代帧,
153 并在 React 订阅前接入传输。世代变化、序号缺口或订阅期间遗漏会触发壳内事件
154 `desktop:resync`(`generation`、`reason`、`expectedSeq`、`actualSeq`),它不属于 Go
155 业务事件。运行状态通过 `SyncRuntimeState` 重读;已挂载控制器重读 `ListTabs`,复用
156 现有 `TurnEventsForTab` 日志与待审批提示恢复投影。异步读取受后续恢复请求、会话身份
157 和导航变更约束,不重放业务调用。服务重启后复用仍存活的应用渲染进程,保留未发送草稿。
158
159 当前保证范围是核心运行状态、会话元数据、持久化轮次事件和待审批提示。终端虽有有界
160 输出快照,但没有原子输出游标,因此缺口后的终端会明确标记输出可能不完整,不将无法
161 确定边界的快照合并进实时输出。扩展输出、文件监听等独立事件流仍需各自的重新快照契约,
162 不在上述核心恢复保证范围内。
163
164 ## 原生宿主调用(服务 → 壳)
165
166 这些调用替代 Go 中对壳工具包的直接调用。每一项对应 Go `nativeHost` 接口的一个方法;
167 Wails 实现已随 Electron 壳落地删除。
168
169 | 方法 | 参数 | 结果 |
170 | --- | --- | --- |
171 | `host/window.show` | `{"reason":string}` | `{}` |
172 | `host/window.hide` | `{}` | `{}` |
173 | `host/app.hide` | `{}` | `{}`(macOS 应用级隐藏) |
174 | `host/window.maximise` `unmaximise` `minimise` `unminimise` `toggleMaximise` `center` | `{}` | `{}` |
175 | `host/window.isMaximised` `isMinimised` | `{}` | `{"value":bool}` |
176 | `host/window.setPosition` | `{"x":n,"y":n}` | `{}` |
177 | `host/window.setTitle` | `{"title":string}` | `{}` |
178 | `host/screen.list` | `{}` | `{"screens":[{"x","y","width","height","scale","primary"}]}` |
179 | `host/dialog.openDirectory` | `{"title","defaultDirectory"}` | `{"path":string}`(`""` 表示取消) |
180 | `host/dialog.openFile` | `{"title","defaultDirectory","filters":[{"displayName","pattern"}],"multiple":bool}` | `{"paths":[]}` |
181 | `host/dialog.saveFile` | `{"title","defaultDirectory","defaultFilename","filters"}` | `{"path":string}` |
182 | `host/dialog.message` | `{"type":"info"\|"warning"\|"error"\|"question","title","message","buttons":[],"defaultButton","cancelButton"}` | `{"button":string}` |
183 | `host/shell.openExternal` | `{"url":string}` | `{}` |
184 | `host/app.quit` | `{}` | `{}` |
185 | `host/app.relaunch` | `{"args":[],"execPath"?:string}` | `{}` |
186 | `host/devtools.toggle` | `{}` | `{}` |
187 | `host/remoteWindow.open` | `{"hostKey","url","title"}` | `{"windowId":string}` |
188 | `host/remoteWindow.navigate` | `{"hostKey","url","title"}` | `{}` |
189 | `host/remoteWindow.focus` `close` | `{"hostKey"}` | `{}` |
190 | `host/tray.ensure` | `{"openTitle","openTooltip","quitTitle","quitTooltip","tooltip"}` | `{"ready":bool,"reason":string}` |
191
192 `host/shell.openExternal` 只接受 `http:`、`https:` 和 `mailto:` URL。
193 包括 `file:`、`javascript:`、`data:` 在内的其他协议会在 Electron 宿主边界
194 被拒绝,不会交给系统打开器执行。
195 | `host/tray.destroy` | `{}` | `{}` |
196 | `host/browser.grant` `revoke` | `{"grantId","tabId","sessionId"}` / `{"grantId"}` | `{}` |
197 | `host/browser.tabs.list` | `{"grantId"}` | `{"tabs":[{"id","url","title","loading","temporary"}]}` |
198 | `host/browser.tabs.open` | `{"grantId","url","temporary"}` | tab |
199 | `host/browser.tabs.navigate` | `{"grantId","tabId","url","action"}` | tab |
200 | `host/browser.tabs.close` | `{"grantId","tabId"}` | `{}` |
201 | `host/browser.snapshot` | `{"grantId","tabId","selector"}` | `{"documentToken","url","title","tree","refs"}` |
202 | `host/browser.act` | `{"grantId","operationId","tabId","documentToken","action","ref","text","keys","options","files","submit","deltaX","deltaY"}` | `{"executed","reason","documentToken"}` |
203 | `host/browser.screenshot` | `{"grantId","tabId","ref","fullPage","directory"}` | `{"path","mime","width","height"}` |
204 | `host/browser.downloads` | `{"grantId","tabId","waitForMs"}` | `{"downloads":[{"id","url","path","state","bytes"}]}` |
205
206 浏览器调用以 `-32010`(引用过期)、`-32011`(用户已接管该标签)或 `-32012`(没有当前授权)
207 失败;Go 执行器把它们映射到内核哨兵错误,并把操作结果记入账本。授权的 `tabId` 是桌面
208 标签(任务);该授权下打开的浏览器标签归属于它。
209
210 宿主事件(`desktop/hostEvent`):`tray.open`、`tray.quit`、`secondInstance`(`payload` 携带
211 原始 argv)、`menu.showWindow`、`remoteWindow.closed`(`{"hostKey"}`)、`browser.takeover`
212 (`{"tabId","epoch","reason"}`)。
213
214 对话框结果从不暴露文件内容;它们只返回路径,再由 Go 经现有工作区和媒体检查授权。
215
216 ## 资源源
217
218 服务在回环端口上监听,承载现有的授权资源处理器(`/__reasonix_workspace_media/…`、
219 `/__reasonix_theme_asset/…`、远程 markdown 图片代理)。壳从受限的 `reasonix://app/`
220 scheme 提供打包界面,只把上述前缀转发到资源源,并在主进程中附加
221 `Authorization: Bearer <token>`。token 从不到达渲染进程、网站视图、远程窗口或
222 MCP App 框架。Go 保留今天的全部文件身份与 TTL 检查。
223
224 ## 渲染进程 preload 接口
225
226 可信 preload 只暴露一个对象 `window.reasonixDesktop`:
227
228 ```ts
229 interface ReasonixDesktopHost {
230 readonly kind: "electron";
231 readonly contract: { protocolVersion: number; digest: string; commands: readonly string[] };
232 readonly platform: { os: "darwin" | "windows" | "linux"; arch: string; versions: Record<string, string> };
233 invoke(method: string, args: unknown[]): Promise<unknown>;
234 on(name: string, cb: (...args: unknown[]) => void): () => void;
235 native: {
236 openExternal(url: string): Promise<void>;
237 clipboard: { writeText(text: string): Promise<boolean>; readText(): Promise<string> };
238 window: {
239 setTheme(theme: "system" | "light" | "dark"): void;
240 setBackgroundColour(r: number, g: number, b: number, a: number): void;
241 getBounds(): Promise<{ x: number; y: number; width: number; height: number; maximised: boolean }>;
242 isMaximised(): Promise<boolean>;
243 minimise(): void; toggleMaximise(): void; close(): void;
244 };
245 getPathForFile(file: File): string; // 原生拖放路径
246 onServiceState(cb: (state: ServiceState) => void): () => void;
247 browserControl: { // 内置浏览器的设置页
248 get(): Promise<BrowserControlState | null>;
249 setEnabled(enabled: boolean): Promise<BrowserControlState>;
250 setIgnoreCertificateErrors(enabled: boolean): Promise<BrowserControlState>;
251 clearCache(): Promise<void>; // 保留 Cookie 与站点数据
252 clearAllData(): Promise<void>; // Cookie、站点数据与缓存
253 importChromeLogin(): Promise<ChromeImportOutcome>;
254 };
255 };
256 browser: { // 用户驱动的浏览器面板;Agent 调用经 Go
257 list(): Promise<BrowserTabView[]>;
258 open(url: string, opts?: { temporary?: boolean; taskId?: string }): Promise<BrowserTabView>;
259 close(tabId: string): Promise<void>;
260 activate(tabId: string | null): Promise<void>;
261 navigate(tabId: string, target: { url?: string; action?: "back" | "forward" | "reload" | "stop" }): Promise<void>;
262 setZoom(tabId: string, factor: number): Promise<void>;
263 toggleDevTools(tabId: string): Promise<void>;
264 resume(tabId: string): Promise<void>; // 把接管的标签交还给 Agent
265 setLayout(rect: { x: number; y: number; width: number; height: number } | null): void;
266 setOverlay(active: boolean): void; // 应用覆盖层隐藏所有网站视图
267 onTabs(cb: (tabs: BrowserTabView[]) => void): () => void;
268 onDownload(cb: (download: BrowserDownloadView) => void): () => void;
269 };
270 }
271 ```
272
273 `BrowserTabView` 为 `{ id, taskId, url, title, loading, canGoBack, canGoForward,
274 temporary, mode: "agent" | "human", epoch, zoom, error }`,`BrowserDownloadView` 为
275 `{ id, tabId, url, filename, path, state, received, total }`。网站视图位于
276 `persist:browser`(共享登录)或 `temp:<id>` 分区,永远不会获得应用 preload。
277
278 `ServiceState` 为 `{ phase: "starting" | "ready" | "restarting" | "failed" | "exited"; generation: string; error?: string }`。
279 业务组件只导入类型化 SDK,从不直接使用该对象;只有桥接适配层读取它。
280
281 `BrowserControlState` 为 `{ controlEnabled, ignoreCertificateErrors, writable,
282 warning: "invalid-config" | "unreadable-config" | "unsupported-version" | null }`,
283 `ChromeImportOutcome` 为 `{ ok: true, profile, cookies, skipped }` 或
284 `{ ok: false, reason }`,`reason` 取值 `chrome-missing`、`profile-not-found`、
285 `cookies-unreadable`、`safe-storage-denied`、`safe-storage-unavailable`、
286 `unsupported-platform`。
287
288 ## 安全边界
289
290 - 应用窗口:sandbox 开启,context isolation 开启,Node integration 关闭,只加载
291 `reasonix://app`,使用上述 preload。
292 - 网站视图、远程 Serve 窗口和 MCP App 框架:独立 session,没有应用 preload,不能访问
293 `reasonix://`,不能触达 `host/*`。
294 - IPC 处理器只接受来自应用窗口 `webContents` 的请求,其他发送者被拒绝并记录。
295 - 内嵌契约之外的 `desktop/invoke` 名称在到达 Go 之前失败。
296
297 ## 性能诊断补充
298
299 以下可选 native 接口仅允许可信应用主框架调用。旧 shell 可缺少这些接口;
300 不涉及持久化用户数据格式变更或迁移。
301
302 - `processDiagnostics()` 返回 `{scope: "electron", samples, growth}`。
303 样本包含年龄、可空 CPU 区间、PID/类型/创建时间、可空 CPU 百分比、
304 工作集及私有内存(MiB)、截断标记。前台最多每 30 秒采集一次,后台每 60 秒一次;
305 最多保留五分钟内的 12 条记录,每条最多 128 个进程。不采集标题、URL 或进程名称。
306 范围仅含 Electron 管理的进程,不含 Go 服务。
307 - `captureRendererProfile(requestId?)` 通过 CDP 录制当前 renderer 五秒,
308 请求的采样间隔为 10ms,返回状态、时长和最多八个应用脚本的自身耗时摘要。
309 普通页面不启用 JS Self-Profiling。最多一个进行中的采样,要求窗口在前台,
310 冷却十分钟,每次启动 shell 最多尝试三次。不接管已有 debugger/DevTools。
311 失焦、隐藏、导航、renderer 退出或取消会停止采样。
312 - `cancelRendererProfile(requestId)` 仅取消身份匹配的采样;忽略 renderer 不带身份的取消请求,
313 防止长时间挂起后迟到的旧请求干扰新采样。每条 CDP 命令最多等待 1.5 秒,
314 所有终态均释放自己的 debugger。分析在临时 Worker 中运行,老生代限制 32 MiB,
315 超时 1.5 秒,输入最多 20,000 个节点及 100,000 个样本。
316 原始 profile 不进入 UI 报告。
317 - `exportHeapSnapshot()` 先显示原生风险提示并要求用户确认,再选择保存路径,
318 仅保存本地、不上传,也不接受 renderer 提供的路径。快照可能包含代码、聊天、
319 密钥,会暂停界面并可能占用较多磁盘。Electron 无法中断已开始的快照,
320 因而忙碌状态保持到操作实际结束,不用超时伪装取消成功。
321
322 内存增长信号要求 PID 与创建时间连续一致,至少五次读数跨越两分钟,
323 最近三次读数均超过前两次的较高基线至少 256 MiB 且至少 50%。
324 全程可用时采用私有内存,否则使用工作集。这表示观察到持续增长,
325 不代表确认泄漏,也不代表独占的物理内存。
326
327 报告立即显示已有证据。进程补充最多等待 750ms;短时 CPU 采样结束后更新同一报告。
328 前端 12 秒后放弃采样补充并请求取消。这些是异步等待期限,不是同步工作可被抢占的保证。
329 迟到结果不会重建已经关闭的报告,采样结果明确标注为触发后的数据。
330 用户主动生成堆快照期间及结束后五秒内,暂停压力报警,避免诊断触发自身报警。
331
332 在 `desktop/electron` 运行 `node scripts/performance-smoke.mjs`,
333 可用隔离原生测试验证采样 owner、Worker、报告更新及本地堆快照。
334 `node scripts/performance-benchmark.mjs` 分别以关闭监测、基础监测、短时采样运行
335 三次独立进程对照,记录可用的 CPU 时间、帧时序、工作集及指标采集耗时,
336 各模式使用同一 renderer bundle,通过运行时开关选择;固定活动信号并关闭后台节流,
337 用于无人值守比较成本。宿主事件测试独立覆盖生产焦点和导航取消策略,
338 原生 smoke 验证实际 CDP 与 ASAR 路径。
339 输出至 `artifacts/performance/overhead.json`。该合成测试不等同于 Windows 用户场景复现;
340 仍需对比刚启动、长时间使用、切回窗口和关闭标签等阶段。
341
341 lines MARKDOWN