From 83f47dd85723a4f9f238aa31788522a2f706bad3 Mon Sep 17 00:00:00 2001 From: wuyang6 Date: Mon, 9 Nov 2026 12:30:33 +0800 Subject: [PATCH] Publish Sophon Realtime calling tool protocol and latency guide --- .dockerignore | 6 + .gitignore | 6 + Dockerfile | 10 + LICENSE | 21 ++ README.md | 15 + app.js | 150 ++++++++ build.mjs | 17 + favicon.svg | 1 + index.html | 796 ++++++++++++++++++++++++++++++++++++++++++ model.js | 208 +++++++++++ model.test.mjs | 35 ++ nginx.conf | 12 + package.json | 14 + release.json | 7 + robots.txt | 2 + styles.css | 931 +++++++++++++++++++++++++++++++++++++++++++++++++ validate.mjs | 13 + 17 files changed, 2244 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app.js create mode 100644 build.mjs create mode 100644 favicon.svg create mode 100644 index.html create mode 100644 model.js create mode 100644 model.test.mjs create mode 100644 nginx.conf create mode 100644 package.json create mode 100644 release.json create mode 100644 robots.txt create mode 100644 styles.css create mode 100644 validate.mjs 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.`);