From b9b33c8ba1a3957862d1a8190330c29b5e999f5d Mon Sep 17 00:00:00 2001 From: wuyang6 Date: Fri, 6 Nov 2026 21:36:16 +0800 Subject: [PATCH] Build model latency reference and duplex dialogue flow estimator --- .dockerignore | 4 + .gitignore | 4 + Dockerfile | 14 + LICENSE | 21 + README.md | 42 ++ index.html | 16 + nginx.conf | 12 + package-lock.json | 1115 +++++++++++++++++++++++++++++++++++++++++++ package.json | 19 + public/favicon.svg | 1 + public/preview.svg | 6 + src/App.jsx | 140 ++++++ src/catalog.js | 88 ++++ src/engine.js | 238 +++++++++ src/main.jsx | 6 + src/presets.js | 29 ++ src/styles.css | 9 + test/engine.test.js | 90 ++++ 18 files changed, 1854 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 index.html create mode 100644 nginx.conf create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/favicon.svg create mode 100644 public/preview.svg create mode 100644 src/App.jsx create mode 100644 src/catalog.js create mode 100644 src/engine.js create mode 100644 src/main.jsx create mode 100644 src/presets.js create mode 100644 src/styles.css create mode 100644 test/engine.test.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..490b1e0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +node_modules +.git +test +*.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9451024 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.DS_Store +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..89e7f72 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM node:24-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci --no-audit --no-fund +COPY index.html ./ +COPY src ./src +COPY public ./public +RUN npm run build +FROM nginx:1.28-alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build --chmod=0755 /app/dist/ /usr/share/nginx/html/ +RUN find /usr/share/nginx/html -type f -exec chmod 644 {} \; +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s 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..ebe95c4 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# 模型时延实验室 / Latency Lab + +公开模型时延对照与对话系统流程估算器。资料截止 **2026-11-06**。 + +- 58条带出处的性能记录,区分公开报告、理论时延、条件化结果和未知数据。 +- 文本、原生语音、TTS组件对照;参数规模、激活参数、硬件、精度、输入长度和并发条件。 +- 可编辑的有向流程:串行、多层级联、并行分支、全部/任一汇总、首包/文本块/完整结果触发。 +- 模型节点loop最大步数(1–100)、预计步数、每步思考与输出、上下文增长、步骤间工具等待。 +- 首结果关键路径、全部目标完成、自然语音播放、持续负载RTF、共享资源调度与敏感性区间。 +- 本地自动保存、JSON导入导出、CSV数据导出、专注画布和可缩放时间线。 + +## 本地运行 + +需要Node.js 24或满足Vite 8要求的Node版本。 + +```sh +npm ci +npm test +npm run dev +``` + +生产构建:`npm run build`。Docker镜像使用多阶段构建,Nginx监听8080,健康检查为`/healthz`。 + +## 计算边界 + +`src/catalog.js`保存公开来源和性能记录。`src/engine.js`保存工程估算与图调度,两者明确分开。 + +时间原点是用户停止说话。流式输入可以在此前持续预填充;只有尾包处理与判停等待计入后续延迟。图上禁止环路,重复调用在模型节点内部展开。并行组默认拥有独立资源,显式共享资源组按拓扑顺序串行调度。 + +流式连接允许提前启动下游,并保留上游最终数据对完成时间的约束。它采用平均供给率近似,不能替代逐帧背压和音素对齐仿真。RTF按预设每轮音频预算计算;模型Agent的循环工作也占用该预算,因此只用于设计负载检查。 + +最大步数表示预算情景,不代表任务一定成功或实际一定跑满。敏感性区间为模型与程序阶段的0.75–1.5倍变化,不是统计置信区间、p95或SLA。跨硬件倍率、首token模型、MoE与大尺寸外推均为可校准工程假设;未在本项目中执行模型硬件基准。 + +公开记录不能直接组成统一排行榜。未知参数量保持未知;首文本token、首音频、打断停止和RTF不混用。历史Cerebras结果不代表当前可用性。受控MTP接受数、放宽验证和纯理论数字有独立标记。 + +## 验证 + +Node测试覆盖串行/并行关键路径、任一汇总、流式完成约束、token门槛、loop上界、隐藏思考、共享资源、判停/网络边界、循环拒绝、导入验证和显存不足提示。浏览器检查桌面与移动布局及主要交互。 + +## 数据与许可 + +代码采用MIT许可。性能数字和说明由公开一手资料整理;每条记录保留来源链接,原始论文、商标及第三方资料权利属于各自作者。本仓库不分发第三方模型权重。 diff --git a/index.html b/index.html new file mode 100644 index 0000000..a5b1a93 --- /dev/null +++ b/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + + + + 模型时延实验室 · Latency Lab + +
+ diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..3a03e54 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,12 @@ +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + add_header X-Frame-Options SAMEORIGIN always; + location = /healthz { access_log off; default_type text/plain; return 200 "ok\n"; } + location /assets/ { expires 1y; add_header Cache-Control "public, immutable"; try_files $uri =404; } + location / { add_header Cache-Control "no-cache"; try_files $uri $uri/ /index.html; } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c3cf7da --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1115 @@ +{ + "name": "latency-lab", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "latency-lab", + "version": "1.0.0", + "dependencies": { + "@xyflow/react": "12.12.1", + "lucide-react": "1.70.0", + "react": "19.3.1", + "react-dom": "19.3.1" + }, + "devDependencies": { + "vite": "8.3.6" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", + "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz", + "integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz", + "integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz", + "integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz", + "integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz", + "integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz", + "integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz", + "integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz", + "integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz", + "integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz", + "integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz", + "integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz", + "integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz", + "integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz", + "integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz", + "integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz", + "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.9.tgz", + "integrity": "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@xyflow/react": { + "version": "12.12.1", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.12.1.tgz", + "integrity": "sha512-g41OHmP+GL5wDBQKpgYt8AF6RlB4CrABtiStXhCMqdItK6gPdSpsuDk8V0YtdyvIvMPTjDJvkuLkz0JbSYu5Cw==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.84", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.84", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.84.tgz", + "integrity": "sha512-96zfGFjH3oZ3T0MRdH4gJ0SlXuCIxbBuHVevZ4xry5UTxqjzjt010+LRdholgepq7jhrcaz1JNtzrETscwnkEA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.1.tgz", + "integrity": "sha512-lfab8x0Bj0UO7SNsY8dZSbNIA6ipbkE3X0NG9nqjCu6CfCHZseuDnOSJY7hidKrjpP++i1M2qT+FlAdPKYRilA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.2.tgz", + "integrity": "sha512-sAzH4Y4nsLo8eSqw5jKiZt+LU2wZmL/qS+OkxQGjhwazB/D7TY34R/QK1eshfGr7cZEKymKZ/Haem7m+lu98Sg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.1.tgz", + "integrity": "sha512-OWeoayCq8J69hUIjvL+Mb6Q6Oaj/fyHV5XaXByEUAG+JUjIfTGDnk3qGpcXrD3VmQ3BSV96fcbM6fqq7C0tbPA==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-B5znKqRmKROAN4oHWQ50W/qisVjA9GXh2WMsVKdFVp4DxWmcrOsweqgUOUVsv5th96kwaZlokU6SlV4J3gak1w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.2.tgz", + "integrity": "sha512-IRzGU62DtxhvsnSTWMQIdnoZ6gA+BCWwBon1EJI6SjoN595799b6z/QRBzXfFhE8iupUPp3042yBNo0STPqnzQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.1.tgz", + "integrity": "sha512-ODSf35vqCrlCfp7hrm0eZjvOosSXSYLjqvIlhMBV9q9VOx7EBJG6pc+uXBswEZNJgCGJ+qnKYcWutbZDIxe/Xg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-1Il1aliroVSErrv5aulRBxAY+7+DnaoLIWGd1C6SsBTWDeobWiP/V69vwb8vT6abR1kKwaNe1196pmaQ1fK90A==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.2.tgz", + "integrity": "sha512-RcjCsdFgyFoDB+QXLC5m60ZkTN0k9zH+ovoLlTdJnDS1AbfbcuowiM4pCKSTOclNibS4f4J4cbv2m7O7y8NTOQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.1.tgz", + "integrity": "sha512-kfjD3BDQu/FOatpZ6JdMSyIoDwTxIpI29ySTI2J5ZlDaH1WbuJGQJ2Q9oJkWTEZEW98j6idezoomSD3Cn2zwiw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.70.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.70.0.tgz", + "integrity": "sha512-gCTPPN1MlQUBtUgdsUv+0CAUj+X8hifRUiRBw4E5v7lvDIOIpx0EZezdGQMSimS8pKA6esvullJub4GYr0mCHA==", + "license": "ISC", + "peerDependencies": { + "@types/react": "*", + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.1" + } + }, + "node_modules/rolldown": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz", + "integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.156.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.16", + "@rolldown/binding-android-arm64": "1.2.16", + "@rolldown/binding-darwin-arm64": "1.2.16", + "@rolldown/binding-darwin-x64": "1.2.16", + "@rolldown/binding-freebsd-x64": "1.2.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", + "@rolldown/binding-linux-arm64-gnu": "1.2.16", + "@rolldown/binding-linux-arm64-musl": "1.2.16", + "@rolldown/binding-linux-ppc64-gnu": "1.2.16", + "@rolldown/binding-linux-s390x-gnu": "1.2.16", + "@rolldown/binding-linux-x64-gnu": "1.2.16", + "@rolldown/binding-linux-x64-musl": "1.2.16", + "@rolldown/binding-openharmony-arm64": "1.2.16", + "@rolldown/binding-win32-arm64-msvc": "1.2.16", + "@rolldown/binding-win32-x64-msvc": "1.2.16" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.6.tgz", + "integrity": "sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.32", + "rolldown": "~1.2.16", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1e31db7 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "latency-lab", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0 --port 4178", + "build": "vite build", + "preview": "vite preview --host 0.0.0.0 --port 4178", + "test": "node --test test/*.test.js" + }, + "dependencies": { + "@xyflow/react": "12.12.1", + "lucide-react": "1.70.0", + "react": "19.3.1", + "react-dom": "19.3.1" + }, + "devDependencies": { "vite": "8.3.6" } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..b586329 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/public/preview.svg b/public/preview.svg new file mode 100644 index 0000000..abd8773 --- /dev/null +++ b/public/preview.svg @@ -0,0 +1,6 @@ + +K1412 RESEARCH / LATENCY LAB +模型时延实验室文本与实时语音模型对照 · 全双工流程估算 + +INPUT / STREAM持续监听AGENT / LOOP模型 × N步PARALLEL工具与检索FIRST AUDIO / CRITICAL PATH汇总、生成与播放58 SOURCE-ATTRIBUTED RECORDS / NOV 2026 + diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..1cc0e1e --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,140 @@ +import React, { useState,useEffect,useMemo,useRef,memo } from 'react'; +import { ReactFlow,Background,Controls,Handle,Position,applyNodeChanges,applyEdgeChanges,MarkerType } from '@xyflow/react'; +import { ArrowUpRight,ArrowRight,Activity,AudioLines,GitBranch,Clock3,Layers3,Plus,ChevronDown,ChevronRight,Download,Upload,RotateCcw,LayoutGrid,Trash2,Copy,Check,Search,BookOpen,SlidersHorizontal,Play,Pause,X,Info,Link2,Braces,Mic,Volume2,BrainCircuit,Workflow,ExternalLink,Menu } from 'lucide-react'; +import { RECORDS,SOURCES,CUTOFF,RELEASE,EVIDENCE,METRICS,recordValue,HARDWARE,SIZE_OPTIONS,CALIBRATION } from './catalog.js'; +import { DEFAULT_GLOBALS,KINDS,MODEL_KINDS,simulateGraph,estimateNode,validateGraph,validateDocument,autoLayout,getRecord,ms } from './engine.js'; +import { PRESETS,makeDocument,nodeData,toDocument } from './presets.js'; + +const STORE='latency-lab-v1'; +const icons={input:Mic,asr:AudioLines,llm:BrainCircuit,speech:AudioLines,tts:Volume2,program:Braces,merge:GitBranch,output:Volume2}; +function loadSaved(){try{const d=JSON.parse(localStorage.getItem(STORE)||'null');if(d&&!validateDocument(d).length)return d;}catch{}return makeDocument();} +function download(name,value,type='application/json'){const blob=new Blob([value],{type}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);} +const options = entries => entries.map(([value,label])=>); +function Field({label,children,hint}) {return ;} +function Num({value,onChange,min=0,max=1e6,step=1,label}){return onChange(Math.min(max,Math.max(min,Number(e.target.value)||0)))}/>;} +function Toggle({checked,onChange,children}){return ;} +function Tag({type='neutral',children}){return {children};} +function SourceLink({id,children}){const s=SOURCES[id];return s?{children||s.publisher}:null;} + +const StageNode=memo(function StageNode({data,selected}){ + const Icon=icons[data.kind]||Braces,r=data.result; + return
+ {data.kind!=='input'&&} +
{KINDS[data.kind]}{data.maxSteps>1&&↻ {data.maxSteps}}
+ {data.label} +
{MODEL_KINDS.includes(data.kind)?data.mode==='reference'?(getRecord(data.profileId)?.name||'来源参考'):`${data.arch==='moe'?`${data.size}B / A${data.active}B`:`${data.size}B`} · ${HARDWARE.find(h=>h.id===data.hardware)?.name||'自定义'}`:data.kind==='output'?'声音 / 最终结果的测量终点':data.kind==='input'?'t = 0 · 用户停说':`${data.durationMs} ms · 可修改`}
+
首出 {r?ms(r.first-r.start):'—'}完成 {r?ms(r.end-r.start):'—'}
+ {data.kind!=='output'&&} +
; +}); +const nodeTypes={stage:StageNode}; + +function Hero({onStart,onCompare}){ + return
+
INFERENCE / VOICE / AGENTS 研究快照 2026.11

从模型的毫秒,
到对话的节奏。

看清不同尺寸模型的输出速度,
再把节点连起来,计算一场对话真正的等待。

公开来源逐条标注 理论与报告分开 估算参数可校准
+
一场对话的时间切片ILLUSTRATION / ms
{Array.from({length:52},(_,i)=>)}
用户停说模型持续处理首段声音
输入流持续监听
推理流并行工作
输出流流式合成
↗
开始得早,结束也要算清。

首token ≠ 首音频 ≠ 完整任务

+
{RECORDS.length} 条

带来源的性能记录

0.5–2,400 B

覆盖主干 / 总参数规模

170 ms

7B语音模型公开接续报告 来源

≤ 100 步

每个模型节点的loop预算

+
; +} + +function Comparison({addProfile}){ + const [modality,setModality]=useState('all'),[evidence,setEvidence]=useState('all'),[metric,setMetric]=useState('all'),[query,setQuery]=useState(''),[expanded,setExpanded]=useState(null),[view,setView]=useState('records'),[sort,setSort]=useState('default'); + const [hw,setHw]=useState('h200'),[cards,setCards]=useState(1),[prompt,setPrompt]=useState(1024),[tokens,setTokens]=useState(128),[spec,setSpec]=useState(1); + const filtered=useMemo(()=>{const rows=RECORDS.filter(r=>(modality==='all'||r.modality===modality)&&(evidence==='all'||r.evidence===evidence)&&(metric==='all'||r.metric===metric)&&`${r.name} ${r.hardware} ${r.params}`.toLowerCase().includes(query.toLowerCase()));if(sort==='size')rows.sort((a,b)=>(a.size??Infinity)-(b.size??Infinity));return rows;},[modality,evidence,metric,query,sort]); + const exportCsv=()=>{const rows=[['模型','模态','参数口径','指标','数值','单位','硬件','量化','输入tokens','并发','证据','时间','来源','限制'],...filtered.map(r=>[r.name,r.modality,r.params,METRICS[r.metric],r.value??'',r.metric==='tps'?'tok/s':r.metric==='rtf'?'RTF':'ms',r.hardware,r.precision||'',r.input??'',r.concurrency??'',EVIDENCE[r.evidence],r.date,SOURCES[r.source].url,r.note||SOURCES[r.source].note])];download('model-latency-20261106.csv','\uFEFF'+rows.map(r=>r.map(v=>'"'+String(v).replaceAll('"','""')+'"').join(',')).join('\n'),'text/csv;charset=utf-8');}; + return
01 / THE EVIDENCE

模型时延对照

先比较同口径,再讨论极限。尺寸相同,部署条件仍可能相差很大。

+
+ {view==='records'?<>
{[['all','全部'],['text','纯文本'],['speech','端到端语音'],['tts','TTS组件']].map(([v,l])=>)}
+
{filtered.length} 条记录 · 截止 {CUTOFF}点击一行展开条件与限制
{filtered.map(r=>setExpanded(expanded===r.id?null:r.id)} tabIndex={0} onKeyDown={e=>{if(e.key==='Enter')setExpanded(expanded===r.id?null:r.id);}}>{expanded===r.id&&})}
模型 / 规模指标与结果部署条件证据与来源
{r.name}{r.params}{r.active?` · 激活${r.active}B`:''} {r.modality==='text'?'文本':r.modality==='tts'?'TTS':'语音'}{recordValue(r)}{r.scope}{r.metric==='tps'&&≈ {(1000/r.value).toFixed(2)} ms/token}{r.hardware}{r.precision||'精度未披露'}{r.concurrency?` · 并发${r.concurrency}`:''}{EVIDENCE[r.evidence]}{r.date}
这条数字说明什么

{r.note||SOURCES[r.source].note}

输入:{r.input?`${r.input.toLocaleString()} token`:r.inputSeconds?`${r.inputSeconds}秒音频/视频`:'未充分披露'} 输出:{r.output?`${r.output} token`:'未充分披露'} {r.ttft?`另报告首token ${r.ttft}ms。`:''}{r.tps?` 另报告文本速度${r.tps} tok/s。`:''}{r.rtf?` RTF ${r.rtf}。`:''}

{SOURCES[r.source].title}
{r.value!=null&&r.metric!=='interrupt'&&}
{!filtered.length&&
没有匹配记录,试试其他筛选条件。
}
+

“最佳”必须带条件。历史晶圆级结果、单卡GPU、受控接受率、生产API观测不构成统一排行榜。公开报告也不等于本网站实测。

AMoE看两种尺寸。总参数影响存储,激活参数影响每步计算;不能把30B-A3B当作30B稠密模型。

:<>

FP8、模型已热身、低负载、不计网络与排队。以Qwen3/H20为速率锚点,硬件倍率和首token都是可校准的工程假设;不是硬件实测或物理下界。

{[.5,1.5,3,7,8,14,27,32,70,120,235,405].map(size=>{const r=estimateNode({...nodeData('llm','尺寸扫描'),hardware:hw,cards,size,inputTokens:prompt,outputTokens:tokens,speculative:spec});return ;})}
稠密模型规模估计首token估计输出速度完整输出估计可部署性 / 外推
{size}B稠密 · FP8{ms(r.first)}{r.rate.toFixed(1)} tok/s{(1000/r.rate).toFixed(1)} ms/token{ms(r.end)}{r.warnings.find(w=>w.includes('显存'))?显存不足:估算}{r.warnings.find(w=>w.includes('显存'))||'参数变化仅代表此工程模型下的趋势。'}
} +
; +} + +function NumberField({data,patch,keyName,label,hint,min=0,max=1e6,step=1}){return patch({[keyName]:v})} min={min} max={max} step={step}/>;} + +function NodeInspector({node,result,patch,remove,duplicate}){ + if(!node)return

选择一个节点

点击流程中的节点,编辑模型、参数和loop步数。也可以先从左侧添加节点。

; + const d=node.data,isModel=MODEL_KINDS.includes(d.kind),isText=d.kind==='llm'; + const profiles=d.kind==='asr'?[]:RECORDS.filter(r=>r.value!=null&&r.metric!=='interrupt'&&(isText?r.modality==='text':d.kind==='tts'?r.modality==='tts':r.modality==='speech')); + const chooseProfile=id=>{const r=getRecord(id);patch({profileId:id,mode:'reference',...(r?.size?{size:r.size}:{}),...(r?.active?{active:r.active,arch:'moe'}:{arch:'dense'})});}; + return <>
NODE CONFIG

节点设置

patch({label:e.target.value})}/>
{KINDS[d.kind]}{result&&{result.label}}
+ {isModel&&<> + {d.mode==='reference'?<>{getRecord(d.profileId)&&
{SOURCES[getRecord(d.profileId).source].title}

{getRecord(d.profileId).note||SOURCES[getRecord(d.profileId).source].note}

}:<>
patch({size:Math.max(.1,Number(e.target.value)||.1)})}/>
{d.arch==='moe'&&}
} + {d.mode==='custom'&&

手动校准

{isText?:}
} + {isText&&<>

一次推理的工作量

{d.mode==='estimate'&&}
} + {d.kind==='asr'&&<>patch({streamingInput:v})}>用户说话时持续识别{d.mode!=='custom'&&}} +

Agent / 模型 loop

逐步累计
{d.maxSteps>1&&<>{isText&&}patch({finalOnly:v})}>仅最后一步向下游输出

关闭后首包可能是中间反馈。loop指节点内部重复调用,不代表真实对话往返轮数。

}
patch({resource:e.target.value})}/>} + {(d.kind==='program'||d.kind==='merge'||d.kind==='output')&&} + {d.kind==='output'&&<>patch({target:v})}>作为主结果测量终点} + {d.kind==='input'&&

该节点代表用户停止说话。判停等待在“全局参数”中调整。流式识别可以提前处理用户说话期间的输入。

} + + {result&&

本节点的时间

开始
{ms(result.start)}
首出
{ms(result.first)}
结束
{ms(result.end)}
{result.resourceWait>0&&
资源等待
{ms(result.resourceWait)}
}
{result.assumptions.map((s,i)=>

{s}

)}{result.warnings.map((s,i)=>

{s}

)}
} + ; +} + +function Timeline({sim,nodes,selected,setSelected}){ + const [playing,setPlaying]=useState(false),[cursor,setCursor]=useState(0),[stepsOpen,setStepsOpen]=useState(false),timer=useRef(); + const total=sim.complete||1; + useEffect(()=>{if(!playing)return;let start=performance.now(),from=cursor;const tick=now=>{const next=from+(now-start)*(total/6000);if(next>=total){setCursor(total);setPlaying(false);}else{setCursor(next);timer.current=requestAnimationFrame(tick);}};timer.current=requestAnimationFrame(tick);return()=>cancelAnimationFrame(timer.current);},[playing,total]); + useEffect(()=>{setCursor(0);setPlaying(false);},[total]); + const result=selected?sim.results?.[selected]:null; + return

流程时间线 用户停说 = 0

绿色标出首结果关键路径。浅色区域是准备/等待,实色区域是输出。

0{ms(total*.25)}{ms(total*.5)}{ms(total*.75)}{ms(total)}
{sim.order.map(id=>{const r=sim.results[id],width=Math.max(.7,(r.end-r.start)/total*100),left=r.start/total*100,first=(r.first-r.start)/Math.max(1,r.end-r.start)*100;return ;})}
{result?.steps.length>1&&
{stepsOpen&&
{result.steps.map(s=>)}
步输入 / 输出 / 思考开始首出结束步前工具等待
{s.step}{s.input} / {s.output} / {s.reasoning}{ms(s.start)}{ms(s.first)}{ms(s.end)}{ms(s.toolWait)}
}
}
; +} + +function Studio({doc,setDoc,selected,setSelected,toast}){ + const nodes=doc.nodes,edges=doc.edges,g=doc.globals; + const [flow,setFlow]=useState(null),[panel,setPanel]=useState('node'),[edgeId,setEdgeId]=useState(null),[from,setFrom]=useState(''),[to,setTo]=useState(''),[release,setRelease]=useState('complete'),[focus,setFocus]=useState(false),[focusSettings,setFocusSettings]=useState(false); + const inputRef=useRef(),canvasRef=useRef(),history=useRef([]); + const sim=useMemo(()=>simulateGraph(nodes,edges,g),[nodes,edges,g]); + useEffect(()=>{if(!focus)return;const old=document.body.style.overflow;document.body.style.overflow='hidden';const escape=e=>{if(e.key==='Escape')setFocus(false);};window.addEventListener('keydown',escape);return()=>{document.body.style.overflow=old;window.removeEventListener('keydown',escape);};},[focus]); + const saveHistory=()=>{history.current.push(structuredClone(doc));if(history.current.length>35)history.current.shift();}; + const update=next=>{saveHistory();setDoc(typeof next==='function'?next(doc):next);}; + const patchNode=patch=>update({...doc,nodes:nodes.map(n=>n.id===selected?{...n,data:{...n.data,...patch}}:n)}); + const patchGlobals=patch=>update({...doc,globals:{...g,...patch}}); + const current=nodes.find(n=>n.id===selected),edge=edges.find(e=>e.id===edgeId); + const fit=()=>setTimeout(()=>flow?.fitView({padding:.18,duration:300}),80); + const choosePreset=key=>{update(makeDocument(key));setSelected(null);setEdgeId(null);fit();}; + const addNode=kind=>{if(nodes.length>=100){toast('最多支持100个节点。');return;}const id=`n-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,5)}`;const rect=canvasRef.current?.getBoundingClientRect();const pos=flow&&rect?flow.screenToFlowPosition({x:rect.x+rect.width/2,y:rect.y+rect.height/2}):{x:100,y:100};update({...doc,nodes:[...nodes,{id,type:'stage',position:pos,data:nodeData(kind,KINDS[kind])}]});setSelected(id);setPanel('node');}; + const connect=c=>{if(!c.source||!c.target){toast('请选择起点和终点。');return;}if(edges.some(e=>e.source===c.source&&e.target===c.target)){toast('这两个节点已经相连。');return;}const next={id:`edge-${Date.now()}-${Math.random().toString(36).slice(2,5)}`,type:'smoothstep',source:c.source,target:c.target,data:{release:c.release||'complete',tokens:8}};const err=validateGraph(nodes,[...edges,next]);if(err.length){toast(err[0]);return;}update({...doc,edges:[...edges,next]});setEdgeId(next.id);setPanel('edge');}; + const removeNode=()=>{update({...doc,nodes:nodes.filter(n=>n.id!==selected),edges:edges.filter(e=>e.source!==selected&&e.target!==selected)});setSelected(null);}; + const duplicate=()=>{if(!current)return;const id=`n-${Date.now().toString(36)}`;update({...doc,nodes:[...nodes,{...structuredClone(current),id,position:{x:current.position.x+35,y:current.position.y+165},data:{...current.data,label:current.data.label+' 副本'}}]});setSelected(id);}; + const renderNodes=useMemo(()=>nodes.map(n=>({...n,selected:n.id===selected,data:{...n.data,result:sim.results?.[n.id],critical:sim.criticalNodes?.has(n.id)}})),[nodes,selected,sim]); + const renderEdges=useMemo(()=>edges.map(e=>({...e,selected:e.id===edgeId,animated:e.data?.release!=='complete'&&!e.data?.control,label:e.data?.control?'控制路径':e.data?.release==='chunk'?`${e.data.tokens||8} token后`:e.data?.release==='first'?'首包后':'完成后',markerEnd:{type:MarkerType.ArrowClosed,width:16,height:16,color:sim.criticalEdges?.has(e.id)?'#2d7854':'#91a39a'},style:{stroke:sim.criticalEdges?.has(e.id)?'#2d7854':'#91a39a',strokeWidth:sim.criticalEdges?.has(e.id)?2.2:1.5,...(e.data?.control?{strokeDasharray:'5 5'}:{})},labelStyle:{fill:'#50645b',fontSize:10},labelBgStyle:{fill:'#f7f8f4'},labelBgPadding:[5,3]})),[edges,edgeId,sim]); + const exportDoc=()=>download('dialogue-flow.json',JSON.stringify(toDocument(nodes,edges,g,doc.name),null,2)); + const importDoc=async e=>{const file=e.target.files?.[0];if(!file)return;try{if(file.size>2e6)throw new Error('文件超过2MB。');const parsed=JSON.parse(await file.text());const err=validateDocument(parsed);if(err.length)throw new Error(err[0]);update(parsed);setSelected(null);fit();toast('已导入流程。');}catch(error){toast(`导入失败:${error.message}`);}e.target.value='';}; + const onChanges=changes=>setDoc(prev=>({...prev,nodes:applyNodeChanges(changes,prev.nodes)})); + return
02 / BUILD THE CONVERSATION

把一场对话,搭出来。

拖拽、连线、并行、汇总。改变一个节点,看等待如何沿流程传递。

自动保存在本机
从一个结构开始{Object.entries(PRESETS).filter(([k])=>k!=='blank').map(([key,p])=>)}
+
+
{doc.name||'自定义流程'}{!sim.errors.length&&首出 {ms(sim.first)} 完成 {ms(sim.complete)}}
{focus&&}
+ setDoc(prev=>({...prev,edges:applyEdgeChanges(changes,prev.edges)}))} onConnect={connect} onInit={setFlow} onNodeClick={(_,n)=>{setSelected(n.id);setPanel('node');if(focus)setFocusSettings(true);}} onEdgeClick={(_,e)=>{setEdgeId(e.id);setPanel('edge');if(focus)setFocusSettings(true);}} onNodeDragStart={saveHistory} onPaneClick={()=>{setSelected(null);setEdgeId(null);}} onNodesDelete={ns=>{setDoc(prev=>({...prev,edges:prev.edges.filter(e=>!ns.some(n=>n.id===e.source||n.id===e.target))}));}} isValidConnection={c=>!validateGraph(nodes,[...edges,{id:'candidate',...c}]).length} fitView fitViewOptions={{padding:.16}} minZoom={.16} maxZoom={1.6} deleteKeyCode={['Backspace','Delete']} connectOnClick selectionOnDrag={false} ariaLabelConfig={{'controls.zoomIn.ariaLabel':'放大流程','controls.zoomOut.ariaLabel':'缩小流程','controls.fitView.ariaLabel':'显示完整流程','controls.interactive.ariaLabel':'切换交互'}}> + {!nodes.length&&

从第一个节点开始

点击左侧添加节点,或者加载一个示例结构。

} +
首结果关键路径 流式连接
+ +
+ {sim.errors.length?
{sim.errors.join(' ')}
:<>
{sim.hasAudio?'首段可播放音频':'首个目标结果'}方案估计{ms(sim.first)}敏感性范围 {ms(sim.range[0])} – {ms(sim.range[1])}
全部主目标完成{ms(sim.complete)}音频目标包含 {g.speechSeconds}s 自然播放
跑满loop预算{ms(sim.maxFirst)}首结果 / 完成 {ms(sim.maxComplete)}
持续处理负载1?'warn-text':''}>{sim.rtf.toFixed(2)} RTF{sim.rtf>1?'当前假设可能跟不上实时':'理想资源下具备实时余量'}

范围只把模型和程序阶段乘0.75–1.5,固定网络/判停不变;不是p95或统计置信区间。改变模型、隐藏思考、共享资源或loop后会立即重算。打断控制能力仍需真实音频系统验证。

{sim.warnings.length>0&&
估算条件与待校准项 {sim.warnings.length}
    {sim.warnings.map((w,i)=>
  • {w}
  • )}
}{setSelected(id);setPanel('node');}}/>} + {SIZE_OPTIONS.map(s=> +
; +} + +function Method(){ + return
03 / THE MODEL BEHIND THE MODEL

这些时间,怎么算。

结果的可信度,取决于计时边界和输入假设是否说清。

先定义你在等什么。

文本首token是模型开始生成的时间。隐藏思考可能发生在可见答案之前。语音首包是第一段可播放音频就绪的时间;终端播放还需要网络与缓冲。完整回复则要考虑所有输出和自然说话时长。

TTFT

首次模型token;对推理模型应另计首个可见答案。

TPOT

持续输出阶段每token平均耗时,近似1000÷token/s。

TTFA

首段可播放音频的时间,不能用文本TTFT代替。

RTF

处理/生成音频所需时间÷音频时长。小于1才有持续实时余量。

01

串行与并行

start(v) = max(各前驱满足条件的时刻)

默认等全部分支。选择“任一就绪”取min;前提是最早分支的结果可用。独立硬件分支可以重叠,共享资源组会增加排队。

02

流式开始,持续供给

首包 + (k − 1) / 输出速率

文本积累k个token后启动下游。完成时刻还受上游最终数据就绪约束,避免把提前启动误当作全部工作已完成。这里采用平均供给率,未模拟音素对齐、每帧抖动与流控背压。

03

Agent loop

Σ[首token + (思考 + 输出 − 1) / 速率] + (N − 1) × 工具等待

每步可增加上下文;默认仅最后一步对下游放行。最大步数定义预算情景,不保证真实任务能在预算内成功。选择预计步数后,同时显示跑满预算的结果。

04

参数规模外推

速率 = H20锚点 × 硬件系数 × 量化系数 × 并行收益

稠密锚点采用Qwen3官方FP8数据做对数插值;32B以上按反比外推。MoE用激活参数并乘额外开销。卡数收益上限4倍,不能靠无限加卡把时延降到零。

查看全部默认系数与计算限制

可复查的默认值

{HARDWARE.map(h=>)}
硬件相对H20速率系数
{h.name}{h.factor}×(假设)

量化速率系数:BF16 0.62,FP8 1,INT4 1.06。它们是近似值,不代表所有模型的固定规律。

首token与显存

Prefill默认8B/H20为18,000 token/s,固定开销14ms;随激活规模、缓存和硬件调整。缺少首token来源时使用该模型。公开记录中的硬件条件固定;更换条件应切回工程估算。

权重显存粗算:BF16 2字节/参数,FP8 1.05,INT4 0.55;另加max(2GB,8%)运行余量。这不是完整KV-cache容量规划。

未建模的因素

任务正确性、语音自然度、错误重试、冷启动、长上下文attention非线性、真实调度、网络抖动、设备音频延迟与多用户拥塞。模型间tokenizer也不同,不把token直接等同汉字。

怎样理解“理想时延”

统一条件估算采用已热身、低并发、稳定网络。端到端语音通过边听边处理缩短停说后的等待,不能因此声称复杂任务已完成。一个170ms的接续可以很自然,却不能代表检索、工具和多轮Agent也在170ms内结束。网站中的数据为公开资料调研,本次没有运行模型效果或硬件性能实验。

覆盖公开可核验的代表性尺寸与部署条件;不穷尽全部模型。未公开的参数规模保持未知,缺少首音频数据时也保留空缺。

; +} + +function Sources(){return
04 / PRIMARY SOURCES

让每个数字,都有来处。

截止 {CUTOFF} 查阅。只引用官方文档、官方模型卡或原始研究论文。

{Object.keys(SOURCES).length} 个一手来源
{Object.entries(SOURCES).map(([id,s],i)=>
{String(i+1).padStart(2,'0')}
{s.title}{s.publisher} / {s.date}

{s.note}

)}
;} + +export default function App(){ + const [tab,setTab]=useState(()=>location.hash==='#studio'?'studio':location.hash==='#method'?'method':location.hash==='#sources'?'sources':'overview'),[doc,setDoc]=useState(loadSaved),[selected,setSelected]=useState(null),[message,setMessage]=useState(''),[mobileMenu,setMobileMenu]=useState(false); + const toastTimer=useRef(); + const toast=text=>{setMessage(text);clearTimeout(toastTimer.current);toastTimer.current=setTimeout(()=>setMessage(''),4500);}; + useEffect(()=>{const timer=setTimeout(()=>{try{localStorage.setItem(STORE,JSON.stringify(toDocument(doc.nodes,doc.edges,doc.globals,doc.name)));}catch{toast('本机存储不可用,请导出JSON保存流程。');}},500);return()=>clearTimeout(timer);},[doc]); + const navigate=t=>{setTab(t);setMobileMenu(false);history.replaceState(null,'',t==='overview'?'#overview':`#${t}`);window.scrollTo({top:0,behavior:'instant'});}; + const addProfile=r=>{const id=`n-${Date.now().toString(36)}`,kind=r.modality==='text'?'llm':r.modality==='tts'?'tts':'speech',data={...nodeData(kind,r.name),mode:'reference',profileId:r.id,...(r.size?{size:r.size}:{}),...(r.active?{active:r.active,arch:'moe'}:{})};setDoc(prev=>({...prev,name:'自定义对话流程',nodes:[...prev.nodes,{id,type:'stage',position:{x:120,y:120+prev.nodes.length*30},data}]}));setSelected(id);navigate('studio');toast('已添加参考节点;首包或持续速度缺失时会明确使用工程假设。');}; + return <>
{e.preventDefault();navigate('overview');}}>模型时延实验室LATENCY LAB
K1412 RESEARCH
+
{tab==='overview'&&<>navigate('studio')} onCompare={()=>document.getElementById('compare').scrollIntoView({behavior:'smooth'})}/>
FROM A NUMBER TO A SYSTEM

一个模型快,
整条链路也快吗?

把多个模型、工具和语音模块连起来,找出真正决定体验的那条路径。

}{tab==='studio'&&} {tab==='method'&&}{tab==='sources'&&}
+
LATENCY LAB

公开证据 × 可校准的系统估算

资料截止 {CUTOFF}源代码与数据
{RELEASE} · 本机计算与存储,无需模型API密钥
{message&&
{message}
} + ; +} diff --git a/src/catalog.js b/src/catalog.js new file mode 100644 index 0000000..7f503be --- /dev/null +++ b/src/catalog.js @@ -0,0 +1,88 @@ +export const CUTOFF = '2026-11-06'; +export const RELEASE = 'K1412-LATENCY-LAB-20261106-v1'; +export const SOURCES = { + qwen3: { title: 'Qwen3 官方速度基准', publisher: 'Qwen', date: '2025 · 在线文档', url: 'https://github.com/QwenLM/Qwen3/blob/main/docs/source/getting_started/speed_benchmark.md', note: 'H20 96GB、SGLang 0.4.6、batch=1。原始 speed=(输入+输出)/总时间;本表只取输入1、输出2048的行,近似生成速度。不是跨硬件最佳成绩。' }, + glm: { title: 'GLM-4 官方推理数据', publisher: 'Z.ai', date: '2025 · 在线文档', url: 'https://github.com/zai-org/GLM-4/blob/main/inference/README.md', note: 'H100 80GB,具体卡数和输入长度逐行列出。短输入与长输入的 TTFT 不可混用。' }, + groq8: { title: 'Groq · Llama 3.1 8B', publisher: 'Groq', date: '在线文档 · 查询于截止日', url: 'https://console.groq.com/docs/model/llama-3.1-8b-instant', note: '服务商标称 token speed,未提供本地可复现的完整负载条件;不是 SLA。' }, + groq70: { title: 'Groq · Llama 3.3 70B', publisher: 'Groq', date: '在线文档 · 查询于截止日', url: 'https://console.groq.com/docs/model/llama-3.3-70b-versatile', note: 'LPU 专用推理平台,不能套用到单张 GPU。' }, + swift: { title: 'SwiftSpec 论文', publisher: 'ByteDance Seed 等', date: '2025-06-12', url: 'https://arxiv.org/html/2506.11309v1', note: '单请求低时延优化,8张 Hopper GPU、异步推测解码。速度随任务和接受率变化。' }, + cb70: { title: 'Cerebras · 70B 推理结果', publisher: 'Cerebras', date: '2024-10-24 · 历史结果', url: 'https://www.cerebras.ai/blog/cerebras-inference-3x-faster', note: '晶圆级硬件、16位权重与推测解码。厂商报告每用户2100 token/s,文中提示速度可上下波动约20%。不是当前 API 可用性承诺。' }, + cb405: { title: 'Cerebras · 405B 推理结果', publisher: 'Cerebras', date: '2024-11-18 · 历史结果', url: 'https://www.cerebras.ai/blog/llama-405b-inference', note: '1000-token 输入时报告969输出token/s、240ms TTFT;支持128K不等于该TTFT来自128K输入。' }, + qwen38: { title: 'TensorRT LLM · Qwen3.8 MoE 部署基准', publisher: 'NVIDIA', date: '1.4.0rc0 · 截止日可见版本', url: 'https://nvidia.github.io/TensorRT-LLM/1.4.0rc0/deployment-guide/deployment-guide-for-qwen3.8-qwen3.5-on-trtllm.html', note: 'GB300、TP16/EP1、并发1、8192输入/1024输出。MTP3的383.051 token/s使用受控接受数2.3,属于条件化性能点。' }, + r1: { title: 'TensorRT LLM · DeepSeek-R1 低时延优化', publisher: 'NVIDIA', date: '2025 · 技术文档', url: 'https://nvidia.github.io/TensorRT-LLM/blogs/tech_blog/blog1_Pushing_Latency_Boundaries_Optimizing_DeepSeek-R1_Performance_on_NVIDIA_B200_GPUs.html', note: '8×B200,1K输入/2K输出。峰值配置含NVFP4、MTP与放宽接受验证;不能视为输出分布不变的免费加速。' }, + omni2: { title: 'LLaMA-Omni 2 · 表6', publisher: 'ICT/CAS', date: '2025-05-05', url: 'https://arxiv.org/html/2505.02625v1#A2', note: '单张L40、从收到语音指令到第一段合成语音。0.5B至14B指语言主干;未计公网和终端播放。R=3/W=10为主实验;R=1/W=5更快,但音质有变化。' }, + moshi: { title: 'Moshi 技术论文', publisher: 'Kyutai', date: '2024 · 论文v2', url: 'https://arxiv.org/html/2410.00037v2', note: '160ms为架构理论时延,论文称实际200ms。连续流延迟与一问一答的完整任务耗时不同。' }, + persona: { title: 'PersonaPlex 官方模型卡', publisher: 'NVIDIA', date: '2026-01 · v1', url: 'https://huggingface.co/nvidia/personaplex-7b-v1', note: 'A100 80GB、PyTorch、FullDuplexBench。接续170ms;打断停止240ms是不同指标。主要为英语。' }, + voicechat: { title: 'Nemotron 3 VoiceChat 官方模型卡', publisher: 'NVIDIA', date: '2026-03-16 · v1', url: 'https://build.nvidia.com/nvidia/nemotron-voicechat/modelcard', note: '12B总参数、9B主干、H100、vLLM,报告260ms接续。早期评估版本;不与后续11B NemotronLabs检查点混用。' }, + minicpm: { title: 'MiniCPM-o 4.5 · 表11/12', publisher: 'OpenBMB', date: '2026-04-30', url: 'https://arxiv.org/html/2604.27393v1', note: '9B总参数。RTX4090/llama.cpp-omni/INT4的RTF=0.21;0.58s的首token来自64帧视觉输入,不是语音首包。' }, + qomni3: { title: 'Qwen3-Omni · 表1/2', publisher: 'Qwen', date: '2025-09-22', url: 'https://arxiv.org/html/2509.17765v1#S2.T2', note: 'Thinker 30B-A3B,另有3B-A0.3B Talker和编解码模块。234ms是无历史上下文的理论音频首包;表2并发1/4/6分别为234/728/1172ms。未给出可复现的具体GPU型号。' }, + qomni35: { title: 'Qwen3.5-Omni · 表1/2', publisher: 'Qwen', date: '2026-04-17', url: 'https://arxiv.org/html/2604.15804v1#S2.T2', note: '理论首包;Flash235ms/Plus435ms。论文只说扩展到数千亿参数,未公开两变体精确尺寸;部署资源不同,不能据此比较单位算力效率。' }, + qomni38: { title: 'Qwen3.8-Omni-Flash · 实际API性能', publisher: 'Qwen', date: '2026-09-18', url: 'https://qwen.ai/blog?id=qwen3.8-omni-flash#throughput--latency', note: '官方生产环境观测,纯音频6/12/20秒输入时首音频约978/983/1026ms。未公布精确参数、GPU、样本数及百分位。网络和端点判停边界未充分说明,不能视作端到端SLA。' }, + lfm3: { title: 'LFM3-Audio-2B 官方模型卡', publisher: 'Liquid AI', date: '2026-10-27', url: 'https://huggingface.co/LiquidAI/LFM3-Audio-2B', note: '流式编码可在用户说话时prefill,官方称首token低至50ms;没有声明50ms是首段可播放音频,也未给出该数值的完整硬件条件。' }, + qtts: { title: 'Qwen3-TTS · 表2', publisher: 'Qwen', date: '2026-01-22', url: 'https://arxiv.org/html/2601.15621v1#S3.T2', note: '12Hz版本0.6B/1.7B在并发1时报告97/101ms首包,RTF=0.288/0.313。内部vLLM V0、编解码compile与CUDA Graph;未公开具体GPU。这是TTS组件,不含语音识别和LLM响应。' }, + live: { title: 'GPT-Live 官方文档', publisher: 'OpenAI', date: '在线文档 · 截止日查阅', url: 'https://developers.openai.com/api/docs/guides/live', note: '原生全双工语音与后台任务委托;没有可用于按参数规模比较的公开精确尺寸和统一时延基准。后台任务完成时延需要另算。' }, + vad: { title: 'Realtime VAD 官方文档', publisher: 'OpenAI', date: '在线文档 · 截止日查阅', url: 'https://developers.openai.com/api/docs/guides/realtime-vad', note: 'silence_duration_ms可调,官方示例为500ms。判停等待是额外时延;更短等待可能增加误抢话。' }, + gemini: { title: 'Gemini Live API 官方文档', publisher: 'Google', date: '在线文档 · 截止日查阅', url: 'https://ai.google.dev/gemini-api/docs/live', note: '原生实时音频API。模型尺寸和统一的用户停说至首音频时延缺少公开可比数据。' }, +}; + +const row = (id, name, modality, size, metric, value, source, extra = {}) => ({ id, name, modality, size, metric, value, source, evidence: 'reported', hardware: '未披露', params: size == null ? '未公开' : `${size}B`, ...extra }); +export const RECORDS = [ + ...[[0.6,458.03],[1.7,333.90],[4,200.61],[8,150.25],[14,97.11],[32,46.17]].map(([s,v])=>row(`q3-${s}`,`Qwen3-${s}B`,'text',s,'tps',v,'qwen3',{hardware:'1×H20 96GB',precision:'FP8',input:1,output:2048,concurrency:1,scope:'纯文本生成近似',date:'2025',note:'极短输入,原指标含1个输入token。未报告首token;不等同长上下文速度。'})), + row('q3-32-q4','Qwen3-32B','text',32,'tps',47.67,'qwen3',{hardware:'1×H20 96GB',precision:'AWQ INT4',input:1,output:2048,concurrency:1,scope:'纯文本生成近似',date:'2025'}), + row('q3-30','Qwen3-30B-A3B','text',30,'tps',155.55,'qwen3',{active:3,hardware:'1×H20 96GB',precision:'FP8',input:1,output:2048,concurrency:1,scope:'MoE生成近似',date:'2025'}), + row('q3-235','Qwen3-235B-A22B','text',235,'tps',74.5,'qwen3',{active:22,hardware:'8×H20 96GB',precision:'BF16',input:1,output:2048,concurrency:1,scope:'MoE生成近似',date:'2025'}), + row('q3-235-fp8','Qwen3-235B-A22B','text',235,'tps',71.65,'qwen3',{active:22,hardware:'4×H20 96GB',precision:'FP8',input:1,output:2048,concurrency:1,scope:'MoE生成近似',date:'2025'}), + ...[[9,1000,50,44.4,1],[9,8000,390,39,1],[9,32000,2290,18.7,1],[9,100000,6800,14.1,1],[32,1000,160,24.4,1],[32,8000,1370,16.9,1],[32,32000,6750,8.1,2],[32,100000,37830,3,4]].map(([s,input,value,tps,cards])=>row(`glm-${s}-${input}`,`GLM-4-${s}B-0414`,'text',s,'ttft',value,'glm',{tps,input,cards,hardware:`${cards}×H100 80GB`,precision:'BF16',scope:'首个文本token',date:'2025-04'})), + row('groq8','Llama 3.1 8B · Groq','text',8,'tps',560,'groq8',{hardware:'Groq LPU 服务',precision:'服务商数值格式',scope:'服务商标称生成速度',date:'查询于2026-11-06',note:'无统一TTFT和完整并发条件。'}), + row('groq70','Llama 3.3 70B · Groq','text',70,'tps',280,'groq70',{hardware:'Groq LPU 服务',scope:'服务商标称生成速度',date:'查询于2026-11-06'}), + row('swift70','Llama 3 70B · SwiftSpec','text',70,'tps',348,'swift',{hardware:'8×Hopper GPU',precision:'论文优化配置',scope:'单请求低时延解码',concurrency:1,date:'2025-06-12',note:'任务相关的推测解码接受率;不是所有任务统一速度。'}), + row('cb70','Llama 3.1 70B · Cerebras','text',70,'tps',2100,'cb70',{hardware:'Cerebras 晶圆级系统',precision:'16位 + 推测解码',scope:'历史每用户生成结果',date:'2024-10-24',historical:true}), + row('cb405','Llama 3.1 405B · Cerebras','text',405,'tps',969,'cb405',{ttft:240,input:1000,hardware:'Cerebras 晶圆级系统',precision:'16位',scope:'历史每用户生成结果',date:'2024-11-18',historical:true}), + row('q38','Qwen3.8-2.4T-A95B','text',2400,'tps',133.502,'qwen38',{active:95,input:8192,output:1024,concurrency:1,hardware:'16×GB300 · TP16/EP1',precision:'FP8 · 无MTP',scope:'每用户解码速度',date:'2026 · 文档1.4.0rc0'}), + row('q38-mtp','Qwen3.8-2.4T-A95B · MTP3','text',2400,'tps',383.051,'qwen38',{active:95,input:8192,output:1024,concurrency:1,hardware:'16×GB300 · TP16/EP1',precision:'FP8 · MTP3',evidence:'conditional',scope:'受控接受数性能点',date:'2026 · 文档1.4.0rc0',note:'接受草稿数固定为2.3;不能当成自然负载的无条件实测速率。'}), + row('r1','DeepSeek-R1 · 峰值探索','text',671,'tps',368,'r1',{active:37,hardware:'8×B200',input:1000,output:2000,concurrency:1,precision:'混合精度 · NVFP4/MTP',evidence:'conditional',scope:'条件化峰值解码',date:'2025',note:'配置包含放宽接受验证,输出分布保持性不能由该速度证明。'}), + ...[[0.5,542.71],[1.5,552.76],[3,567.84],[7,582.91],[14,663.32]].map(([s,v])=>row(`omni2-${s}`,`LLaMA-Omni2-${s}B`,'speech',s,'audio',v,'omni2',{params:`${s}B 主干`,hardware:'1×L40',scope:'收到指令→首段语音',date:'2025-05-05',note:'R=3/W=10,未计公网与播放缓冲;尺寸仅语言主干。'})), + row('omni2-7-fast','LLaMA-Omni2-7B · 小块输出','speech',7,'audio',457.29,'omni2',{params:'7B 主干',hardware:'1×L40',scope:'收到指令→首段语音',date:'2025-05-05',note:'R=1/W=5;与较大块的语音质量不同,不能只按速度排名。'}), + row('llama-omni','LLaMA-Omni','speech',8,'audio',346.73,'omni2',{params:'8B 主干',hardware:'1×L40',scope:'论文复测首段语音',date:'2025-05-05'}), + row('glmvoice','GLM-4-Voice','speech',9,'audio',1562.81,'omni2',{params:'9B 主干',hardware:'1×L40',scope:'论文复测首段语音',date:'2025-05-05',note:'同一论文中的基线复测,不能代表该模型所有优化部署。'}), + row('moshi-theory','Moshi · 理论','speech',7,'audio',160,'moshi',{params:'7B 主干',evidence:'theoretical',scope:'连续流架构延迟',date:'2024',note:'算法时延,非公网用户停说至首音频SLA。'}), + row('moshi','Moshi · 实际','speech',7,'audio',200,'moshi',{params:'7B 主干',scope:'论文称实际流延迟',date:'2024'}), + row('persona','PersonaPlex-7B-v1','speech',7,'audio',170,'persona',{params:'7B 主干',hardware:'A100 80GB',precision:'PyTorch',scope:'用户停说→开始回复',date:'2026-01-15',duplex:true,note:'FullDuplexBench接续指标;不代表回答正确率。'}), + row('persona-interrupt','PersonaPlex · 打断','speech',7,'interrupt',240,'persona',{params:'7B 主干',hardware:'A100 80GB',scope:'用户打断→停止说话',date:'2026-01-15',duplex:true}), + row('minicpm','MiniCPM-o 4.5','speech',9,'rtf',0.21,'minicpm',{params:'9B 总参数',hardware:'RTX4090',precision:'INT4 · llama.cpp-omni',scope:'生成实时因子',date:'2026-04-30',duplex:true,note:'不是210ms首音频;未给出本条件下的可比首音频数。'}), + row('voicechat','Nemotron 3 VoiceChat v1','speech',12,'audio',260,'voicechat',{params:'12B总量 / 9B主干',hardware:'H100',precision:'vLLM',scope:'FullDuplexBench接续',date:'2026-03-16',duplex:true,note:'早期评估版本,不与后续NemotronLabs不同检查点合并。'}), + ...[[1,234,0.47],[4,728,0.56],[6,1172,0.66]].map(([c,v,rtf])=>row(`qomni3-${c}`,'Qwen3-Omni-30B-A3B','speech',30,'audio',v,'qomni3',{active:3,params:'Thinker 30B-A3B + Talker 3B-A0.3B',concurrency:c,rtf,evidence:'theoretical',scope:'理论音频首包',hardware:'未披露具体GPU',precision:'vLLM + compile / CUDA Graph',date:'2025-09-22'})), + row('qomni35-flash','Qwen3.5-Omni-Flash','speech',null,'audio',235,'qomni35',{params:'精确尺寸未公开',concurrency:1,rtf:0.178,evidence:'theoretical',scope:'理论音频首包',date:'2026-04-17'}), + row('qomni35-plus','Qwen3.5-Omni-Plus','speech',null,'audio',435,'qomni35',{params:'数千亿级 / 精确尺寸未公开',concurrency:1,rtf:0.187,evidence:'theoretical',scope:'理论音频首包',date:'2026-04-17'}), + ...[[6,978.36,591.26,84.87],[12,982.74,604.80,83.18],[20,1026.39,617.98,81.06]].map(([sec,v,ttft,tps])=>row(`qomni38-${sec}`,'Qwen3.8-Omni-Flash-Realtime','speech',null,'audio',v,'qomni38',{ttft,tps,rtf:0.1538,params:'未公开',inputSeconds:sec,scope:'生产API首音频',date:'2026-09-18',note:'官方生产观测;未披露p50/p95、硬件与完整计时边界。'})), + row('qomni38-video','Qwen3.8-Omni-Flash-Realtime · 音视频','speech',null,'audio',1214.73,'qomni38',{ttft:837.96,tps:84.89,rtf:0.1524,inputSeconds:6,scope:'6秒音视频首音频',date:'2026-09-18'}), + row('lfm3','LFM3-Audio-2B','speech',2,'ttft',50,'lfm3',{scope:'流式输入后的首token下限',date:'2026-10-27',note:'官方“低至”数值,硬件条件未完整说明;没有可比的首音频值。'}), + ...[[.6,1,97,.288],[1.7,1,101,.313],[.6,3,179,.338],[1.7,3,195,.363],[.6,6,299,.434],[1.7,6,333,.463]].map(([s,c,v,rtf])=>row(`qtts-${s}-${c}`,`Qwen3-TTS-12Hz-${s}B`,'tts',s,'audio',v,'qtts',{rtf,concurrency:c,scope:'TTS组件首音频包',hardware:'未披露具体GPU',precision:'内部vLLM V0 + compile',date:'2026-01-22'})), + row('gpt-live','GPT-Live','speech',null,'unknown',null,'live',{scope:'全双工API · 统一时延未知',date:'查询于2026-11-06',duplex:true,evidence:'unknown'}), + row('gemini-live','Gemini Live','speech',null,'unknown',null,'gemini',{scope:'原生音频API · 统一时延未知',date:'查询于2026-11-06',evidence:'unknown'}), +]; + +export const EVIDENCE = { reported:'公开报告', theoretical:'理论口径', conditional:'条件化结果', unknown:'数据未公开' }; +export const METRICS = { tps:'输出速度', ttft:'首token', audio:'首音频 / 接续', interrupt:'打断停止', rtf:'实时因子', unknown:'未报告' }; +export const recordValue = r => r.value == null ? '—' : r.metric==='tps' ? `${r.value.toLocaleString('en-US',{maximumFractionDigits:2})} tok/s` : r.metric==='rtf' ? `${r.value} RTF` : `${r.value.toLocaleString('en-US',{maximumFractionDigits:1})} ms`; +export const HARDWARE = [ + {id:'h20',name:'H20 96GB',factor:1,bandwidth:4, memory:96}, + {id:'h100',name:'H100 80GB',factor:1.3,bandwidth:3.35,memory:80}, + {id:'h200',name:'H200 141GB',factor:1.6,bandwidth:4.8,memory:141}, + {id:'b200',name:'B200 180GB',factor:2.4,bandwidth:8,memory:180}, + {id:'gb300',name:'GB300 288GB',factor:3.1,bandwidth:8,memory:288}, + {id:'4090',name:'RTX 4090 24GB',factor:0.48,bandwidth:1,memory:24}, + {id:'5090',name:'RTX 5090 32GB',factor:0.78,bandwidth:1.79,memory:32}, + {id:'cpu',name:'CPU / 统一内存(泛化)',factor:0.07,bandwidth:0.12,memory:64}, +]; +export const SIZE_OPTIONS = [0.5,0.6,1.5,1.7,2,3,4,7,8,9,12,14,27,32,70,120,235,405,671,2400]; + +// These are engineering defaults, deliberately separate from the source records above. +export const CALIBRATION = { + denseAnchors: [[0.6,458.03],[1.7,333.90],[4,200.61],[8,150.25],[14,97.11],[32,46.17]], + prefillTokensPerSecondAt8B: 18000, + prefillFloorMs: 14, + quantRateFactors: {bf16:0.62,fp8:1,int4:1.06}, + speculativeDefault: 1, + sensitivity: [0.75,1.5], +}; diff --git a/src/engine.js b/src/engine.js new file mode 100644 index 0000000..cf19d05 --- /dev/null +++ b/src/engine.js @@ -0,0 +1,238 @@ +import { RECORDS, HARDWARE, CALIBRATION } from './catalog.js'; + +export const DEFAULT_GLOBALS = { computeScale:1, programScale:1, networkMs:20, queueMs:0, vadMs:120, playbackBufferMs:60, speechSeconds:4, inputSeconds:3, sharedGPU:false, loopPlan:'max' }; +export const KINDS = { input:'语音输入', asr:'流式识别 / 编码', llm:'文本模型 / Agent', speech:'原生语音模型', tts:'流式语音合成', program:'程序 / 工具', merge:'分支汇总', output:'音频播放 / 目标输出' }; +export const MODEL_KINDS = ['llm','speech','tts','asr']; +const finite = (v,f=0) => Number.isFinite(Number(v)) ? Number(v) : f; +const num = (v,f=0,max=1e7) => Math.max(0,Math.min(max,finite(v,f))); +const recordMap = new Map(RECORDS.map(r=>[r.id,r])); +export const ms = v => !Number.isFinite(v) ? '—' : Math.abs(v)>=1000 ? `${(v/1000).toFixed(v>=10000?1:2)} s` : `${Math.round(v)} ms`; +export const getRecord = id => recordMap.get(id); + +function interpolate(size) { + const a=CALIBRATION.denseAnchors, s=Math.max(.1,size); + if(s<=a[0][0]) return a[0][1]*(a[0][0]/s)**.2; + for(let i=1;ih.id===d.hardware)||HARDWARE[0], cards=Math.max(1,Math.round(num(d.cards,1,256))); + const size=Math.max(.1,num(d.size,8,1e5)), active=d.arch==='moe'?Math.max(.1,Math.min(size,num(d.active,3,1e5))):size; + const quant=d.quant||'fp8', quantFactor=CALIBRATION.quantRateFactors[quant]||1; + const tpGain= Math.min(4,cards**.6)/(1+Math.log2(cards)*.08); + const audioHardware=hw.factor*tpGain*quantFactor; + const speculative=Math.max(1,num(d.speculative,1,8)); + let rate=Math.max(1,interpolate(active)*hw.factor*quantFactor*tpGain*speculative/(d.arch==='moe'?1.25:1)); + let assumptions=[], warnings=[], sourceId=null; + if(ref) { + sourceId=ref.source; + const rr=ref.metric==='tps'?ref.value:ref.tps; + if(rr) rate=rr; + assumptions.push('采用选中来源的部署配置;硬件/量化外推参数不参与该参考速率。'); + if(ref.metric==='tps'&&!ref.ttft) assumptions.push('来源未给首token,首token另用工程模型估算。'); + if(ref.evidence==='theoretical') warnings.push('参考值是理论口径,计算结果仍是方案估计。'); + if(ref.evidence==='conditional') warnings.push('参考速率依赖特定接受率或验证条件。'); + if(ref.historical) warnings.push('参考是历史性能点,不能保证当前API仍提供同样服务。'); + if(kind==='llm'&&ref.input&&num(d.inputTokens,1024)!==ref.input) warnings.push(`来源输入${ref.input} token,当前输入${num(d.inputTokens,1024)} token;首token或生成速度的迁移包含额外假设。`); + if(ref.inputSeconds&&ref.inputSeconds!==num(g.inputSeconds,3)) warnings.push(`来源使用${ref.inputSeconds}秒输入,当前为${num(g.inputSeconds,3)}秒;首包参考未由音频长度重新标定。`); + } else if(mode==='custom') { + rate=Math.max(.1,num(d.customTps,100,1e6)); + assumptions.push('使用手动填写的时延和速度。'); + } else { + assumptions.push('速度由H20/Qwen3锚点按参数规模、硬件系数、量化和卡数外推。'); + if(active>32||cards>1||hw.id!=='h20'||d.arch==='moe') warnings.push('该硬件/规模组合包含外推,请用实测校准。'); + if(speculative>1) warnings.push(`推测解码${speculative}倍为手动假设,接受率不足时不成立。`); + } + rate/=scale; + const maxSteps=Math.max(1,Math.round(num(d.maxSteps,1,100))); + const plannedSteps=forceMax||g.loopPlan==='max'?maxSteps:Math.max(1,Math.min(maxSteps,Math.round(num(d.expectedSteps,Math.min(2,maxSteps),100)))); + const stepsCount=model?plannedSteps:1; + const outputTokens=Math.max(1,Math.round(num(d.outputTokens,128,1e6))); + const hiddenTokens=Math.round(num(d.reasoningTokens,0,1e6)); + const inputTokens=Math.round(num(d.inputTokens,1024,1e6)); + const growth=Math.round(num(d.contextGrowth,256,1e6)); + const cached=Math.min(.99,num(d.cacheRatio,0,1)); + const toolMs=num(d.toolMs,0)*progScale; + const q=num(g.queueMs,0); + let first=0,end=0,rtf=0,tail=0,steps=[],busy=0; + if(kind==='input') { first=end=num(g.vadMs,120); assumptions=['时间原点为用户停止说话;此前音频采集时间不计入等待。']; } + else if(kind==='program'||kind==='merge') { first=end=num(d.durationMs,40)*progScale; assumptions=['程序节点耗时为可编辑的单次预算。']; } + else if(kind==='output') { first=num(d.durationMs,g.playbackBufferMs); end=first+(d.outputType==='text'?0:num(g.speechSeconds,4)*1000); assumptions=['播放完成包含自然语音时长;上游供给过慢会推迟结束。']; } + else if(model) { + const audioMs=num(g.speechSeconds,4)*1000; + const refFirst=ref ? (ref.metric==='audio'||ref.metric==='ttft'?ref.value:ref.ttft) : null; + const refRtf=ref?.metric==='rtf'?ref.value:ref?.rtf; + for(let i=0;i1) prefill*=Math.max(.15,uncached/ref.input); + } + if(mode==='custom') prefill=num(d.customFirstMs,100)*scale; + let sf=0,se=0,srtf=0; + if(kind==='llm') { + sf=prefill+hiddenTokens/rate*1000; + se=sf+(outputTokens-1)/rate*1000; + tail=1000/rate; + } else if(kind==='speech') { + // A text TTFT (e.g. LFM3's 50ms) is deliberately not reused as first audio. + sf=(mode==='custom'?num(d.customFirstMs,300)*scale:ref?.metric==='audio'?ref.value*scale:Math.max(80,(160+active*9)/Math.sqrt(audioHardware))*scale); + if(ref?.metric==='ttft') assumptions.push('来源只给首token;本节点首音频使用工程假设,未把二者等同。'); + if(ref?.metric==='rtf') assumptions.push('来源只给RTF,首音频另按工程假设;0.21 RTF不等于210ms首包。'); + srtf=(mode==='custom'?num(d.customRtf,.45,100):refRtf??(0.24+active*.025)/audioHardware)*scale; + if(ref&&refRtf==null) assumptions.push('来源未报告RTF,持续生成速度采用工程假设。'); + se=sf+audioMs*srtf; + tail=80*srtf; + } else if(kind==='tts') { + sf=mode==='custom'?num(d.customFirstMs,100)*scale:ref?.metric==='audio'?ref.value*scale:(70+Math.sqrt(size)*35)/audioHardware*scale; + srtf=(mode==='custom'?num(d.customRtf,.18,100):refRtf??(.12+size*.035)/audioHardware)*scale; + se=sf+audioMs*srtf; tail=80*srtf; + } else if(kind==='asr') { + const seconds=num(g.inputSeconds,3); + sf=(mode==='custom'?num(d.customFirstMs,100):num(d.durationMs,100)*(size/.6)**.5/audioHardware)*scale; + srtf=(mode==='custom'?num(d.customRtf,.15,100):.15*(size/.6)**.6/audioHardware)*scale; + se=sf+(d.streamingInput===false?seconds*1000*srtf:0); + sf=se; tail=se; + assumptions.push(d.streamingInput===false?'整段识别,计入全段处理时间。':'用户说话时持续处理,仅将尾包处理计入停说后的等待。'); + if(mode!=='custom')assumptions.push('尾包基线按0.6B/H20定义,并随规模、硬件与量化外推。'); + } + sf+=q; se+=q; + steps.push({step:i+1,start,first:start+sf,end:start+se,input:tokens,output:outputTokens,reasoning:hiddenTokens,toolWait:i?toolMs:0}); + if(i===0) first=start+sf; + end=start+se; + busy+=se; + rtf=Math.max(rtf,srtf); + } + if(d.finalOnly!==false&&steps.length) first=steps.at(-1).first; + if(kind==='llm') rtf=busy/Math.max(1000,audioMs); + } + if(!ref&&mode!=='custom'&&model) { + const bytesPerWeight=quant==='bf16'?2:quant==='int4'?.55:1.05; + const weightsGB=size*bytesPerWeight; + if(weightsGB+Math.max(2,weightsGB*.08)>hw.memory*cards) warnings.push(`估计权重与最小运行余量约${Math.round(weightsGB+Math.max(2,weightsGB*.08))}GB,超出所选显存;数值不能视为可部署结果。`); + } + return { first,end,tail,rate,rtf,steps,stepsCount,maxSteps,outputTokens,model,sourceId,assumptions:[...new Set(assumptions)],warnings:[...new Set(warnings)],label:ref?'来源锚点 + 方案估计':mode==='custom'?'手动校准':'工程外推' }; +} + +export function validateGraph(nodes,edges) { + const errors=[]; + if(!Array.isArray(nodes)||!Array.isArray(edges)) return ['流程必须包含节点和连线数组。']; + if(nodes.length>100||edges.length>300) return ['最多支持100个节点和300条连线。']; + const ids=new Set(); + for(const n of nodes) { + if(!n||typeof n.id!=='string'||!n.id||ids.has(n.id)) { errors.push('节点ID缺失或重复。'); continue; } + ids.add(n.id); + if(!n.data||!KINDS[n.data.kind]) errors.push(`节点${n.id}的类型无效。`); + if(!Number.isFinite(n.position?.x)||!Number.isFinite(n.position?.y)) errors.push('节点坐标必须是有限数值。'); + for(const [k,v] of Object.entries(n.data||{})) if(typeof v==='number'&&(!Number.isFinite(v)||v<0||v>1e7)) errors.push(`节点${n.id}的${k}超出范围。`); + } + const indeg=new Map(nodes.map(n=>[n.id,0])), adj=new Map(nodes.map(n=>[n.id,[]])); + for(const e of edges) { + if(!ids.has(e.source)||!ids.has(e.target)) { errors.push('连线指向了不存在的节点。'); continue; } + if(e.source===e.target) errors.push('请用节点内的loop步数表示循环。'); + indeg.set(e.target,indeg.get(e.target)+1); adj.get(e.source).push(e.target); + if(e.data?.networkMs!=null&&(!Number.isFinite(e.data.networkMs)||e.data.networkMs<0)) errors.push('连线网络时延必须为非负数。'); + } + const ready=nodes.filter(n=>indeg.get(n.id)===0).map(n=>n.id), order=[]; + while(ready.length) { const id=ready.shift(); order.push(id); for(const t of adj.get(id)) { indeg.set(t,indeg.get(t)-1); if(!indeg.get(t))ready.push(t); } } + if(order.length!==nodes.length) errors.push('流程存在环。图上使用有向无环结构,Agent循环放在模型节点内。'); + return [...new Set(errors)]; +} + +function schedule(nodes,edges,g,forceMax=false) { + const results={}, indeg=new Map(nodes.map(n=>[n.id,0])), outgoing=new Map(nodes.map(n=>[n.id,[]])); + const byId=new Map(nodes.map(n=>[n.id,n])); + edges.forEach(e=>{indeg.set(e.target,indeg.get(e.target)+1);outgoing.get(e.source).push(e);}); + const ready=nodes.filter(n=>!indeg.get(n.id)).map(n=>n.id), resources=new Map(), order=[]; + while(ready.length) { + const id=ready.shift(), n=byId.get(id), d=n.data, cost=estimateNode(d,g,forceMax), incoming=edges.filter(e=>e.target===id); + const arrivals=incoming.map(e=>{ + const p=results[e.source], mode=e.data?.release||'complete', network=num(e.data?.networkMs,g.networkMs); + let at=p.end,event='end'; + if(mode==='first'||mode==='chunk') { + const tokens=mode==='chunk'?Math.min(p.outputTokens,Math.max(1,num(e.data?.tokens,8))):1; + at=p.first+(p.kind==='llm'?(tokens-1)/p.rate*1000:0); at=Math.min(p.end,at);event='first'; + } + return {at:at+network,id:e.source,event,edge:e.id,network,stream:mode!=='complete'}; + }); + const selected=arrivals.length ? arrivals.reduce((a,b)=>d.join==='any'?(a.at<=b.at?a:b):(a.at>=b.at?a:b)) : null; + let start=selected?.at||0, parent=selected, resourceWait=0; + const resource=cost.model?(d.resource?.trim()||(g.sharedGPU?'shared-gpu':'')):''; + const prev=resource?resources.get(resource):null; + if(prev&&prev.end>start) { resourceWait=prev.end-start;start=prev.end;parent={id:prev.id,event:'end',resource:true}; } + let first=start+cost.first,end=start+cost.end,endParent=parent; + // Streaming means an early start, never that the producer's remaining output disappears. + for(const a of arrivals) if(a.stream&&(d.join!=='any'||a===selected)) { + const floor=results[a.id].end+a.network+cost.tail; + if(floor>end) {end=floor;endParent={id:a.id,event:'end',edge:a.edge};} + } + if(d.kind==='output'&&d.outputType!=='text') end=Math.max(end,first+num(g.speechSeconds,4)*1000); + first=Math.min(first,end); + results[id]={...cost,id,kind:d.kind,title:d.label,start,first,end,parent,endParent,resourceWait,resource,steps:cost.steps.map(s=>({...s,start:s.start+start,first:s.first+start,end:s.end+start}))}; + if(resource)resources.set(resource,{end,id}); order.push(id); + for(const e of outgoing.get(id)) {indeg.set(e.target,indeg.get(e.target)-1);if(!indeg.get(e.target))ready.push(e.target);} + } + const targets=nodes.filter(n=>n.data.kind==='output'&&n.data.target!==false); + const terminals=targets.length?targets:nodes.filter(n=>!outgoing.get(n.id).length&&n.data.target!==false); + const audioTargets=targets.filter(n=>n.data.outputType!=='text'); + const firstTargets=audioTargets.length?audioTargets:terminals; + const firstTarget=firstTargets.reduce((a,n)=>!a||results[n.id].first!a||results[n.id].end>results[a.id].end?n:a,null); + const criticalNodes=new Set(),criticalEdges=new Set(),completionNodes=new Set(); + function trace(id,event,set,seen=new Set(),captureEdges=true) { if(!id||seen.has(`${id}:${event}`))return;seen.add(`${id}:${event}`);set.add(id);const r=results[id],p=event==='end'?r.endParent:r.parent;if(p) {if(p.edge&&captureEdges)criticalEdges.add(p.edge);trace(p.id,p.event,set,seen,captureEdges);} } + if(firstTarget)trace(firstTarget.id,'first',criticalNodes); + if(completeTarget)trace(completeTarget.id,'end',completionNodes,new Set(),false); + const loads=new Map(); + for(const r of Object.values(results)) if(r.model) { const key=r.resource||r.id;loads.set(key,(loads.get(key)||0)+r.rtf); } + const rtf=Math.max(0,...loads.values()); + const warnings=Object.values(results).flatMap(r=>r.warnings.map(w=>`${r.title}:${w}`)); + if(targets.length) { + const connected=new Set(targets.map(n=>n.id)),queue=[...connected]; + while(queue.length){const id=queue.shift();for(const e of edges.filter(x=>x.target===id))if(!connected.has(e.source)){connected.add(e.source);queue.push(e.source);}} + for(const n of nodes)if(!connected.has(n.id)&&n.data.target!==false)warnings.push(`${n.data.label}未连到主输出,当前不计入主结果耗时。`); + } + if(!targets.length)warnings.push('没有主输出节点,当前结果取叶子节点;添加播放/目标输出可明确测量终点。'); + if(nodes.some(n=>n.data.join==='any'&&edges.filter(e=>e.target===n.id).length>1))warnings.push('“任一就绪”假设最早分支结果可用;未建模失败重试与答案正确性。'); + if(rtf>1)warnings.push('持续负载RTF超过1,所选资源/输出预算可能跟不上实时音频。'); + return {results,order,first:firstTarget?results[firstTarget.id].first:0,complete:completeTarget?results[completeTarget.id].end:0,firstTarget:firstTarget?.id,completeTarget:completeTarget?.id,criticalNodes,criticalEdges,completionNodes,rtf,warnings:[...new Set(warnings)],hasAudio:audioTargets.length>0}; +} + +export function simulateGraph(nodes,edges,globals=DEFAULT_GLOBALS) { + const errors=validateGraph(nodes,edges); + if(errors.length)return {errors}; + const g={...DEFAULT_GLOBALS,...globals}; + const base=schedule(nodes,edges,g), max=schedule(nodes,edges,g,true); + const low=schedule(nodes,edges,{...g,computeScale:g.computeScale*.75,programScale:g.programScale*.75}),high=schedule(nodes,edges,{...g,computeScale:g.computeScale*1.5,programScale:g.programScale*1.5}); + return {...base,errors:[],maxFirst:max.first,maxComplete:max.complete,range:[low.first,high.first],completeRange:[low.complete,high.complete]}; +} + +export function validateDocument(doc) { + if(!doc||typeof doc!=='object'||doc.version!==1)return ['不是受支持的流程文件(version=1)。']; + const errors=validateGraph(doc.nodes,doc.edges); + if(!doc.globals||typeof doc.globals!=='object')errors.push('全局参数缺失。'); + for(const [k,v] of Object.entries(doc.globals||{}))if(typeof v==='number'&&(!Number.isFinite(v)||v<0||v>1e7))errors.push(`全局参数${k}无效。`); + return errors; +} + +export function autoLayout(nodes,edges) { + if(validateGraph(nodes,edges).length)return nodes; + const levels={}, pending=new Set(nodes.map(n=>n.id)); + for(let i=0;ie.target===id).map(e=>e.source); + if(parents.every(p=>levels[p]!=null)){levels[id]=parents.length?Math.max(...parents.map(p=>levels[p]))+1:0;pending.delete(id);} + } + const grouped={};nodes.forEach(n=>(grouped[levels[n.id]]??=[]).push(n.id)); + return nodes.map(n=>({...n,position:{x:levels[n.id]*290,y:100+grouped[levels[n.id]].indexOf(n.id)*170}})); +} diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..8ceb647 --- /dev/null +++ b/src/main.jsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App.jsx'; +import '@xyflow/react/dist/style.css'; +import './styles.css'; +createRoot(document.getElementById('root')).render(); diff --git a/src/presets.js b/src/presets.js new file mode 100644 index 0000000..21de4ad --- /dev/null +++ b/src/presets.js @@ -0,0 +1,29 @@ +import { DEFAULT_GLOBALS, autoLayout } from './engine.js'; + +export function nodeData(kind,label) { + return { kind,label,mode:'estimate',size:kind==='tts'?.6:kind==='asr'?.6:8,active:3,arch:'dense',hardware:'h200',cards:1,quant:'fp8',inputTokens:1024,outputTokens:128,reasoningTokens:0,contextGrowth:256,cacheRatio:0,speculative:1,maxSteps:1,expectedSteps:1,toolMs:0,finalOnly:true,streamingInput:true,durationMs:kind==='program'?80:kind==='merge'?20:kind==='asr'?100:kind==='output'?60:0,customTps:150,customFirstMs:100,customRtf:.25,resource:'',join:'all',target:true,outputType:'audio' }; +} +const n=(id,kind,label,more={})=>({id,type:'stage',position:{x:0,y:0},data:{...nodeData(kind,label),...more}}); +const e=(source,target,release='complete',more={})=>({id:`${source}-${target}`,source,target,type:'smoothstep',data:{release,tokens:8,...more}}); +const preset=(name,description,nodes,edges,globals={})=>({name,description,version:1,nodes:autoLayout(nodes,edges),edges,globals:{...DEFAULT_GLOBALS,...globals}}); + +export const PRESETS = { + native:preset('原生全双工','输入与输出在同一模型内持续运行;另设独立的打断控制路径。',[ + n('in','input','用户停止说话'),n('voice','speech','原生语音对话',{mode:'reference',profileId:'persona',size:7,customRtf:.45}),n('play','output','主回复播放'),n('cancel','program','打断检测与取消',{durationMs:80,target:false}) + ],[e('in','voice','first'),e('voice','play','first'),e('in','cancel','first',{networkMs:0,control:true})],{vadMs:0}), + cascade:preset('流式级联','边听边识别;文本积累8个token后启动TTS,输入与输出可重叠。',[ + n('in','input','用户停止说话'),n('asr','asr','流式语音识别'),n('llm','llm','对话模型 · 8B'),n('tts','tts','流式TTS · 0.6B'),n('play','output','主回复播放'),n('cancel','program','并行打断控制',{durationMs:80,target:false}) + ],[e('in','asr','first'),e('asr','llm','complete'),e('llm','tts','chunk'),e('tts','play','first'),e('in','cancel','first',{networkMs:0,control:true})]), + parallel:preset('并行检索与汇总','检索和工具分支并行,汇总模型等到全部分支完成后组织回复。',[ + n('in','input','用户停止说话'),n('asr','asr','流式语音识别'),n('plan','llm','意图与任务规划',{size:3,outputTokens:32}),n('search','program','知识检索',{durationMs:180}),n('tool','program','业务工具',{durationMs:350}),n('merge','llm','汇总模型 · 14B',{size:14,outputTokens:160}),n('tts','tts','流式TTS'),n('play','output','主回复播放') + ],[e('in','asr','first'),e('asr','plan'),e('plan','search'),e('plan','tool'),e('search','merge'),e('tool','merge'),e('merge','tts','chunk'),e('tts','play','first')]), + agent:preset('Agent 多步循环','模型最多运行4步,每步输出96个token,步骤之间预留250ms工具等待。',[ + n('in','input','用户停止说话'),n('asr','asr','流式语音识别'),n('agent','llm','工具Agent · 14B',{size:14,maxSteps:4,expectedSteps:2,outputTokens:96,reasoningTokens:64,contextGrowth:384,toolMs:250,finalOnly:true}),n('tts','tts','流式TTS'),n('play','output','最终结果播放') + ],[e('in','asr','first'),e('asr','agent'),e('agent','tts','chunk'),e('tts','play','first')]), + blank:preset('空白流程','从左侧添加节点,也可用下方的连线表单连接。',[],[]), +}; + +export function makeDocument(key='cascade') { return structuredClone(PRESETS[key]); } +export function toDocument(nodes,edges,globals,name='自定义对话流程') { + return {version:1,name,createdAt:new Date().toISOString(),nodes:nodes.map(({id,type,position,data})=>({id,type,position,data})),edges:edges.map(({id,source,target,type,data})=>({id,source,target,type,data})),globals}; +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..62338a5 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,9 @@ +:root{font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',sans-serif;color:#1d332a;background:#f7f8f4;font-synthesis:none;--ink:#173a2b;--green:#286b4d;--lime:#d4ef9a;--muted:#7a897f;--line:#dce3d9;--paper:#fff;--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,monospace} +*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0}button,input,select{font:inherit}button,a,input,select{touch-action:manipulation}button{cursor:pointer}button:disabled{opacity:.45;cursor:not-allowed}a{color:inherit;text-decoration:none}button{color:inherit;border:0;background:transparent}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible,tr:focus-visible{outline:3px solid #97bf5a;outline-offset:3px}button{transition:background .16s,color .16s,box-shadow .16s}button:hover{background:#e8eee3}input,select{min-width:0}svg{flex-shrink:0}p{line-height:1.8}h1,h2,h3,h4,p{margin-top:0}h2{font-size:34px;letter-spacing:-1.4px;font-weight:600;margin-bottom:12px}h3{font-size:20px}h4{font-size:13px}strong{font-weight:600}.wrap{max-width:1440px;margin:0 auto;padding-left:48px;padding-right:48px}.muted{color:#7c887f}.micro-copy{font-size:11px;line-height:1.8;color:#7c887f}.eyebrow{font:500 10px/1.5 var(--mono);letter-spacing:1.6px;color:#738879}.site-header{height:92px;padding:0 48px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:40px;background:#f7f8f4;position:sticky;top:0;z-index:30}.brand{display:flex;align-items:center;gap:11px;flex-shrink:0;font-weight:600;font-size:16px;letter-spacing:.3px}.brand-icon{width:39px;height:39px;border:1px solid #bfcabb;display:grid;place-items:center;border-radius:10px;color:#2c6c4c}.brand small{font:500 8px var(--mono);display:block;letter-spacing:2.6px;color:#768674;margin-top:6px}.site-header nav{display:flex;gap:7px;align-self:stretch;margin:auto}.site-header nav button{font-size:13px;padding:0 18px;position:relative}.site-header nav button.active{color:var(--green)}.site-header nav button.active:after{position:absolute;content:'';bottom:0;left:18px;right:18px;height:2px;background:var(--green)}.nav-new{background:#e7eddf;color:#568340;border-radius:3px;font-size:9px;padding:2px 4px;margin-left:5px}.header-right a{font:500 10px var(--mono);letter-spacing:.8px;display:flex;align-items:center;gap:8px;color:#6c7c6c}.menu-button{display:none}.primary,.outline,.text-button{display:inline-flex;align-items:center;justify-content:center;gap:9px;font-size:13px;border-radius:6px;min-height:43px;padding:11px 19px}.primary{background:var(--ink);color:#f8fff0}.primary:hover{background:#2b6247}.primary.light{background:var(--lime);color:#193726}.primary.light:hover{background:#e6ffb8}.outline{background:transparent;border:1px solid #ced8c9}.outline:hover{border-color:#91ab83;background:#edf3e8}.text-button{padding-left:0;color:#425b47}.compact{min-height:32px;padding:7px 10px;font-size:11px}.full-width{width:100%}.live-dot{width:6px;height:6px;border-radius:50%;background:#5f9163;display:inline-block;margin-right:8px;box-shadow:0 0 0 4px #e9f0de}.hero{padding-top:72px;display:grid;grid-template-columns:1.1fr 1fr;column-gap:7%;}.hero-copy .eyebrow{display:flex;align-items:center}.edition{margin-left:auto;font-size:9px;color:#94a089;letter-spacing:0}.hero h1{font-size:clamp(40px,4.6vw,66px);font-weight:500;letter-spacing:-3.5px;line-height:1.28;margin:25px 0 21px}.hero h1 span{color:#598440}.hero-copy>p{font-size:16px;color:#7a8878;line-height:1.95;letter-spacing:.3px;margin-bottom:27px}.hero-actions{display:flex;align-items:center;gap:25px}.hero-foot{display:flex;align-items:center;gap:13px;color:#929b8b;font-size:10px;margin-top:23px;letter-spacing:.2px}.hero-foot i{width:2px;height:2px;background:#99aa8e}.hero-visual{border:1px solid #d4dfcb;background:linear-gradient(145deg,#edf1e8,#f5f6ef);border-radius:12px;padding:26px 28px;align-self:center;position:relative;box-shadow:0 16px 50px #2e462209;overflow:hidden}.hero-visual:before{position:absolute;inset:0;content:'';background-image:radial-gradient(#9fae8c55 .7px,transparent .7px);background-size:15px 15px;opacity:.5;pointer-events:none}.hero-visual>*{position:relative}.diagram-head{display:flex;justify-content:space-between;font-size:11px;gap:12px}.diagram-head span:last-child{font:9px var(--mono);color:#8e9a82}.wave-line{display:flex;height:107px;align-items:center;gap:4px;margin-top:7px}.wave-line i{width:4px;flex:1;max-width:5px;border-radius:5px;background:#71955b;opacity:.8}.wave-line i:nth-child(n+35){background:#c0d5a5}.voice-end{display:flex;align-items:center;gap:10px;font-size:9px;color:#7d9073;margin-bottom:29px}.voice-end i{height:1px;background:#c4d2b7;flex:1}.diagram-rows{display:grid;gap:14px}.diagram-rows>div{display:flex;align-items:center;gap:12px;font-size:10px;color:#7b8e70;height:24px}.diagram-rows span{width:39px;flex-shrink:0}.diagram-rows b{height:19px;border-radius:3px;display:block}.input-stream{width:47%;background:repeating-linear-gradient(90deg,#bacaa7 0 15px,#dce8cc 15px 17px)}.think-stream{width:49%;margin-left:12%;background:repeating-linear-gradient(90deg,#426447 0 15px,#a5b79b 15px 17px)}.output-stream{width:43%;margin-left:28%;background:repeating-linear-gradient(90deg,#a6ce70 0 15px,#c4e395 15px 17px)}.diagram-rows em{font-style:normal;font-size:8px;white-space:nowrap}.visual-caption{display:flex;align-items:center;gap:13px;border-top:1px solid #d2ddc8;margin-top:28px;padding-top:20px}.tiny-mark{font:40px/1 var(--mono);color:#7f9d67}.visual-caption strong{font-size:12px;font-weight:500}.visual-caption p{font-size:9px;letter-spacing:.3px;color:#89977f;margin:5px 0 0}.hero-stats{grid-column:1/-1;display:grid;grid-template-columns:repeat(4,1fr);border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin-top:65px;padding:27px 0 25px}.hero-stats>div{border-right:1px solid var(--line);padding-left:29px}.hero-stats>div:first-child{padding-left:0}.hero-stats>div:last-child{border:0}.hero-stats b{font:400 30px var(--mono);letter-spacing:-1px}.hero-stats b span{font-size:12px;color:#87967b;letter-spacing:0}.hero-stats p{font-size:10px;color:#87907e;margin:10px 0 0}.hero-stats .source-link{font-size:9px;margin-left:3px}.section-title{display:flex;align-items:flex-end;justify-content:space-between;gap:25px;margin-bottom:32px}.section-title .eyebrow{margin-bottom:14px}.section-title p{font-size:12px;color:#82907e;margin-bottom:0}.compare-section{padding-top:68px}.view-tabs{display:flex;gap:30px;border-bottom:1px solid var(--line)}.view-tabs button{font-size:13px;display:flex;align-items:center;gap:9px;padding:13px 0;position:relative;color:#8b9984}.view-tabs button.active{color:var(--ink);font-weight:600}.view-tabs button.active:after{content:'';position:absolute;bottom:-1px;left:0;right:0;height:2px;background:var(--green)}.view-tabs button>span:not(.tag){font:10px var(--mono);background:#e6ebdf;border-radius:4px;padding:2px 5px}.filters{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:15px;padding:24px 0 20px}.segmented{display:flex;gap:4px;background:#e9eee2;border-radius:6px;padding:4px}.segmented button{padding:7px 11px;font-size:11px;border-radius:4px;color:#829273;white-space:nowrap}.segmented button.active{background:#fff;box-shadow:0 1px 4px #3045200b;color:#2d5834}.filter-controls{display:flex;align-items:center;gap:8px}.filter-controls select{font-size:10px;border:1px solid #dbe2d4;background:#f7f8f4;border-radius:5px;padding:9px 7px;max-width:125px;color:#64765a}.search-box{display:flex;align-items:center;border:1px solid #dbe2d4;border-radius:5px;padding:7px 10px;gap:6px;color:#9aa58e}.search-box input{width:132px;border:0;outline:0;background:transparent;font-size:10px;color:#364d30}.search-box input::placeholder{color:#9ba78f}.table-summary{display:flex;justify-content:space-between;font-size:9px;color:#96a28a;margin-bottom:11px}.table-scroll{overflow:auto;border:1px solid var(--line);border-radius:7px;background:#fff;scrollbar-width:thin;scrollbar-color:#c4d3bc transparent}.research-table{border-collapse:collapse;width:100%;font-size:11px;min-width:850px}.research-table thead{background:#edf1e7;position:sticky;top:0;z-index:2}.research-table th{text-align:left;padding:14px 18px;color:#7c8e6d;font-size:10px;font-weight:500;white-space:nowrap}.research-table td{padding:19px 18px;border-bottom:1px solid #edf0e8;line-height:1.6;color:#64745d;vertical-align:middle}.research-table td:first-child{width:31%}.research-table td:nth-child(2){width:20%}.research-table td:nth-child(3){width:24%}.research-table td:nth-child(4){width:22%}.research-table td:last-child{padding-left:0;width:3%;color:#a1ae95}.research-table tbody>tr:not(.detail-row){cursor:pointer}.research-table tbody>tr:hover:not(.detail-row),.research-table tr.expanded{background:#f8faf5}.research-table strong{font-size:12px;color:#314b2e;display:block}.subline{display:block;font-size:9px;color:#8b9b7e;margin-top:5px;font-weight:400;line-height:1.7}.subline .source-link{margin-right:6px}.record-date{font-size:8px}.modality-dot{font-size:8px;background:#eef3e7;color:#6b8559;border-radius:3px;padding:1px 4px;margin-left:5px}.value{font:500 17px var(--mono);letter-spacing:-.5px;color:#294c33}.derived{font:9px var(--mono);color:#a1ac95;display:block;margin-top:6px}.tag{display:inline-flex;align-items:center;width:max-content;gap:4px;font-size:9px;line-height:1.5;padding:3px 7px;border-radius:4px;font-weight:500;background:#edf0e7;color:#7f8e72;white-space:nowrap}.tag.green{background:#e7f0dc;color:#4b7433}.tag.amber{background:#f6ecd4;color:#96752f}.tag.purple{background:#eee8f4;color:#9274a2}.source-link{display:inline-flex;align-items:center;gap:2px;color:#698954;font-size:10px}.source-link:hover{color:#28572b;text-decoration:underline}.rotate{transform:rotate(180deg)}.detail-row td{background:#f0f4ea;padding:23px!important}.record-detail{display:flex;gap:30px;justify-content:space-between;align-items:center}.record-detail>div{flex:1}.record-detail strong{font-size:12px;margin-bottom:7px}.record-detail p{font-size:11px;margin-bottom:7px;line-height:1.8;max-width:900px}.record-detail .outline{flex-shrink:0;font-size:10px}.empty-state{padding:50px;text-align:center;color:#899a7d;font-size:12px}.comparison-notes{display:grid;grid-template-columns:1fr 1fr;gap:40px;padding:25px 0 0}.comparison-notes p{display:flex;align-items:flex-start;gap:10px;font-size:10px;color:#8c9880;margin:0;line-height:1.85}.comparison-notes svg{margin-top:3px}.comparison-notes b{color:#6b805d}.note-mark{font:14px var(--mono);margin-top:1px;border:1px solid #c6d5ba;padding:0 3px;line-height:1.4}.estimate-controls{display:flex;gap:20px;padding:25px 0 10px}.estimate-controls .field{flex:1}.estimate-controls .field:first-child{flex:1.5}.inline-note{font-size:11px;color:#859578;display:flex;gap:8px;align-items:flex-start;line-height:1.9}.inline-note svg{margin-top:4px}.studio-invite{margin-top:75px;margin-bottom:70px;max-width:1344px;background:#193c2d;border-radius:10px;padding:41px 44px;display:flex;align-items:center;justify-content:space-between;color:#eff7e3;position:relative;overflow:hidden}.studio-invite:after{content:'';position:absolute;inset:0;background-image:linear-gradient(110deg,transparent 55%,#aac97a10);pointer-events:none}.studio-invite .eyebrow{color:#92a57d;font-size:9px}.studio-invite h2{font-size:31px;font-weight:400;line-height:1.4;letter-spacing:-1px;margin:14px 0}.studio-invite p{font-size:11px;color:#aab997;margin:0}.studio-invite>button{z-index:1}.studio-section{padding:48px 0 70px}.studio-section .wrap{max-width:1600px;padding:0 35px}.saved-indicator{font-size:10px;display:flex;align-items:center;gap:5px;color:#8a9c7d}.preset-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:20px}.preset-bar>span{font-size:10px;color:#8f9e84;margin-right:8px}.preset-bar button{display:flex;align-items:center;gap:7px;padding:9px 12px;font-size:11px;background:#edf1e7;border:1px solid #dce4d3;border-radius:5px}.preset-bar button:hover,.preset-bar button.active{border-color:#8da479;background:#e6eedb;color:#37552d}.studio-shell{display:grid;grid-template-columns:170px minmax(0,1fr) 304px;border:1px solid #d4dfca;background:#fff;border-radius:8px;overflow:hidden;min-height:650px}.node-palette{padding:22px 12px;background:#f2f5ed;border-right:1px solid var(--line)}.node-palette>.eyebrow{display:block;padding-left:8px;margin-bottom:15px}.node-palette>button{display:flex;align-items:center;text-align:left;gap:7px;width:100%;font-size:10px;padding:9px 7px;border-radius:5px;margin-bottom:5px}.node-palette>button>span:nth-child(2){flex:1;line-height:1.6}.node-palette>button>svg{color:#97a88b}.palette-icon{display:grid;place-items:center;width:30px;height:30px;flex-shrink:0;border:1px solid #cfdcc2;border-radius:5px;color:#61844d;background:#fbfff6}.palette-icon.kind-llm{color:#796393;border-color:#ddd0e6;background:#f4eff7}.palette-icon.kind-speech{color:#aa7a3e;border-color:#e1d0b0;background:#faf4e6}.palette-icon.kind-program,.palette-icon.kind-merge{color:#657e8a;border-color:#c7d7dc;background:#f0f5f7}.palette-help{margin:35px 7px 0;border-top:1px solid #dfe7d6;padding-top:20px;color:#8c9e7f}.palette-help p{font-size:9px;line-height:1.9}.canvas-column{display:flex;flex-direction:column;min-width:0;position:relative}.canvas-toolbar{display:flex;align-items:center;justify-content:space-between;height:49px;padding:0 14px;border-bottom:1px solid #e3e9dc;background:#fcfdf9}.flow-name{font-size:11px;color:#687e5b;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;margin-right:8px}.canvas-toolbar>div{display:flex;gap:2px}.canvas-toolbar button,.icon-actions button,.icon-button{padding:7px;display:inline-flex;align-items:center;justify-content:center;border-radius:4px;color:#819572}.flow-canvas{flex:1;min-height:545px;position:relative;background:#f8faf5}.react-flow__controls{box-shadow:0 1px 6px #33532913!important;border:1px solid #d5e0cc}.react-flow__controls-button{border-bottom:1px solid #dce6d2!important;color:#6f885e!important;width:27px!important;height:27px!important}.react-flow__controls-button svg{max-width:11px;max-height:11px}.react-flow__attribution{font-size:8px!important;background:#f8faf5!important;color:#8f9c87!important}.stage-node{width:228px;background:#fff;border:1px solid #d0dec5;border-radius:8px;padding:12px 13px 0;color:#526b43;box-shadow:0 3px 9px #1d3d2210;transition:box-shadow .15s,border-color .15s}.stage-node.is-selected{box-shadow:0 0 0 3px #94b76b4d;border-color:#6b9a48}.stage-node.is-critical{border-color:#67975c}.stage-top{display:flex;align-items:center;gap:6px;font-size:8px;color:#8b9f7d;margin-bottom:10px}.stage-icon{display:flex;align-items:center;color:#739957}.stage-node.kind-llm .stage-icon{color:#9981b0}.stage-node.kind-speech .stage-icon{color:#be9355}.stage-node.kind-program .stage-icon,.stage-node.kind-merge .stage-icon{color:#7097a7}.stage-node>strong{font-size:12px;line-height:1.6;display:block;max-width:100%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#37552f}.stage-model{font-size:8px;color:#99a88c;margin:5px 0 12px;line-height:1.7;max-height:28px;overflow:hidden}.stage-bottom{display:flex;justify-content:space-between;font-size:8px;border-top:1px solid #eaf0e4;margin:0 -13px;padding:8px 12px;background:#f8fbf4;border-radius:0 0 8px 8px;color:#92a282}.stage-bottom b{color:#5d7d48;font:500 9px var(--mono);margin-left:3px}.loop-mark{margin-left:auto;background:#efe8f5;color:#9475a9;padding:1px 5px;border-radius:3px;font:8px var(--mono)}.react-flow__handle{width:9px!important;height:9px!important;background:#87aa6c!important;border:2px solid #fff!important;box-shadow:0 0 0 1px #b7caaa}.canvas-footer{display:flex;align-items:center;gap:14px;font-size:8px;padding:11px 13px;border-top:1px solid #e3e9dc;background:#fcfdf9;color:#8f9f83;min-height:37px;flex-wrap:wrap}.canvas-footer>span{display:flex;align-items:center;gap:5px}.canvas-footer>button{font-size:9px;display:flex;align-items:center;gap:4px;margin-left:auto;color:#66894e}.legend-critical{background:#4c8458;width:12px;height:2px}.legend-stream{border-top:2px dashed #9cb28c;width:12px}.canvas-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;pointer-events:none;color:#8da77c}.canvas-empty h3{font-size:16px;margin:20px 0 8px;color:#698954}.canvas-empty p{font-size:10px}.canvas-empty button{pointer-events:auto;font-size:11px;margin-top:10px}.inspector{border-left:1px solid var(--line);background:#fff;display:flex;flex-direction:column;min-width:0}.inspector-tabs{display:flex;background:#f7faf2;border-bottom:1px solid var(--line);height:49px;flex-shrink:0;padding:0 8px}.inspector-tabs button{font-size:11px;flex:1;color:#93a286;border-bottom:2px solid transparent}.inspector-tabs button.active{color:#3b6930;border-color:#78a257}.inspector-body{padding:20px 18px;max-height:630px;overflow:auto;scrollbar-width:thin;scrollbar-color:#d1dcc6 transparent}.inspector-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:19px}.inspector-heading .eyebrow{font-size:8px;letter-spacing:1.6px}.inspector-heading h3{font-size:17px;font-weight:500;margin:5px 0 0}.icon-actions{display:flex;gap:0}.field{display:flex;flex-direction:column;gap:7px;font-size:10px;color:#70865f;margin-bottom:15px;min-width:0}.field>span{line-height:1.6}.field input,.field select{font:11px/1.5 'DM Sans',-apple-system,'PingFang SC',sans-serif;color:#526a41;border:1px solid #d9e2cf;background:#fcfdf9;border-radius:4px;padding:9px 9px;width:100%;min-height:35px}.field input[type=number]{font-family:var(--mono);font-size:11px}.field small{font-size:8px;color:#9aa88f;line-height:1.7}.field-pair{display:grid;grid-template-columns:1fr 1fr;gap:12px}.setting-group{border-top:1px solid #e9eee2;padding-top:18px;margin-top:18px}.setting-group h4{font-size:11px;margin:0 0 16px;font-weight:600;color:#586f47}.group-title{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.group-title h4{margin:0}.loop-group{padding:14px 12px 1px;background:#f7f4fa;border-radius:6px;border:1px solid #ebe3f0;margin-bottom:18px}.loop-group .field input{background:#fffeff;border-color:#e3dbea}.toggle{display:flex;align-items:flex-start;gap:7px;font-size:10px;color:#698056;line-height:1.7;margin:10px 0 13px;cursor:pointer}.toggle input{accent-color:#5a8a45;margin:2px 0 0;flex-shrink:0;width:13px;height:13px}.inspector-kind{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:20px}.ref-note{background:#f2f6ec;border:1px solid #e2ead9;border-radius:5px;padding:11px 12px;font-size:9px;color:#91a17f;line-height:1.85;margin:0 0 17px}.ref-note p{margin:7px 0 0}.ref-note .source-link{font-size:9px}.inspector-empty{color:#a3b395;padding:60px 12px;text-align:center}.inspector-empty h3{font-size:15px;font-weight:500;color:#7b9467;margin:20px 0 10px}.inspector-empty p{font-size:10px;line-height:1.9}.node-estimate{background:#f3f7ed;border-radius:6px;padding:14px 12px;margin-top:23px}.node-estimate h4{font-size:11px;margin-bottom:13px}.node-estimate dl{margin:0 0 13px;display:flex;flex-wrap:wrap;gap:15px}.node-estimate dl div{display:flex;flex-direction:column;gap:5px}.node-estimate dt{font-size:8px;color:#96a583}.node-estimate dd{margin:0;font:11px var(--mono);color:#5d8145}.node-estimate p{font-size:9px;color:#8f9f7d;margin-bottom:7px;line-height:1.8}.node-estimate p:last-child{margin-bottom:0}.warn-text{color:#ac803d!important}.edge-description{display:flex;flex-wrap:wrap;align-items:center;gap:5px;color:#7f976b;font-size:10px;background:#f1f6ea;padding:9px;border-radius:4px}.connection-list{border-top:1px solid var(--line);margin-top:25px;padding-top:17px}.connection-list h4{font-size:10px;color:#8da17c}.connection-list button{display:flex;align-items:center;gap:6px;text-align:left;width:100%;font-size:9px;line-height:1.5;padding:8px 7px;border-bottom:1px solid #edf1e7}.connection-list button span{flex:1;word-break:break-all}.connection-list button.active{background:#e9f1df;color:#598441}.duplex-callout{border:1px solid #d7e5c7;background:#f4f9ec;border-radius:6px;padding:15px;margin-top:25px;color:#7f9d66}.duplex-callout h4{font-size:12px;margin:8px 0}.duplex-callout p{font-size:9px;margin:0;line-height:1.9}.result-strip{display:grid;grid-template-columns:1.3fr 1fr 1fr 1fr;border:1px solid #d0dfc1;border-radius:7px;background:#f1f6e9;margin-top:22px;padding:23px 8px}.result-strip>div{border-right:1px solid #d6e1c8;padding:0 24px;display:flex;flex-direction:column;gap:12px}.result-strip>div:last-child{border:0}.result-strip span{display:flex;align-items:center;gap:8px;font-size:10px;color:#8b9d7b;flex-wrap:wrap}.result-strip strong{font:400 27px var(--mono);letter-spacing:-1px;color:#547c3d;white-space:nowrap}.result-strip .primary-result strong{font-size:34px;color:#315f2c}.result-strip strong em{font-size:12px;font-style:normal;color:#9ab184}.result-strip small{font-size:9px;color:#9aab89;line-height:1.7}.result-note{display:flex;gap:8px;color:#a2af94;font-size:9px;margin-top:14px;line-height:1.9}.result-note svg{margin-top:2px}.result-note p{margin:0}.warnings{font-size:10px;color:#9e8d61;background:#faf5e7;border:1px solid #e9dfc4;border-radius:6px;padding:12px 15px;margin-top:16px}.warnings summary{display:flex;align-items:center;gap:8px;cursor:pointer;list-style:none}.warnings summary>span{background:#f0e7cf;border-radius:4px;padding:1px 5px;font:9px var(--mono)}.warnings summary>svg{margin-left:auto}.warnings ul{padding-left:17px;font-size:10px;line-height:1.9}.timeline-panel{background:#fff;border:1px solid var(--line);border-radius:8px;margin-top:26px;padding:24px}.timeline-heading{display:flex;justify-content:space-between;align-items:center;gap:16px}.timeline-heading h3{font-size:16px;font-weight:500;margin:0 0 8px}.timeline-heading h3 span{font:9px var(--mono);letter-spacing:0;color:#9aab88;margin-left:9px}.timeline-heading p{font-size:9px;color:#9caf8a;margin-bottom:20px}.timeline-axis{margin-left:171px;margin-right:80px;display:flex;justify-content:space-between;font:8px var(--mono);color:#9eae8f;border-bottom:1px solid #e4ecdb;padding:10px 0}.timeline-row{display:grid;grid-template-columns:160px minmax(0,1fr) 70px;gap:12px;align-items:center;width:100%;padding:9px 0;text-align:left;font-size:10px;border-bottom:1px solid #f0f4eb;border-radius:3px}.timeline-row.selected{background:#f3f8ed}.timeline-row>span{color:#6e875b;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.timeline-row small{font:8px var(--mono);margin-left:5px;color:#a18cb2}.timeline-row>strong{font:9px var(--mono);font-weight:400;color:#8ca577;text-align:right}.bar-track{height:23px;position:relative;background:repeating-linear-gradient(90deg,transparent 0 calc(25% - 1px),#f0f4e9 calc(25% - 1px) 25%)}.time-bar{position:absolute;height:13px;top:5px;background:#b6cba4;border:1px solid #9ebc84;border-radius:3px;min-width:4px;overflow:hidden}.time-bar.critical{background:#548653;border-color:#3c7140}.time-bar>i{display:block;height:100%;background:#e3ecd8b8}.playhead{height:23px;position:absolute;top:0;width:1px;background:#d59d52}.scrubber{display:flex;align-items:center;gap:16px;margin:17px 0 0;color:#9eae8d;font-size:9px}.scrubber>span{white-space:nowrap;width:160px;flex-shrink:0}.scrubber input{flex:1;accent-color:#86a664;height:3px}.step-breakdown{margin-top:20px;border-top:1px solid var(--line);padding-top:15px}.details-toggle{display:flex;align-items:center;gap:6px;font-size:11px;color:#8f76a4;padding:5px 0}.step-breakdown .table-scroll{margin-top:15px}.step-breakdown table{width:100%;border-collapse:collapse;font:10px var(--mono);min-width:650px}.step-breakdown th{text-align:left;font:9px 'DM Sans','PingFang SC',sans-serif;color:#ad9cbc;background:#f7f3fa}.step-breakdown th,.step-breakdown td{padding:10px;border-bottom:1px solid #eee8f3}.step-breakdown td{color:#8b759d}.error-box{padding:20px;background:#fae8e2;border:1px solid #e6b9ac;color:#b26850;margin-top:20px;border-radius:5px;font-size:12px}.method-section,.sources-section{padding-top:65px;padding-bottom:75px}.method-grid{display:grid;grid-template-columns:.8fr 1.2fr;gap:9%;margin-top:40px}.method-intro h3{font-weight:500;font-size:27px;letter-spacing:-.8px}.method-intro>p{font-size:13px;color:#87967c;line-height:2.1}.metric-defs{margin-top:35px;border-top:1px solid var(--line)}.metric-defs>div{display:flex;align-items:flex-start;gap:20px;border-bottom:1px solid var(--line);padding:18px 0}.metric-defs span{width:55px;flex-shrink:0;font:13px var(--mono);color:#567b40;line-height:1.7}.metric-defs p{font-size:11px;line-height:1.9;color:#8b9b7c;margin:0}.formula-list{display:flex;flex-direction:column;gap:30px}.formula-list article{display:flex;gap:23px;border-bottom:1px solid var(--line);padding-bottom:25px}.formula-list article>span{font:11px var(--mono);color:#a1b38f;padding-top:4px}.formula-list h3{font-size:16px;font-weight:500;margin-bottom:13px}.formula-list code{font:10px/1.8 var(--mono);background:#e8eedf;color:#5d7f45;padding:8px 10px;display:block;border-radius:4px;overflow-wrap:anywhere}.formula-list p{font-size:11px;color:#899b7a;line-height:2;margin:13px 0 0}.calibration-details{margin-top:50px;background:#f0f4e9;border:1px solid #d8e4cc;padding:20px 24px;border-radius:7px;color:#7b9564}.calibration-details summary{cursor:pointer;display:flex;align-items:center;justify-content:space-between;font-size:12px}.calibration-grid{display:grid;grid-template-columns:1fr 1fr;gap:45px;padding-top:28px}.calibration-grid table{width:100%;border-collapse:collapse;font-size:11px}.calibration-grid th{text-align:left;font-size:10px;color:#98aa88}.calibration-grid td,.calibration-grid th{border-bottom:1px solid #dce6d1;padding:9px 0}.calibration-grid h4{font-size:13px;font-weight:500}.calibration-grid p{font-size:10px;color:#8fa27d;line-height:2}.reading-note{max-width:800px;margin:55px auto 0}.reading-note h3{font-size:24px;font-weight:500}.reading-note p{font-size:13px;color:#839477;line-height:2.2}.source-list{margin-top:42px;display:grid;grid-template-columns:1fr 1fr;column-gap:50px}.source-list article{display:flex;gap:20px;border-top:1px solid var(--line);padding:22px 0}.source-number{font:10px var(--mono);color:#9db38b;padding-top:3px}.source-list article>div{flex:1;min-width:0}.source-list a{font-size:13px;display:flex;justify-content:space-between;align-items:center;gap:8px;color:#5b7b45}.source-list a:hover{text-decoration:underline}.source-list span:not(.source-number){font:9px var(--mono);color:#9cac8c;display:block;margin-top:8px}.source-list p{font-size:10px;color:#94a384;line-height:2;margin:12px 0 0}.site-footer{border-top:1px solid var(--line);padding-top:27px;padding-bottom:25px;display:flex;gap:25px;justify-content:space-between;align-items:center;flex-wrap:wrap}.brand-word{font:500 12px var(--mono);letter-spacing:2px;color:#6e8b58}.site-footer p{font-size:9px;color:#9aa98a;margin:7px 0 0}.site-footer>div:nth-child(2){display:flex;align-items:center;gap:25px;font-size:9px;color:#91a180}.site-footer a{display:flex;align-items:center;gap:5px}.site-footer button{font-size:9px;padding:5px}.site-footer>small{font:8px var(--mono);color:#adbaa0;width:100%;margin-top:3px}.toast{position:fixed;bottom:25px;left:50%;transform:translateX(-50%);z-index:100;background:#234931;color:#e7f3dd;box-shadow:0 10px 40px #1d3d2235;border:1px solid #7c9c65;padding:14px 16px;border-radius:8px;display:flex;align-items:center;gap:10px;max-width:calc(100vw - 30px);font-size:12px;line-height:1.7;min-width:260px}.toast button{color:#a7c18f;margin-left:auto;padding:4px;display:flex} +@media(min-width:1600px){.site-header{padding-left:max(48px,calc((100vw - 1440px)/2 + 48px));padding-right:max(48px,calc((100vw - 1440px)/2 + 48px))}.hero{padding-top:90px}.hero-stats{margin-top:75px}} +@media(max-width:1200px){.site-header{padding:0 28px;gap:15px}.site-header nav button{padding:0 12px;font-size:12px}.header-right a{font-size:8px}.wrap{padding-left:30px;padding-right:30px}.hero{gap:40px;padding-top:55px}.hero h1{font-size:48px}.edition{display:none}.hero-visual{padding:22px}.hero-copy>p{font-size:14px}.hero-foot{font-size:8px;gap:9px}.hero-stats b{font-size:25px}.hero-stats>div{padding-left:18px}.filters{gap:20px}.studio-invite{margin-left:30px;margin-right:30px}.studio-section .wrap{padding:0 22px}.studio-shell{grid-template-columns:148px minmax(0,1fr) 280px}.node-palette{padding-left:7px;padding-right:7px}.node-palette>button{font-size:9px;gap:5px}.result-strip>div{padding:0 17px}.result-strip strong{font-size:22px}.result-strip .primary-result strong{font-size:29px}.result-strip span{font-size:9px}.source-list{gap:0 30px}.studio-section h2{font-size:30px}} +@media(max-width:950px){.header-right a{display:none}.site-header nav{margin-left:auto;margin-right:0}.hero{grid-template-columns:1fr 1fr;gap:30px}.hero h1{font-size:42px;letter-spacing:-2px}.hero-visual{padding:20px 18px}.hero-copy>p{font-size:12px}.hero-actions{gap:12px}.hero-actions .primary,.hero-actions .text-button{font-size:11px;padding:10px 13px}.hero-foot{flex-wrap:wrap;gap:7px}.hero-stats{margin-top:25px}.hero-stats b{font-size:22px}.hero-stats p{font-size:8px}.diagram-head span:last-child{display:none}.diagram-rows em{font-size:7px}.studio-shell{grid-template-columns:120px minmax(0,1fr) 255px}.node-palette{padding-left:6px;padding-right:6px}.node-palette>button{font-size:8px;padding-left:3px;padding-right:3px}.node-palette>button>svg{display:none}.palette-icon{width:25px;height:25px}.inspector-body{padding:18px 13px}.inspector-tabs button{font-size:10px}.canvas-toolbar{padding:0 7px}.canvas-toolbar button{padding:5px}.canvas-footer{gap:8px;font-size:7px}.canvas-footer>button{margin-left:0;font-size:8px}.flow-name{font-size:9px}.result-strip>div{padding:0 12px}.result-strip small{font-size:8px}.result-strip strong{font-size:20px}.result-strip .primary-result strong{font-size:25px}.estimate-controls{gap:12px}.field>span{font-size:9px}.method-grid{gap:35px}.formula-list article{gap:12px}.site-footer>div:nth-child(2){gap:12px}} +@media(max-width:760px){.site-header{height:72px;padding:0 20px}.brand{font-size:14px;gap:9px}.brand-icon{width:34px;height:34px}.brand small{font-size:7px;letter-spacing:2px;margin-top:4px}.menu-button{display:flex;padding:7px}.header-right{margin-left:auto}.site-header nav{display:none;position:absolute;left:0;right:0;top:71px;height:auto;background:#f7f8f4;border-bottom:1px solid var(--line);padding:10px 15px;flex-direction:column;gap:0;align-items:stretch}.site-header nav.open{display:flex}.site-header nav button{text-align:left;padding:13px 12px;font-size:13px}.site-header nav button.active:after{display:none}.site-header nav button.active{background:#eaf1e1}.wrap{padding-left:22px;padding-right:22px}.hero{display:flex;flex-direction:column;padding-top:35px;gap:30px}.hero h1{font-size:48px;letter-spacing:-2px;margin-top:20px}.hero-copy>p{font-size:14px}.hero-actions{gap:20px}.hero-actions .primary,.hero-actions .text-button{font-size:12px;padding:12px 17px}.hero-foot{font-size:9px;margin-top:20px}.hero-visual{width:100%;padding:20px 22px;align-self:stretch;max-width:550px}.diagram-head span:last-child{display:block}.wave-line{height:82px}.diagram-rows{gap:9px}.diagram-rows em{font-size:8px}.voice-end{margin-bottom:17px}.visual-caption{margin-top:20px;padding-top:14px}.hero-stats{display:grid;grid-template-columns:1fr 1fr;width:100%;margin-top:5px;row-gap:23px;padding:23px 0}.hero-stats>div{padding-left:18px}.hero-stats>div:first-child,.hero-stats>div:nth-child(3){padding-left:0}.hero-stats>div:nth-child(2){border:0}.hero-stats b{font-size:24px}.hero-stats p{font-size:9px}.section-title{align-items:flex-start;gap:15px;flex-wrap:wrap;margin-bottom:24px}.section-title h2,h2{font-size:28px;letter-spacing:-1px}.section-title p{font-size:11px;line-height:1.9}.section-title .eyebrow{margin-bottom:12px}.compare-section{padding-top:38px}.section-title>.outline{font-size:10px;padding:8px 12px;min-height:34px}.view-tabs{gap:20px}.view-tabs button{font-size:11px;gap:5px}.view-tabs .tag{font-size:8px;padding:2px 4px}.filters{padding-top:17px;gap:15px}.filter-controls{width:100%;flex-wrap:wrap;gap:7px}.search-box{flex:1;min-width:150px}.search-box input{width:100%}.filter-controls select{max-width:130px;flex:1;min-width:90px}.segmented{width:100%;justify-content:space-between;gap:1px}.segmented button{font-size:10px;padding:7px 8px}.table-summary{font-size:8px;gap:5px}.research-table{min-width:810px}.research-table td{padding:16px 14px}.research-table th{padding:13px 14px}.research-table strong{font-size:11px}.record-detail{gap:15px;flex-direction:column;align-items:flex-start}.comparison-notes{grid-template-columns:1fr;gap:16px}.studio-invite{margin:40px 22px;padding:29px 25px;flex-direction:column;align-items:flex-start;gap:25px}.studio-invite h2{font-size:26px}.studio-invite p{font-size:10px}.studio-section{padding:30px 0 45px}.studio-section .wrap{padding:0 14px}.studio-section h2{font-size:27px}.studio-section .section-title{padding:0 8px}.saved-indicator{font-size:9px}.preset-bar{gap:6px}.preset-bar>span{width:100%;margin-bottom:4px;padding-left:5px;font-size:9px}.preset-bar button{font-size:9px;padding:8px 9px;gap:5px}.preset-bar button svg{width:13px}.studio-shell{display:flex;flex-direction:column;min-height:0}.node-palette{border-right:0;border-bottom:1px solid var(--line);display:flex;overflow-x:auto;gap:5px;padding:10px;scrollbar-width:thin}.node-palette>.eyebrow,.palette-help{display:none}.node-palette>button{width:auto;flex-shrink:0;margin:0;background:#fff;border:1px solid #dce6d2;padding:6px 7px;gap:5px;font-size:8px}.palette-icon{border:0;width:21px;height:21px}.palette-icon svg{width:15px}.canvas-column{height:470px;flex:none}.canvas-toolbar{height:43px;flex-shrink:0;padding:0 12px}.flow-name{font-size:10px}.canvas-toolbar button{padding:6px}.flow-canvas{min-height:0;height:390px;flex:1}.canvas-footer{padding:9px 10px;font-size:8px;min-height:36px}.canvas-footer>button{margin-left:auto;font-size:8px}.inspector{border-left:0;border-top:1px solid var(--line);max-height:590px}.inspector-tabs{height:43px}.inspector-tabs button{font-size:11px}.inspector-body{max-height:490px;padding:20px}.inspector-empty{padding:20px 25px}.inspector-empty h3{margin-top:10px}.field{font-size:11px;margin-bottom:16px}.field>span{font-size:10px}.field input,.field select{font-size:12px;min-height:38px}.field small{font-size:9px}.field-pair{gap:15px}.ref-note{font-size:10px}.micro-copy{font-size:10px}.toggle{font-size:11px}.node-estimate{margin-top:20px}.node-estimate p{font-size:10px}.node-estimate dl{gap:23px}.node-estimate dt{font-size:9px}.node-estimate dd{font-size:12px}.duplex-callout p{font-size:10px}.result-strip{grid-template-columns:1fr 1fr;padding:18px 0;gap:23px 0;margin-top:18px}.result-strip>div{padding:0 16px;gap:9px}.result-strip>div:nth-child(2){border:0}.result-strip strong{font-size:23px}.result-strip .primary-result strong{font-size:28px}.result-strip span{font-size:9px}.result-strip .tag{font-size:7px;padding:2px 4px}.result-note{font-size:8px;padding:0 4px;line-height:2}.timeline-panel{padding:20px 14px}.timeline-heading{align-items:flex-start}.timeline-heading h3{font-size:14px;line-height:1.7}.timeline-heading h3 span{display:block;margin:3px 0 0;font-size:8px}.timeline-heading p{font-size:8px;margin-bottom:13px}.timeline-heading .outline{white-space:nowrap;font-size:9px;padding:7px 9px;gap:4px}.timeline-axis{margin-left:106px;margin-right:50px;font-size:7px}.timeline-row{grid-template-columns:99px minmax(0,1fr) 43px;gap:7px;font-size:8px;padding:8px 0}.timeline-row>strong{font-size:8px}.time-bar{height:11px;top:6px}.scrubber{gap:8px;font-size:8px}.scrubber>span{width:99px}.warnings{font-size:9px;padding:12px}.warnings ul{font-size:9px;padding-left:15px}.estimate-controls{flex-wrap:wrap;gap:0 12px;padding-top:20px}.estimate-controls .field{flex:1;min-width:85px}.estimate-controls .field:first-child{min-width:100%;flex:1}.inline-note{font-size:9px;line-height:2}.method-section,.sources-section{padding-top:35px;padding-bottom:45px}.method-grid{grid-template-columns:1fr;gap:40px;margin-top:27px}.method-intro h3{font-size:23px}.method-intro>p{font-size:12px}.metric-defs{margin-top:25px}.metric-defs p{font-size:11px}.formula-list article{gap:17px}.formula-list h3{font-size:16px}.formula-list p{font-size:11px}.formula-list code{font-size:9px}.calibration-details{padding:18px;margin-top:35px}.calibration-details summary{font-size:11px;gap:10px;line-height:1.8}.calibration-grid{grid-template-columns:1fr;gap:20px}.calibration-grid p{font-size:10px}.reading-note{margin-top:35px}.reading-note h3{font-size:23px}.reading-note p{font-size:12px}.source-list{grid-template-columns:1fr;margin-top:25px}.source-list article{gap:16px;padding:20px 0}.source-list a{font-size:12px;line-height:1.9}.source-list p{font-size:10px}.site-footer{gap:20px;padding-top:24px}.site-footer>div:nth-child(2){width:100%;flex-wrap:wrap;gap:16px;font-size:9px}.site-footer>small{font-size:7px;line-height:1.9;overflow-wrap:anywhere}.toast{font-size:11px;padding:12px 14px;bottom:15px}.toast>svg{width:16px}.desktop-only{display:none}} +@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,*:before,*:after{animation:none!important;transition:none!important}.react-flow__edge-path{animation:none!important}} +.toolbar-metric{display:block;font:9px var(--mono);color:#6e8e59;margin-top:4px}.toolbar-metric i{display:inline-block;width:2px;height:2px;background:#a1b38e;margin:0 5px 2px}.canvas-toolbar button.focus-button{font-size:9px;padding:6px 8px;color:#5a7d42;border:1px solid #d9e4ce;margin-right:5px}.studio-shell.focused{position:fixed;inset:12px;z-index:60;height:calc(100dvh - 24px);min-height:0;grid-template-columns:60px minmax(0,1fr);box-shadow:0 0 0 30px #19392655,0 10px 80px #102a2a40}.studio-shell.focused .inspector{display:none}.studio-shell.focused.with-settings{grid-template-columns:60px minmax(0,1fr) 304px}.studio-shell.focused.with-settings .inspector{display:flex}.studio-shell.focused .node-palette{padding:16px 5px}.studio-shell.focused .node-palette>.eyebrow,.studio-shell.focused .node-palette>button>span:nth-child(2),.studio-shell.focused .node-palette>button>svg,.studio-shell.focused .palette-help{display:none}.studio-shell.focused .node-palette>button{justify-content:center;padding:9px 4px}.studio-shell.focused .canvas-toolbar{height:58px;flex-shrink:0}.studio-shell.focused .flow-canvas{min-height:0}.studio-shell.focused .inspector-body{max-height:calc(100dvh - 80px)}.stage-node>strong{font-size:13px}.stage-top,.stage-model{font-size:9px}.stage-bottom{font-size:9px}.stage-bottom b{font-size:10px} +@media(max-width:760px){.toolbar-metric{font-size:8px}.canvas-toolbar{height:52px}.canvas-toolbar button.focus-button{font-size:8px;padding:5px}.studio-shell.focused{inset:5px;height:calc(100dvh - 10px);display:flex;flex-direction:column}.studio-shell.focused .node-palette{padding:6px;flex-shrink:0}.studio-shell.focused .node-palette>button{padding:4px;margin:0}.studio-shell.focused .canvas-column{flex:1;height:auto;min-height:0}.studio-shell.focused .flow-canvas{height:auto}.studio-shell.focused.with-settings .inspector{height:48%;flex-shrink:0}.studio-shell.focused.with-settings .inspector-body{max-height:none}.studio-shell.focused .canvas-toolbar{height:55px;padding:0 6px}.studio-shell.focused .canvas-toolbar>div{gap:0}.studio-shell.focused .canvas-toolbar button{padding:4px}.stage-node>strong{font-size:13px}} diff --git a/test/engine.test.js b/test/engine.test.js new file mode 100644 index 0000000..5127fd6 --- /dev/null +++ b/test/engine.test.js @@ -0,0 +1,90 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {simulateGraph,estimateNode,validateGraph,validateDocument,DEFAULT_GLOBALS,autoLayout} from '../src/engine.js'; +import {makeDocument,nodeData,PRESETS} from '../src/presets.js'; +import {RECORDS,SOURCES} from '../src/catalog.js'; + +const g={...DEFAULT_GLOBALS,networkMs:0,vadMs:0,queueMs:0,speechSeconds:1}; +const node=(id,kind,extra={})=>({id,type:'stage',position:{x:0,y:0},data:{...nodeData(kind,id),...extra}}); +const edge=(source,target,release='complete',extra={})=>({id:`${source}-${target}`,source,target,data:{release,...extra}}); +const near=(a,b)=>assert.ok(Math.abs(a-b)<.00001,`${a} != ${b}`); + +test('serial stages add their service times',()=>{ + const n=[node('a','program',{durationMs:100}),node('b','program',{durationMs:200}),node('out','output',{durationMs:0,outputType:'text'})]; + const s=simulateGraph(n,[edge('a','b'),edge('b','out')],g); + near(s.first,300);near(s.complete,300); +}); +test('parallel all-join waits for longest branch rather than summing branches',()=>{ + const n=[node('in','input'),node('a','program',{durationMs:100}),node('b','program',{durationMs:300}),node('join','merge',{durationMs:20}),node('out','output',{durationMs:40,outputType:'text'})]; + const e=[edge('in','a'),edge('in','b'),edge('a','join'),edge('b','join'),edge('join','out')]; + const s=simulateGraph(n,e,g);near(s.first,360);assert.ok(s.criticalNodes.has('b'));assert.ok(!s.criticalNodes.has('a')); + const any=n.map(x=>x.id==='join'?{...x,data:{...x.data,join:'any'}}:x); + near(simulateGraph(any,e,g).first,160); +}); +test('streaming starts early, completion remains bounded by final upstream data',()=>{ + const n=[node('llm','llm',{mode:'custom',customFirstMs:100,customTps:100,outputTokens:101}),node('tts','tts',{mode:'custom',customFirstMs:50,customRtf:.1}),node('out','output',{durationMs:0})]; + const e=[edge('llm','tts','first'),edge('tts','out','first')],s=simulateGraph(n,e,g); + near(s.first,150);near(s.results.llm.end,1100);assert.ok(s.results.tts.end>=s.results.llm.end);assert.ok(s.complete>=s.first+1000); + const full=simulateGraph(n,[edge('llm','tts'),edge('tts','out','first')],g); + assert.ok(full.first>s.first); +}); +test('token gate adds k-1 decoding intervals',()=>{ + const n=[node('llm','llm',{mode:'custom',customFirstMs:100,customTps:100,outputTokens:101}),node('out','output',{durationMs:0,outputType:'text'})]; + near(simulateGraph(n,[edge('llm','out','chunk',{tokens:8})],g).first,170); +}); +test('loop maximum, inter-step tools, final-only output and context growth are explicit',()=>{ + const d={...nodeData('llm','agent'),mode:'custom',customFirstMs:100,customTps:100,outputTokens:11,maxSteps:3,expectedSteps:2,toolMs:50,contextGrowth:20,inputTokens:100,finalOnly:true}; + const r=estimateNode(d,g);near(r.end,700);near(r.first,600);assert.deepEqual(r.steps.map(x=>x.input),[100,120,140]); + const partial=estimateNode({...d,finalOnly:false},g);near(partial.first,100);near(partial.end,700); + const s=simulateGraph([node('agent','llm',d),node('out','output',{outputType:'text',durationMs:0})],[edge('agent','out','first')],{...g,loopPlan:'expected'}); + near(s.first,350);near(s.maxFirst,600);near(s.maxComplete,700);near(s.results.agent.end,450); +}); +test('hidden reasoning delays the first visible token',()=>{ + const d={...nodeData('llm','reasoner'),mode:'custom',customFirstMs:100,customTps:100,outputTokens:11,reasoningTokens:50}; + const r=estimateNode(d,g);near(r.first,600);near(r.end,700); +}); +test('shared model resources serialize parallel requests and change critical dependency',()=>{ + const n=[node('in','input'),node('a','llm',{mode:'custom',customFirstMs:100,customTps:100,outputTokens:41}),node('b','llm',{mode:'custom',customFirstMs:100,customTps:100,outputTokens:41}),node('out','output',{outputType:'text',durationMs:0})]; + const e=[edge('in','a'),edge('in','b'),edge('a','out'),edge('b','out')]; + near(simulateGraph(n,e,g).complete,500); + const s=simulateGraph(n,e,{...g,sharedGPU:true});near(s.complete,1000);near(s.results.b.resourceWait,500); +}); +test('source text TTFT is not interpreted as first playable audio',()=>{ + const r=estimateNode({...nodeData('speech','LFM3'),mode:'reference',profileId:'lfm3',size:2},g); + assert.notEqual(r.first,50);assert.ok(r.assumptions.some(x=>x.includes('只给首token'))); +}); +test('RTF-only reference uses its real factor without claiming first audio',()=>{ + const r=estimateNode({...nodeData('speech','MiniCPM'),mode:'reference',profileId:'minicpm',size:9},g); + near(r.rtf,.21);assert.ok(r.assumptions.some(x=>x.includes('来源只给RTF')));assert.notEqual(r.first,210); +}); +test('program scaling is global, and disconnected work is called out',()=>{ + const n=[node('a','program',{durationMs:100}),node('orphan','program',{durationMs:20}),node('out','output',{durationMs:0,outputType:'text'})]; + const s=simulateGraph(n,[edge('a','out')],{...g,programScale:2});near(s.first,200);assert.ok(s.warnings.some(x=>x.includes('orphan未连到主输出'))); +}); +test('audio component size and hardware settings affect engineering estimate',()=>{ + const a=estimateNode({...nodeData('asr','ASR'),hardware:'h20',size:.6},g); + const b=estimateNode({...nodeData('asr','ASR'),hardware:'h20',size:2},g); + assert.ok(b.end>a.end);const t1=estimateNode({...nodeData('tts','TTS'),cards:1},g),t2=estimateNode({...nodeData('tts','TTS'),cards:2},g);assert.ok(t2.end{ + const n=[node('in','input'),node('out','output',{durationMs:60,outputType:'text'})]; + near(simulateGraph(n,[edge('in','out','first')],{...g,vadMs:120,networkMs:20}).first,200); +}); +test('invalid cycles and dangling edges are rejected including imports',()=>{ + const n=[node('a','program'),node('b','program')]; + assert.ok(validateGraph(n,[edge('a','b'),edge('b','a')]).some(x=>x.includes('环'))); + assert.ok(validateGraph(n,[edge('a','missing')]).length); + assert.ok(validateDocument({version:1,nodes:n,edges:[],globals:{computeScale:-1}}).length); +}); +test('memory over-capacity is surfaced rather than silently claiming deployability',()=>{ + const r=estimateNode({...nodeData('llm','405B'),size:405,hardware:'4090',cards:1},g); + assert.ok(r.warnings.some(x=>x.includes('显存'))); +}); +test('source records have provenance and do not confuse the units of metrics',()=>{ + const ids=new Set(); + for(const r of RECORDS){assert.ok(!ids.has(r.id));ids.add(r.id);assert.ok(SOURCES[r.source]?.url.startsWith('https://'));assert.ok(r.scope);if(r.evidence==='unknown')assert.equal(r.value,null);else assert.ok(r.value>0);} + const q=RECORDS.find(r=>r.id==='q3-8');near(q.value,150.25);assert.equal(q.metric,'tps'); +}); +test('all presets compute finite targets and survive JSON round trip',()=>{ + for(const key of Object.keys(PRESETS)){const d=makeDocument(key);assert.deepEqual(validateDocument(d),[]);const s=simulateGraph(d.nodes,d.edges,d.globals);assert.deepEqual(s.errors,[]);assert.ok(Number.isFinite(s.first));assert.ok(Number.isFinite(s.complete));assert.deepEqual(validateDocument(JSON.parse(JSON.stringify(d))),[]);assert.equal(autoLayout(d.nodes,d.edges).length,d.nodes.length);} +});