commit 83f47dd85723a4f9f238aa31788522a2f706bad3 Author: wuyang6 Date: Mon Nov 9 12:30:33 2026 +0800 Publish Sophon Realtime calling tool protocol and latency guide diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..05ea36d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +dist +node_modules +*.log +work +.env* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8cbbe6b --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +dist/ +node_modules/ +.DS_Store +.env* +work/ +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..338b229 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json build.mjs index.html styles.css app.js model.js favicon.svg robots.txt release.json ./ +RUN npm run build + +FROM nginx:1.28-alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build --chmod=0644 /app/dist/ /usr/share/nginx/html/ +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..436a541 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 K1412 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb5f2a8 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# Sophon Realtime 调用与工具机制研究 + +独立研究网页,提供模型构建、调用入口、工具运行流程、公开指标、时延口径与协议覆盖的源代码核对。 + +公开地址为 https://sophon-realtime.k1412.top/ 。原模型项目为 https://github.com/adventists-ai/sophon-realtime 。 + +## 开发 + +使用 Node.js 20 或更新版本,运行 `npm run check` 与 `npm run build`。运行 `npm run dev` 后可在终端显示的本地地址预览。 + +页面全部使用本地静态资源。交互生成启动步骤与协议片段,工具过程使用显式标注的合成示例。GPU推理与真实业务工具执行的评测见正文验收边界。 + +## 许可 + +本站原创代码、文字与绘图代码采用 MIT 许可。外部模型、代码与数据遵循其自身许可和声明,正文列出了需要澄清的上游声明差异。 diff --git a/app.js b/app.js new file mode 100644 index 0000000..c3f9693 --- /dev/null +++ b/app.js @@ -0,0 +1,150 @@ +import { builds, callModes, makeRecipe, toolSteps } from "./model.js"; + +const buildSelect = document.getElementById("build-select"); +const modeSelect = document.getElementById("mode-select"); +let currentBuild = "flash"; +let currentStep = 0; +let currentPart = "startup"; + +function renderBuild() { + const selected = builds[currentBuild]; + const flow = document.getElementById("build-flow"); + flow.replaceChildren(); + selected.flow.forEach((label, index) => { + if (index) { + const arrow = document.createElement("b"); + arrow.textContent = "→"; + arrow.setAttribute("aria-hidden", "true"); + flow.append(arrow); + } + const node = document.createElement("span"); + node.textContent = label; + flow.append(node); + }); + document.getElementById("build-detail").textContent = selected.detail; + document.querySelectorAll("[data-build]").forEach((button) => { + button.setAttribute( + "aria-pressed", + String(button.dataset.build === currentBuild), + ); + }); + buildSelect.value = currentBuild; + const previousMode = modeSelect.value; + modeSelect.replaceChildren(); + for (const key of selected.modes) { + const option = document.createElement("option"); + option.value = key; + option.textContent = callModes[key].title; + modeSelect.append(option); + } + modeSelect.value = selected.modes.includes(previousMode) + ? previousMode + : selected.modes[0]; + renderRecipe(); +} + +function renderRecipe() { + const mode = callModes[modeSelect.value]; + document.getElementById("endpoint-kind").textContent = mode.kind; + document.getElementById("endpoint-value").textContent = mode.endpoint; + document.getElementById("endpoint-detail").textContent = mode.detail; + document.getElementById("recipe-label").textContent = + `${builds[currentBuild].title} · ${mode.title}`; + document.getElementById("call-recipe").textContent = makeRecipe( + currentBuild, + modeSelect.value, + )[currentPart]; + document.querySelectorAll("[data-part]").forEach((button) => { + button.setAttribute( + "aria-pressed", + String(button.dataset.part === currentPart), + ); + }); + document.getElementById("copy-status").textContent = ""; +} + +function renderStep() { + const selected = toolSteps[currentStep]; + document.getElementById("step-number").textContent = String( + currentStep + 1, + ).padStart(2, "0"); + document.getElementById("step-title").textContent = selected.title; + document.getElementById("step-explanation").textContent = + selected.explanation; + document.getElementById("step-code").textContent = selected.code; + document.getElementById("step-progress").textContent = + `${currentStep + 1} / ${toolSteps.length}`; + document.getElementById("previous-step").disabled = currentStep === 0; + document.getElementById("next-step").disabled = + currentStep === toolSteps.length - 1; + document.querySelectorAll("[data-step]").forEach((button) => { + button.setAttribute( + "aria-pressed", + String(Number(button.dataset.step) === currentStep), + ); + }); +} + +const stepList = document.getElementById("tool-steps"); +toolSteps.forEach((step, index) => { + const button = document.createElement("button"); + button.type = "button"; + button.dataset.step = String(index); + button.textContent = `${index + 1} ${step.title}`; + button.addEventListener("click", () => { + currentStep = index; + renderStep(); + }); + stepList.append(button); +}); + +document.querySelectorAll("[data-build]").forEach((button) => { + button.addEventListener("click", () => { + currentBuild = button.dataset.build; + renderBuild(); + }); +}); +buildSelect.addEventListener("change", () => { + currentBuild = buildSelect.value; + renderBuild(); +}); +modeSelect.addEventListener("change", renderRecipe); +document.querySelectorAll("[data-part]").forEach((button) => { + button.addEventListener("click", () => { + currentPart = button.dataset.part; + renderRecipe(); + }); +}); +document.getElementById("previous-step").addEventListener("click", () => { + currentStep -= 1; + renderStep(); +}); +document.getElementById("next-step").addEventListener("click", () => { + currentStep += 1; + renderStep(); +}); +document.getElementById("copy-recipe").addEventListener("click", async () => { + try { + await navigator.clipboard.writeText( + document.getElementById("call-recipe").textContent, + ); + document.getElementById("copy-status").textContent = "已复制调用示例。"; + } catch { + document.getElementById("copy-status").textContent = + "可以在上方代码中手动选择并复制。"; + } +}); + +const themeButton = document.getElementById("theme-toggle"); +themeButton.addEventListener("click", () => { + const theme = + document.documentElement.dataset.theme === "dark" ? "light" : "dark"; + document.documentElement.dataset.theme = theme; + themeButton.setAttribute( + "aria-label", + theme === "dark" ? "切换浅色主题" : "切换深色主题", + ); +}); + +renderBuild(); +renderStep(); diff --git a/build.mjs b/build.mjs new file mode 100644 index 0000000..9d0f685 --- /dev/null +++ b/build.mjs @@ -0,0 +1,17 @@ +import { mkdirSync, copyFileSync } from "node:fs"; +const files = [ + "index.html", + "styles.css", + "app.js", + "model.js", + "favicon.svg", + "robots.txt", + "release.json", +]; +mkdirSync(new URL("./dist/", import.meta.url), { recursive: true }); +for (const file of files) + copyFileSync( + new URL(file, import.meta.url), + new URL(`dist/${file}`, import.meta.url), + ); +console.log(`Built ${files.length} public assets.`); diff --git a/favicon.svg b/favicon.svg new file mode 100644 index 0000000..2c18936 --- /dev/null +++ b/favicon.svg @@ -0,0 +1 @@ + diff --git a/index.html b/index.html new file mode 100644 index 0000000..9bb92f4 --- /dev/null +++ b/index.html @@ -0,0 +1,796 @@ + + + + + + + + + + + + + + Sophon Realtime 的调用、工具机制与时延 · K1412 Research + + + + +
+ +
+
+ PUBLIC MODEL · SOURCE AUDIT · API GUIDE +

Sophon Realtime 的
调用、工具机制与时延

+

+ 沿着模型、协议与执行程序,
核对一条语音请求怎样真正调用工具。 +

+
+ 模型研究2026 年 11 月 9 日原始项目 ↗ +
+
+ 语音→模型→<tool_call>→外部执行→语音回应 +
+

+ Sophon 已公开模型权重、服务代码和客户端示例。Tag 与 Flash + 提供自托管调用路径,Live + 网关连接前台语音与后台代理。实际接入需要同时核对模型能力、协议覆盖、外部执行和时延口径。 +

+

+ 本页以公开仓库提交 + b2754d4753e20ecfa860db0cda4325e4479f4427 + 为代码依据。作者测量、代码观察和说明性示例分别标记,GPU + 端到端调用列为待验证项。 +

+
+ +
+
01 / READINESS
+

当前有哪些可调用路径

+

+ Tag、Flash 和 E2E 均有公开权重目录。前两种构建走 vLLM-Omni,E2E + 使用单独的研究预览引擎。仓库还提供客户端、启动脚本和网关,已经具备可检查的部署链路。[1] +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
构建作者文档中的资源主要入口当前定位
Tag单张 80 GB NVIDIA GPU,实例约 65 GB 显存HTTP + Realtime WebSocket强调笑声、叹气等表现力
Flash单张 80 GB NVIDIA GPU,实例约 55 GB 显存HTTP + Realtime WebSocket强调首音速度与并发
E2E两张 80 GB GPU独立网关 /ws连续向量语音生成的研究预览
+
+

+ 资源与入口来源 + Tag、Flash、E2E 部署文档。 +

+
+ 自托管与在线体验分别确认 +

+ 官方模型卡给出了 ModelScope + 体验页。当前核对支持“模型和服务代码可取得”,可直接申请密钥的 + Sophon 托管 API + 尚缺公开调用资料,体验页的实际语音会话也需单独验证。PyPI 已有 + sophon-realtime 1.0.0,Tag / Flash 脚本使用 Python + 3.12。体验页 ↗ + PyPI ↗ +

+
+
+ +
+
02 / ARCHITECTURE
+

三种构建共享怎样的语音骨干

+

+ 共同骨干由语音特征进入 32B 模型,承担理解、话轮决策和文本回答。Tag + 与 Flash 用不同语音模块朗读带表现标签的回答。E2E + 则把语音生成改为连续向量与专用解码。[1] +

+
+
+
+ BUILD EXPLORER +

选择构建,查看生成路径

+
+ 代码与文档核对 +
+
+ +
+
+
+
+

+ Tag 的 NOTICE 列出 Qwen3-VL-32B、DuplexJev-32B-Para、Qwen3-ASR + 编码器及 tag-tts + 等来源。当前构建把语言、声音和话轮决策组合起来,“一个服务”与内部推理阶段数需要分别描述。[2] +

+

+ Flash 的 80 ms 描述语音生成帧。持续听说的响应还受 + VAD、话轮判断、生成调度和播放缓存影响。配置中仍可见外部语音活动检测,模型话轮判断与工程控制共同参与交互。[3] +

+
+ +
+
03 / CALLING
+

从语音接口到工具接口

+

+ 先确认语音引擎正常返回,再检查连续听说,最后接真实工具和后台。以下示例含可复制的启动步骤与协议片段。模型权重应固定 + revision,并预先放入相应 MODEL_DIR。 +

+
+
+
+ REQUEST GUIDE +

按任务选择调用入口

+
+ 调用示例 +
+
+ +
+
+ HTTP +
+

+
+ +
+
+ +
+
+

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
端口与角色协议接入要求
8000 基础引擎 + /v1/chat/completions
/v1/realtime +
+ Tag / Flash + 语音生成与双工引擎。真实工具需显式选择客户端模式并接工具桥。 +
8700 本地工具桥 + /bridge/<key>/events
/bridge/<key>/result +
+ 默认由引擎启动脚本按 + PORT + 700 + 创建,绑定本机,用于外层网关取调用与回填结果。 +
8780 外层网关 + /v1/realtime
/v1/live/sessions +
+ 分别适配函数调用事件与 Live 客户端委派。额外 ASR 约占 2 GB + GPU 显存。 +
E2E 独立网关/ws + 使用自身的配置、音频、打断与结束事件,按研究预览部署文档接入。 +
+
+

+ 接口依据 + gateway.py、adapters.py + 与各构建文档。 +

+

HTTP 音频流有自己的数据形状

+

+ 公开客户端先读 GET /v1/models,使用官方系统提示和 + input_audio 发送完整 WAV。响应按 SSE 流式返回,顶层 + modality == "audio" 时,音频数据位于 + choices[].delta.content,内容为 base64 WAV 块,24 + kHz。接入 SDK 时应按该实际字段解码。[4] +

+
+ +
+
04 / TOOL CALLING
+

提示词、调用标记和外部执行怎样配合

+

+ 运行时把函数 schema 送入 chat template,模型按 Qwen/Hermes + 格式输出函数名与参数。解析器读取 + <tool_call> + 分隔符,语音模块只接收前面的口语内容。外部程序执行函数后,结果回到模型上下文中,模型再生成语音回应。[5] +

+
+
+
+ CALL TRACE +

逐步查看一条工具请求

+
+ 合成示例 +
+
+
+ +

+
+

+
+
+ +
+
+

“特殊 token”需要看两个层次

+

+ 已核对的 Tag tokenizer 中,<tool_call> 与 + </tool_call> 对应独立词表 ID + 151657 和 151658,其 + special 字段均为 + false。服务代码再按分隔符字符串解析 + JSON。标记负责表示调用,函数执行由运行时和客户端完成。[6] +

+
+
+ DIRECT TOOLS +

前台生成业务工具调用

+

+ 给模型具体函数定义,例如 + get_weather(city)。模型填写函数名和参数,网关转换成 + function_call 事件,客户端执行并回传 + function_call_output。 +

+
+
+ LIVE DELEGATION +

前台把任务交给后台

+

+ 前台只拿到 delegate(task)。网关发出 + session.delegation.created,后台代理使用业务工具,结果经 + session.commentary.append 返回语音前台。 +

+
+
+

+ 启用 SOPHON_LIVE_BACKEND 后,代码会把前台工具替换为 + delegate,由配置的后台模型持有具体工具。该配置的表现应归于前后台系统。[7] +

+
+ 真实工具执行要显式接通 +

+ 基础引擎的默认工具模式是 + mock。未提供匹配模拟结果时,代码仍可返回含 + ok: true 和 + note: "mock result" 的对象。真实调用需要 + tool_mode: "client"、会话工具桥及执行端。8780 + 网关适配器会设置客户端模式。引擎代码 ↗ + 适配器 ↗ +

+
+

+ 公开微调样例也采用函数 schema、助手调用标记和 + role: tool + 结果。这展示了工具格式的训练路径,完整工具训练集及发布成绩的全部运行配置仍需更多资料。[8] +

+
+ +
+
05 / EVIDENCE
+

工具能力有哪些公开指标

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
证据作者报告解释范围
FDB-v3 工具任务 + Pass@1 48%
回应率 + 100% +
+ 作者自测,并注明从多次运行中取最佳。与原论文其他系统的历史成绩分别看待。 +
Live 小样本委派Tag / Flash 需委派项均为 7/7 + 14 条录音,7 条应委派、7 条应直接回答,使用 mock 后台。 +
避免不必要委派 + Tag 6/7
Flash 5/7 +
说明部分可直接回答的问题仍会交给后台。样本规模有限。
+
+

+ 来源 + 作者评测说明 + 与 + Live 测试说明。 +

+

+ 完整接入还需要分开测量工具选择、参数正确、外部执行结果、重复调用、任务终态和正确告知。前台、后台、提示规则与参数校验配置都影响结果,应记录为同一份系统配置。 +

+

+ 网关包含工具名检查与参数来源检查,结合 ASR、先前工具结果、指令及 + schema + 决定是否要求模型重做。当前代码还给拒绝重试设置了次数上限。公开的 + FDB-v3 成绩没有充分说明这些配置的开启情况。[9] +

+
+ +
+
06 / LATENCY
+

回复速度与 GPT-Live 怎样比较

+

+ Flash 的作者报告显示较短的 HTTP 首包路径。Tag 的双工报告值和 + GPT-Live + 的官方轮替值接近,整体体验仍需统一环境、输入与计时终点后比较。 +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
配置报告值计时对象
Sophon Flash0.31 s 中位数,P90 0.34 s + 一张 H100 80 GB、单条 HTTP 音频请求,客户端 POST + 前开始计时,到首个音频块。 +
Sophon Tag约 0.7 s作者文档的全双工“用户语音结束到首音”口径。
GPT-Live-10.798 s官方发布图中的 FDB-v1 轮替时延。
+
+

+ 来源 + Flash 测量条件、客户端计时、Tag、GPT-Live 发布材料。 +

+
+
+ 完整交互时间轴用户说完 → 上行与话轮判断 → 模型 / 工具 → 解码 → + 实际播放 +
+
+ Flash 公开客户端的计时提交已读入的 WAV → 收到并解码首块音频 +
+
+

+ 单看这些数值还无法给出同场速度排名。工具请求又有三个不同时间点,首次承接、工具派发、首个正确结果。先说“我查一下”可以很快,结果仍取决于后台模型与实际工具。 +

+

并发需要附带音频连续性

+

+ Flash 作者在 40 路请求、10 秒内到达时,报告首音中位数 0.58 s、P90 + 0.67 s,stall 中位数 0.37 s。Tag 在 16 路时报告首音中位数 0.75 + s、最大 1.07 + s。并发数、尾时延和播放停顿一起报告,才有明确服务含义。[3] + Tag 测量 +

+
+ +
+
07 / INTEGRATION
+

接入时需要核对的实际边界

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
观察项当前代码或资料验收动作
Realtime 取消response.cancel 当前为空操作。 + 验证调用方取消能否停止生成与播放,并单测后台任务状态。 +
历史截断 + conversation.item.truncate + 当前主要回确认事件,截断尚未传播到引擎。 + 用已播放、未播放的事实分别测试后续上下文。
文本输入 + 普通文本 + conversation.item.create + 会被记录为忽略,工具结果输入单独处理。 + 按实际客户端所用的消息类型逐项验证。
参数事件 + 完整参数 JSON 可一次放进 + arguments.delta,随后发 done。 + 记录实际派发时间,区分事件命名与逐 token 执行。
预热与首会话脚本包含预热,Live 文档记录首次会话可能无回应。区分冷启动、预热和稳定服务样本,保留失败。
E2E 预览模型目录与部署文档更新节奏有差异。固定实际权重 revision、运行入口与引擎版本。
许可材料 + Sophon NOTICE 标为 Apache-2.0,上游 Para 模型说明含 CC BY-NC + 4.0。 + 适用权重许可范围需要发布方澄清。
+
+

+ 代码与声明 + Realtime 适配器、Sophon NOTICE、上游许可说明。 +

+

+ 当前材料支持继续开展自托管和接口验证。模型效果、真实工具执行、API + 协议覆盖与语音体验分别验收。接入与配置变更后的结果,应重新建立固定版本、固定场景的证据。 +

+ 完整的双工评测标准与指标方案 ↗ +
+ +
+
08 / SOURCES
+

来源与核对范围

+

+ 截至 2026 年 11 月 9 + 日。模型指标采用作者报告值,接口行为采用固定提交的代码观察,交互步骤使用合成示例。GPU + 推理、真实声学链路与外部业务执行尚待实测。 +

+
    +
  1. + Sophon Realtime 原始项目构建与公开评测说明。本文独立解读,代码版本固定为 + b2754d47。 +
  2. +
  3. + Tag NOTICE骨干、音频编码器与语音模块来源。 +
  4. +
  5. + Flash README80 ms 声学帧、HTTP 首包与并发测量条件。 +
  6. +
  7. + Tag 客户端模型 ID 获取、请求体与音频流解码。 +
  8. +
  9. + 工具解析与口语流裁剪提示模板、JSON 解析、mock 与工具标记。 +
  10. +
  11. + Tag tokenizer 元信息固定权重元信息 revision 9a9ed240,核对分隔符 ID 与 special + 字段。 +
  12. +
  13. + 后台工具循环delegate、后台模型配置与真实工具调用。 +
  14. +
  15. + 微调说明与样例函数 schema、调用格式与 tool 结果消息。 +
  16. +
  17. + Realtime 网关函数调用事件、参数来源检查、取消与截断路径。 +
  18. +
  19. + Live 文档网关部署、委派事件、ASR、14 条 mock 后台测试。 +
  20. +
  21. + OpenAI GPT-Live-1 API 发布材料FDB-v1 + 轮替时延及后台配置应分别解读,完整补充见通用评测站。 +
  22. +
+
+ +
+
+ + diff --git a/model.js b/model.js new file mode 100644 index 0000000..9876e61 --- /dev/null +++ b/model.js @@ -0,0 +1,208 @@ +export const builds = { + flash: { + title: "Flash", + directory: "flash", + modelDirectory: "Sophon-Realtime-32B-Flash", + flow: ["用户语音", "共享 32B 理解与文本", "Flash 语音头", "24 kHz 语音"], + detail: + "声音侧以每 80 ms 帧一次前向为设计。作者报告单 H100 HTTP 首包中位数 0.31 s,实例约 55 GB 显存。", + modes: ["http", "engine", "realtime", "live"], + }, + tag: { + title: "Tag", + directory: "tag", + modelDirectory: "Sophon-Realtime-32B-Tag", + flow: [ + "用户语音", + "共享 32B 与声音标签", + "tag-tts 语音模型", + "Flow + HiFT 语音", + ], + detail: + "模型写出笑声、叹气等声音标签,由专用语音模块表现。作者报告单 80 GB GPU、实例约 65 GB 显存。", + modes: ["http", "engine", "realtime", "live"], + }, + e2e: { + title: "E2E 预览", + directory: "", + modelDirectory: "Sophon-Realtime-32B-E2E", + flow: [ + "用户语音", + "32B Thinker 与 Talker", + "连续语音向量", + "E2 解码与语音", + ], + detail: + "采用独立推理引擎与 /ws 网关,文档要求两张 80 GB GPU。当前作为研究预览,部署文档与模型目录版本需逐项固定。", + modes: ["e2e"], + }, +}; + +export const callModes = { + http: { + title: "HTTP 音频生成", + kind: "POST", + endpoint: "http://HOST:8000/v1/chat/completions", + detail: + "先用完整 WAV 验证语音生成。官方客户端先取 /v1/models,再流式解码文本与音频。", + }, + engine: { + title: "基础引擎双工", + kind: "WebSocket", + endpoint: "ws://HOST:8000/v1/realtime", + detail: + "持续推送音频,模型参与话轮决策。原生工具桥需要自行对接,默认 mock 与真实执行分别核验。", + }, + realtime: { + title: "网关函数调用", + kind: "WebSocket", + endpoint: "ws://HOST:8780/v1/realtime", + detail: + "启动 Live 网关后,使用其 Realtime 适配器接收 function_call 与回填结果。当前协议覆盖有边界。", + }, + live: { + title: "Live 客户端委派", + kind: "WebSocket", + endpoint: "ws://HOST:8780/v1/live/sessions", + detail: + "前台生成 delegate(task),客户端接委派事件,并把后台结果经 commentary.append 返回。", + }, + e2e: { + title: "E2E 自有协议", + kind: "WebSocket", + endpoint: "ws://HOST:8000/ws", + detail: + "按 E2E 文档使用 config、二进制 PCM16、text、barge_in、done 等事件。", + }, +}; + +export function makeRecipe(buildKey, modeKey) { + const build = builds[buildKey]; + if (!build || !build.modes.includes(modeKey)) { + throw new RangeError( + "The selected build does not support this documented path.", + ); + } + if (modeKey === "e2e") { + return { + startup: `# 在已准备 Linux / CUDA / 权重的环境中 +SKIP_DOWNLOAD=1 MODEL_DIR=/path/to/${build.modelDirectory} bash scripts/install.sh +SOPHON_MODEL_DIR=/path/to/${build.modelDirectory} THINKER_GPU=0 TALKER_GPU=1 bash scripts/start.sh`, + request: `连接 ws://HOST:8000/ws 后先发送 +{"type":"config","lang":"zh","history":true} + +随后持续发送 16 kHz、单声道 PCM16 二进制音频。 +接收 text / 二进制 24 kHz 音频 / barge_in / metrics / done。`, + }; + } + const startup = `# 在官方仓库 ${build.directory}/ 中,权重须预先准备 +SKIP_DOWNLOAD=1 bash install.sh +MODEL_DIR=/path/to/${build.modelDirectory} GPU=0 PORT=8000 bash start.sh +# 等待 READY 与预热完成`; + if (modeKey === "http") { + return { + startup: `${startup} + +python client_example.py --url http://HOST:8000 --wav question.wav --lang zh --voice chelsie`, + request: `先从 GET /v1/models 取得模型 ID。 +POST /v1/chat/completions +{ + "model": "<返回的模型 ID>", + "stream": true, + "modalities": ["text", "audio"], + "language": "zh", + "messages": [ + {"role":"system","content":""}, + {"role":"user","content":[ + {"type":"text","text":"<|audio|>"}, + {"type":"input_audio","input_audio":{"data":"","format":"wav"}} + ]} + ] +}`, + }; + } + if (modeKey === "engine") { + return { + startup, + request: `连接 ws://HOST:8000/v1/realtime +{"type":"session.update","session":{"extra_body":{"auto_response":true,"language":"zh"}}} +{"type":"input_audio_buffer.append","audio":"<持续发送的 PCM16_BASE64>"} + +接真实工具时,额外配置 tools / tool_mode=client / bridge_key。 +工具桥默认在引擎本机 PORT+700,需配套执行与回填逻辑。`, + }; + } + const gatewayStartup = `${startup} + +# 同一环境启动外层网关 +cd ../live +SOPHON_LIVE_ASR=/path/to/Qwen3-ASR-0.6B-hf BUILD=${build.directory} PORT=8000 LIVE_PORT=8780 bash start_live.sh`; + if (modeKey === "realtime") { + return { + startup: gatewayStartup, + request: `连接 ws://HOST:8780/v1/realtime 后配置工具 +{"type":"session.update","session":{"instructions":"简短回答用户","tools":[ + {"type":"function","name":"get_weather","description":"查询城市天气", + "parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}} +]}} + +收到 function_call 后,由客户端执行真实函数,再回填 +{"type":"conversation.item.create","item":{"type":"function_call_output","call_id":"<返回的 call_id>","output":"<真实结果>"}} +{"type":"response.create"}`, + }; + } + return { + startup: gatewayStartup, + request: `连接 ws://HOST:8780/v1/live/sessions +{"type":"session.start","session":{"instructions":"用中文简短回答","delegation":{"type":"client"}}} +{"type":"session.input_audio.append","audio":""} + +接收 session.delegation.created,后台完成后回填 +{"type":"session.commentary.append","delegation_id":"<返回的委派 ID>","content":"<真实后台结果>"}`, + }; +} + +export const toolSteps = [ + { + title: "提供函数定义", + explanation: + "调用方声明函数名、说明与参数 schema。运行时经 chat template 把这些定义提供给模型。", + code: '{"type":"function","name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}', + }, + { + title: "模型生成调用", + explanation: + "模型可以先给一句承接语,再输出工具名与参数。下面的天气任务为合成示例。", + code: '我帮你查一下。\n\n{"name":"get_weather","arguments":{"city":"上海"}}\n', + }, + { + title: "口语流与控制流分开", + explanation: + "cut_tool 保留调用标记前的口语。完整或尚未写完的工具标记会被拦住,避免进入语音合成。", + code: '送往语音模块\n我帮你查一下。\n\n留给工具解析器\n{"name":"get_weather","arguments":{"city":"上海"}}', + }, + { + title: "解析与桥接", + explanation: + "Thinker 写完后,服务解析函数调用,发出带 call_id、name 和 arguments 的工具桥事件。此时承接语音可能仍在播放。", + code: '{"type":"tool_call","call_id":"call_demo","name":"get_weather","arguments":{"city":"上海"}}', + }, + { + title: "客户端执行真实函数", + explanation: + "真实执行端取出函数和参数,调用业务系统。mock 模式则返回模拟值。两类证据必须分开记录。", + code: 'result = await actual_weather_service(city="上海")\n# 记录真实调用 ID、参数、返回、耗时与最终状态', + }, + { + title: "回填结果", + explanation: + "结果经客户端工具桥,或网关的 function_call_output / commentary.append 返回。下方内容显式标为示意。", + code: '{"type":"function_call_output","call_id":"call_demo","output":"示例结果:天气晴朗"}', + }, + { + title: "模型继续回应", + explanation: + "结果被加入工具历史,后续语音从该结果继续生成。正确选择函数、真实执行和正确告知各自评分。", + code: "role: tool\n示例结果:天气晴朗\n\nassistant\n查到了,示例结果显示天气晴朗。", + }, +]; diff --git a/model.test.mjs b/model.test.mjs new file mode 100644 index 0000000..d6187e5 --- /dev/null +++ b/model.test.mjs @@ -0,0 +1,35 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { builds, callModes, makeRecipe, toolSteps } from "./model.js"; + +test("every documented build and mode has distinct startup and request examples", () => { + for (const [key, build] of Object.entries(builds)) { + for (const mode of build.modes) { + assert.ok(callModes[mode]); + const example = makeRecipe(key, mode); + assert.ok(example.startup.includes("SKIP_DOWNLOAD=1")); + assert.ok(example.request.length > 30); + } + } +}); +test("E2E cannot select an undocumented OpenAI-style entry", () => { + assert.throws(() => makeRecipe("e2e", "http"), RangeError); + assert.equal(callModes.e2e.endpoint, "ws://HOST:8000/ws"); +}); +test("Live and Realtime gateways retain their distinct event formats", () => { + assert.ok( + makeRecipe("flash", "live").request.includes("session.commentary.append"), + ); + assert.ok( + makeRecipe("tag", "realtime").request.includes("function_call_output"), + ); + assert.ok( + makeRecipe("flash", "http").startup.includes("python client_example.py"), + ); +}); +test("tool walkthrough distinguishes model output and actual execution", () => { + assert.equal(toolSteps.length, 7); + assert.ok(toolSteps[1].code.includes("")); + assert.ok(toolSteps[4].code.includes("actual_weather_service")); + assert.ok(toolSteps[5].code.includes("示例结果")); +}); diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..abd4e3e --- /dev/null +++ b/nginx.conf @@ -0,0 +1,12 @@ +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + charset utf-8; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always; + location = /healthz { access_log off; default_type text/plain; return 200 "ok\n"; } + location / { try_files $uri $uri/ =404; } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1e1ef38 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "sophon-realtime-research-guide", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "check": "node --check app.js && node --check model.js && node --test model.test.mjs && node validate.mjs", + "build": "node build.mjs", + "dev": "python3 -m http.server 4174 --bind 127.0.0.1 --directory dist" + }, + "engines": { + "node": ">=20" + } +} diff --git a/release.json b/release.json new file mode 100644 index 0000000..7ce24cb --- /dev/null +++ b/release.json @@ -0,0 +1,7 @@ +{ + "release": "sophon-realtime-20261109-v1", + "researchCutoff": "2026-11-09", + "canonicalUrl": "https://sophon-realtime.k1412.top/", + "codeAudited": "b2754d4753e20ecfa860db0cda4325e4479f4427", + "gpuInferenceExecuted": false +} diff --git a/robots.txt b/robots.txt new file mode 100644 index 0000000..c2a49f4 --- /dev/null +++ b/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..82bea8a --- /dev/null +++ b/styles.css @@ -0,0 +1,931 @@ +:root { + color-scheme: light; + --paper: #f4f3ef; + --surface: #fffefa; + --ink: #202b29; + --muted: #69756f; + --line: #d5dcd3; + --green: #2d755c; + --green-soft: #e2ece5; + --blue: #3d68a3; + --code: #eaece7; + --amber: #94652b; + --amber-soft: #f1e8d7; +} +html[data-theme="dark"] { + color-scheme: dark; + --paper: #18201d; + --surface: #202a24; + --ink: #e2e9df; + --muted: #a6b1a5; + --line: #3c4c40; + --green: #9ac6a6; + --green-soft: #2d4434; + --blue: #9fbfe8; + --code: #28362c; + --amber: #dfb16e; + --amber-soft: #443b2c; +} +* { + box-sizing: border-box; +} +html { + scroll-behavior: smooth; + scroll-padding-top: 100px; +} +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Microsoft YaHei", sans-serif; + line-height: 1.85; + -webkit-font-smoothing: antialiased; +} +a { + color: inherit; + text-decoration: none; +} +a:hover { + color: var(--green); +} +button, +select { + font: inherit; + color: inherit; +} +button { + cursor: pointer; +} +button:disabled { + opacity: 0.35; + cursor: default; +} +a:focus-visible, +button:focus-visible, +select:focus-visible { + outline: 3px solid var(--blue); + outline-offset: 4px; +} +h1, +h2, +h3, +h4, +p { + margin: 0; +} +strong { + font-weight: 600; +} +code, +pre { + font-family: "SFMono-Regular", Consolas, monospace; +} +p code, +td code { + font-size: 0.87em; + overflow-wrap: anywhere; + color: var(--green); +} +.skip-link { + position: absolute; + top: -80px; + left: 20px; + background: var(--ink); + color: var(--paper); + padding: 8px 15px; + z-index: 20; +} +.skip-link:focus { + top: 15px; +} +.site-header { + position: sticky; + top: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: space-between; + height: 80px; + background: var(--paper); + border-bottom: 1px solid var(--line); + padding: 0 5vw; +} +.brand { + display: flex; + align-items: center; + gap: 17px; +} +.brand b { + font: + 37px Georgia, + serif; + letter-spacing: -4px; +} +.brand > span { + font-size: 11px; + letter-spacing: 1.8px; +} +.brand i { + font-style: normal; + color: var(--muted); + margin-left: 9px; +} +.site-header nav { + display: flex; + gap: 24px; + align-items: center; + font-size: 12px; +} +#theme-toggle { + border: 1px solid var(--line); + background: transparent; + border-radius: 50%; + width: 35px; + height: 35px; + padding: 0; + font-size: 20px; +} +.layout { + display: grid; + grid-template-columns: 180px minmax(0, 900px); + gap: 70px; + max-width: 1300px; + margin: 0 auto; + padding: 0 45px; +} +.contents { + position: sticky; + top: 80px; + align-self: start; + padding-top: 63px; +} +.eyebrow { + color: var(--muted); + font-size: 10px; + font-weight: 600; + letter-spacing: 1.6px; +} +.contents nav { + display: flex; + flex-direction: column; + gap: 15px; + margin-top: 32px; + font-size: 12px; + color: var(--muted); +} +.version-note { + margin-top: 32px; + padding-top: 21px; + border-top: 1px solid var(--line); + font-size: 11px; + color: var(--muted); + line-height: 1.9; +} +.version-note strong { + color: var(--ink); + font-size: 13px; + letter-spacing: 1px; +} +.version-note span { + display: block; + margin-top: 11px; +} +.hero { + padding-top: 60px; +} +h1 { + font-size: clamp(35px, 4.1vw, 56px); + line-height: 1.27; + font-weight: 600; + letter-spacing: -1.3px; + margin: 20px 0 22px; +} +.deck { + font-size: 17px; + color: var(--muted); + line-height: 1.9; +} +.hero-meta { + display: flex; + flex-wrap: wrap; + gap: 10px 20px; + color: var(--muted); + font-size: 11px; + margin: 23px 0 33px; +} +.hero-meta a { + color: var(--green); +} +.hero-route { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); + padding: 26px 0; + margin-bottom: 31px; + font-size: 13px; +} +.hero-route b { + color: var(--muted); + font-weight: 400; +} +.hero-route code { + color: var(--green); + font-size: 15px; + background: var(--green-soft); + padding: 7px 12px; +} +main p { + font-size: 14px; + line-height: 1.95; +} +p + p { + margin-top: 17px; +} +main .lead { + font-size: 18px; + line-height: 1.95; + margin-bottom: 18px; +} +.hero > p:last-child { + color: var(--muted); + font-size: 12px; +} +.hero > p:last-child code { + overflow-wrap: anywhere; +} +.article-section { + border-top: 1px solid var(--line); + padding-top: 37px; + margin-top: 58px; + scroll-margin-top: 105px; +} +.section-number { + font-size: 10px; + color: var(--green); + letter-spacing: 1.8px; + font-weight: 600; + margin-bottom: 12px; +} +h2 { + font-size: 28px; + line-height: 1.5; + font-weight: 600; + letter-spacing: -0.3px; + margin-bottom: 23px; +} +h3 { + font-size: 19px; + font-weight: 600; + line-height: 1.6; + margin: 28px 0 13px; +} +.ref { + font-size: 11px; + color: var(--green); + margin-left: 6px; + white-space: nowrap; +} +.table-wrap { + margin: 25px 0; + overflow-x: auto; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} +table { + border-collapse: collapse; + width: 100%; + min-width: 610px; + font-size: 12px; + line-height: 1.9; +} +th, +td { + padding: 14px 12px; + text-align: left; + vertical-align: top; + border-bottom: 1px solid var(--line); +} +th { + font-weight: 600; +} +thead th { + font-size: 11px; + color: var(--muted); +} +tbody th { + width: 20%; + min-width: 100px; +} +tr:last-child > * { + border-bottom: 0; +} +th:first-child, +td:first-child { + padding-left: 0; +} +th:last-child, +td:last-child { + padding-right: 0; +} +.source-line { + color: var(--muted); +} +main .source-line { + font-size: 11px; + line-height: 1.9; +} +.source-line a { + color: var(--green); +} +.evidence-note { + border-left: 2px solid var(--green); + padding: 13px 0 13px 20px; + margin: 25px 0; +} +.evidence-note > strong { + font-size: 14px; +} +.evidence-note p { + margin-top: 9px; + font-size: 12px; + color: var(--muted); +} +.evidence-note a { + color: var(--green); +} +.figure { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 5px; + padding: 26px; + margin: 26px 0; +} +.figure-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} +.figure-heading h3 { + font-size: 20px; + margin: 5px 0 22px; +} +.tag { + font-size: 10px; + white-space: nowrap; + padding: 4px 7px; + border-radius: 3px; + line-height: 1.6; +} +.audit { + background: var(--green-soft); + color: var(--green); +} +.example { + background: var(--amber-soft); + color: var(--amber); +} +.segmented { + display: inline-flex; + border: 1px solid var(--line); + border-radius: 4px; + overflow: hidden; +} +.segmented button { + background: transparent; + border: 0; + padding: 8px 22px; + font-size: 12px; +} +.segmented button[aria-pressed="true"] { + background: var(--ink); + color: var(--paper); +} +.flow { + display: flex; + align-items: center; + gap: 9px; + justify-content: space-between; + margin: 30px 0 24px; +} +.flow > span { + flex: 1; + background: var(--code); + text-align: center; + padding: 15px 8px; + font-size: 12px; + line-height: 1.8; +} +.flow > span:first-child { + background: var(--green-soft); +} +.flow > b { + font-weight: 400; + color: var(--muted); +} +.selected-detail { + font-size: 12px; + color: var(--muted); + min-height: 45px; + border-top: 1px solid var(--line); + padding-top: 17px; + line-height: 1.9; +} +.call-controls { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; +} +.call-controls label { + font-size: 11px; + color: var(--muted); +} +select { + display: block; + width: 100%; + border: 1px solid var(--line); + border-radius: 3px; + background: var(--paper); + padding: 9px 12px; + margin-top: 7px; + font-size: 13px; +} +.endpoint { + display: flex; + align-items: center; + gap: 12px; + margin: 22px 0 12px; +} +.endpoint > span { + font-size: 10px; + color: var(--green); + font-weight: 600; +} +.endpoint code { + font-size: 13px; + overflow-wrap: anywhere; +} +main .small { + font-size: 11px; + color: var(--muted); + line-height: 1.9; +} +.code-heading { + display: flex; + align-items: center; + justify-content: space-between; + margin: 20px 0 11px; + font-size: 11px; + color: var(--muted); +} +.recipe-parts { + display: flex; + gap: 8px; + margin-top: 19px; +} +.recipe-parts button { + background: transparent; + border: 1px solid var(--line); + border-radius: 3px; + padding: 5px 12px; + font-size: 11px; +} +.recipe-parts button[aria-pressed="true"] { + background: var(--green-soft); + color: var(--green); +} +.code-heading button, +.step-controls button { + border: 1px solid var(--line); + border-radius: 3px; + background: transparent; + padding: 5px 12px; + font-size: 11px; +} +pre { + background: var(--code); + margin: 0 0 11px; + padding: 20px; + font-size: 11px; + line-height: 1.9; + overflow-x: auto; + tab-size: 2; +} +#copy-status { + min-height: 21px; +} +.step-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 24px; +} +.step-list button { + background: transparent; + border: 1px solid var(--line); + padding: 6px 9px; + font-size: 10px; + border-radius: 3px; +} +.step-list button[aria-pressed="true"] { + background: var(--green-soft); + color: var(--green); + border-color: var(--green); +} +.step-head { + display: flex; + gap: 14px; + align-items: center; + margin-bottom: 12px; +} +.step-head > span { + font: + 28px Georgia, + serif; + color: var(--green); +} +.step-head h4 { + font-size: 17px; + font-weight: 600; +} +#step-explanation { + min-height: 59px; + margin-bottom: 19px; + font-size: 13px; +} +#step-code { + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.step-controls { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 18px; +} +.step-controls > span { + font-size: 11px; + color: var(--muted); + font-variant-numeric: tabular-nums; +} +.two-paths { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 31px; + margin: 28px 0; +} +.two-paths > div { + border-top: 1px solid var(--line); + padding-top: 15px; +} +.two-paths h3 { + font-size: 17px; + margin: 8px 0 12px; +} +.two-paths p { + font-size: 12px; +} +.clock-boundaries { + margin: 26px 0; +} +.clock-boundaries > div { + padding: 15px 0; + border-top: 1px solid var(--line); +} +.clock-boundaries span { + color: var(--muted); + display: block; + font-size: 11px; + margin-bottom: 7px; +} +.clock-boundaries strong { + display: block; + font-size: 13px; + font-weight: 500; +} +.text-link { + display: inline-block; + margin-top: 22px; + color: var(--green); + font-size: 13px; +} +.source-list { + margin: 26px 0; + padding-left: 24px; +} +.source-list li { + border-top: 1px solid var(--line); + padding: 13px 0 13px 6px; + scroll-margin-top: 105px; +} +.source-list li::marker { + font-size: 11px; + color: var(--green); +} +.source-list a { + font-size: 13px; + font-weight: 500; +} +.source-list span { + display: block; + font-size: 11px; + color: var(--muted); + margin-top: 4px; +} +footer { + margin-top: 55px; + padding: 26px 0 35px; + border-top: 1px solid var(--line); + display: flex; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + color: var(--muted); + font-size: 10px; +} +footer > div { + display: flex; + gap: 20px; +} +footer small { + flex-basis: 100%; + font-size: 9px; +} +@media (max-width: 1050px) { + .layout { + grid-template-columns: 145px minmax(0, 1fr); + gap: 35px; + padding: 0 28px; + } + .contents nav { + font-size: 11px; + } + h1 { + font-size: 44px; + } + .figure { + padding: 22px; + } + .flow > span { + font-size: 11px; + } +} +@media (max-width: 780px) { + .site-header { + height: 68px; + padding: 0 22px; + } + .site-header nav { + gap: 17px; + font-size: 11px; + } + .brand { + gap: 10px; + } + .brand > span { + font-size: 10px; + } + .brand i { + display: none; + } + .layout { + display: block; + padding: 0 24px; + } + .contents { + position: static; + padding: 20px 0 0; + } + .contents > .eyebrow, + .version-note { + display: none; + } + .contents nav { + margin: 0; + flex-direction: row; + flex-wrap: wrap; + gap: 7px 17px; + padding-bottom: 17px; + border-bottom: 1px solid var(--line); + font-size: 10px; + } + .contents nav a:nth-child(n + 5) { + display: none; + } + .hero { + padding-top: 35px; + } + h1 { + font-size: 39px; + } + .deck { + font-size: 15px; + } + .article-section { + margin-top: 47px; + padding-top: 29px; + } + h2 { + font-size: 25px; + } + .hero-route { + font-size: 11px; + gap: 6px; + } + .hero-route code { + font-size: 12px; + padding: 6px 9px; + } + .flow { + gap: 7px; + } +} +@media (max-width: 480px) { + .site-header { + padding: 0 17px; + } + .site-header nav > a:first-child { + display: none; + } + .site-header nav { + gap: 14px; + } + .brand b { + font-size: 32px; + } + .layout { + padding: 0 19px; + } + .hero > .eyebrow { + font-size: 8px; + letter-spacing: 1px; + } + h1 { + font-size: clamp(28px, 7.9vw, 31px); + letter-spacing: -0.6px; + line-height: 1.35; + margin-top: 16px; + } + .hero-meta { + font-size: 10px; + gap: 7px 13px; + } + .hero-route { + flex-wrap: nowrap; + justify-content: space-between; + gap: 3px; + padding: 19px 0; + } + .hero-route > span { + font-size: 10px; + } + .hero-route code { + font-size: 10px; + padding: 5px 6px; + } + main .lead { + font-size: 16px; + } + main p { + font-size: 13px; + } + h2 { + font-size: clamp(21px, 5.9vw, 23px); + } + h3 { + font-size: 18px; + } + .figure { + padding: 18px 14px; + margin: 23px -3px; + } + .figure-heading { + display: block; + } + .figure-heading h3 { + font-size: 17px; + margin-bottom: 12px; + } + .tag { + font-size: 8px; + padding: 4px 5px; + display: inline-block; + margin-bottom: 17px; + } + .eyebrow { + font-size: 9px; + } + .segmented { + width: 100%; + } + .segmented button { + flex: 1; + padding: 8px 10px; + font-size: 11px; + } + .flow { + flex-direction: column; + gap: 7px; + margin: 22px 0; + } + .flow > span { + width: 100%; + padding: 11px; + font-size: 12px; + } + .flow > b { + line-height: 1; + transform: rotate(90deg); + } + .selected-detail { + font-size: 11px; + min-height: 73px; + } + .call-controls { + grid-template-columns: 1fr; + gap: 14px; + } + .endpoint { + align-items: flex-start; + gap: 9px; + } + .endpoint code { + font-size: 11px; + } + pre { + padding: 14px; + font-size: 10px; + } + .step-list button { + font-size: 9px; + padding: 6px 7px; + } + .step-head h4 { + font-size: 15px; + } + #step-explanation { + font-size: 12px; + min-height: 91px; + } + .two-paths { + grid-template-columns: 1fr; + gap: 24px; + } + .two-paths h3 { + font-size: 16px; + } + .clock-boundaries strong { + font-size: 12px; + } + .source-list a { + font-size: 12px; + } + footer { + display: block; + font-size: 9px; + } + footer > div { + margin: 16px 0; + gap: 15px; + } + footer small { + display: block; + } +} +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + } +} +@media print { + .site-header, + .contents, + button, + select { + display: none; + } + .layout { + display: block; + padding: 0; + } + .figure { + break-inside: avoid; + } + .table-wrap { + overflow: visible; + } + table { + min-width: 0; + } +} diff --git a/validate.mjs b/validate.mjs new file mode 100644 index 0000000..80637da --- /dev/null +++ b/validate.mjs @@ -0,0 +1,13 @@ +import { readFileSync } from "node:fs"; +import assert from "node:assert/strict"; + +const html = readFileSync(new URL("./index.html", import.meta.url), "utf8"); +const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map((match) => match[1]); +assert.equal(ids.length, new Set(ids).size); +for (const match of html.matchAll(/href="#([^"]+)"/g)) { + assert.ok(ids.includes(match[1]), `Missing anchor ${match[1]}`); +} +assert.ok(html.includes("GPU 推理待实测")); +assert.ok(html.includes("mock")); +assert.ok(html.includes("special")); +console.log(`Validated ${ids.length} anchors and model evidence labels.`);