From 76782da9cbb22d8b0cceb095dced7287b3b4c831 Mon Sep 17 00:00:00 2001 From: wuyang6 Date: Fri, 6 Nov 2026 21:37:07 +0800 Subject: [PATCH] Format the estimator and research UI source for review --- index.html | 15 +- package.json | 4 +- src/App.jsx | 2705 +++++++++++++++++++++++++++++++++++++-- src/catalog.js | 716 +++++++++-- src/engine.js | 801 ++++++++---- src/main.jsx | 16 +- src/presets.js | 206 ++- src/styles.css | 2951 ++++++++++++++++++++++++++++++++++++++++++- test/engine.test.js | 354 ++++-- 9 files changed, 7247 insertions(+), 521 deletions(-) diff --git a/index.html b/index.html index a5b1a93..56913fe 100644 --- a/index.html +++ b/index.html @@ -4,13 +4,22 @@ - + - + 模型时延实验室 · Latency Lab -
+ +
+ + diff --git a/package.json b/package.json index 1e31db7..0997848 100644 --- a/package.json +++ b/package.json @@ -15,5 +15,7 @@ "react": "19.3.1", "react-dom": "19.3.1" }, - "devDependencies": { "vite": "8.3.6" } + "devDependencies": { + "vite": "8.3.6" + } } diff --git a/src/App.jsx b/src/App.jsx index 1cc0e1e..bfb914e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,140 +1,2599 @@ -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'; +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 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 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}; +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 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 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观测不构成统一排行榜。公开报告也不等于本网站实测。 + +

+

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

+
+ + ) : ( + <> +
+ + + + + + + + + + + + + + + +
+

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

+
+ + + + + + + + + + + + {[0.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 NumberField({ + data, + patch, + keyName, + label, + hint, + min = 0, + max = 1e6, + step = 1, +}) { + return ( + + patch({ [keyName]: v })} + min={min} + max={max} + step={step} + /> + + ); } -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 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(0.1, Number(e.target.value) || 0.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 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 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 * 0.25)} + {ms(total * 0.5)} + {ms(total * 0.75)} + {ms(total)} +
+
+ {sim.order.map((id) => { + const r = sim.results[id], + width = Math.max(0.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 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 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: 0.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: 0.16 }} + minZoom={0.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 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}
} - ; +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 index 7f503be..0de04d7 100644 --- a/src/catalog.js +++ b/src/catalog.js @@ -1,88 +1,658 @@ -export const CUTOFF = '2026-11-06'; -export const RELEASE = 'K1412-LATENCY-LAB-20261106-v1'; +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。模型尺寸和统一的用户停说至首音频时延缺少公开可比数据。' }, + 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 }); +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'}), + ...[ + [0.6, 458.03], + [1.7, 333.9], + [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.8, 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: "官方“低至”数值,硬件条件未完整说明;没有可比的首音频值。", + }), + ...[ + [0.6, 1, 97, 0.288], + [1.7, 1, 101, 0.313], + [0.6, 3, 179, 0.338], + [1.7, 3, 195, 0.363], + [0.6, 6, 299, 0.434], + [1.7, 6, 333, 0.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 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}, + { 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, ]; -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]], + denseAnchors: [ + [0.6, 458.03], + [1.7, 333.9], + [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}, + quantRateFactors: { bf16: 0.62, fp8: 1, int4: 1.06 }, speculativeDefault: 1, - sensitivity: [0.75,1.5], + sensitivity: [0.75, 1.5], }; diff --git a/src/engine.js b/src/engine.js index cf19d05..2d4f4a0 100644 --- a/src/engine.js +++ b/src/engine.js @@ -1,238 +1,615 @@ -import { RECORDS, HARDWARE, CALIBRATION } from './catalog.js'; +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); +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); + const a = CALIBRATION.denseAnchors, + s = Math.max(0.1, size); + if (s <= a[0][0]) return a[0][1] * (a[0][0] / s) ** 0.2; + for (let i = 1; i < a.length; i++) + if (s <= a[i][0]) { + const p = + (Math.log(s) - Math.log(a[i - 1][0])) / + (Math.log(a[i][0]) - Math.log(a[i - 1][0])); + return Math.exp(Math.log(a[i - 1][1]) * (1 - p) + Math.log(a[i][1]) * p); } - 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'?'手动校准':'工程外推' }; + return (a.at(-1)[1] * a.at(-1)[0]) / s; } -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; } +export function estimateNode( + data, + globals = DEFAULT_GLOBALS, + forceMax = false, +) { + const g = { ...DEFAULT_GLOBALS, ...globals }, + d = data, + kind = d.kind; + const scale = Math.max(0.01, num(g.computeScale, 1, 100)), + progScale = Math.max(0.01, num(g.programScale, 1, 100)); + const model = MODEL_KINDS.includes(kind), + rec = getRecord(d.profileId); + const mode = d.mode || "estimate", + ref = + mode === "reference" && + rec && + rec.value != null && + rec.metric !== "interrupt" + ? rec + : null; + const hw = HARDWARE.find((h) => h.id === d.hardware) || HARDWARE[0], + cards = Math.max(1, Math.round(num(d.cards, 1, 256))); + const size = Math.max(0.1, num(d.size, 8, 1e5)), + active = + d.arch === "moe" + ? Math.max(0.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 ** 0.6) / (1 + Math.log2(cards) * 0.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(0.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(0.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; i < stepsCount; i++) { + const start = end + (i ? toolMs : 0); + const tokens = inputTokens + i * growth; + const uncached = + i === 0 + ? tokens * (1 - cached) + : inputTokens * (1 - cached) + i * growth; + const prefillRate = + CALIBRATION.prefillTokensPerSecondAt8B * + (8 / active) ** 0.65 * + hw.factor * + tpGain; + let prefill = + (CALIBRATION.prefillFloorMs + (1000 * uncached) / prefillRate) * scale; + if (refFirst != null) { + prefill = refFirst * scale; + if (kind === "llm" && ref.input > 1) + prefill *= Math.max(0.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, 0.45, 100) + : (refRtf ?? (0.24 + active * 0.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, 0.18, 100) + : (refRtf ?? (0.12 + size * 0.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 / 0.6) ** 0.5) / audioHardware) * + scale; + srtf = + (mode === "custom" + ? num(d.customRtf, 0.15, 100) + : (0.15 * (size / 0.6) ** 0.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" ? 0.55 : 1.05; + const weightsGB = size * bytesPerWeight; + if (weightsGB + Math.max(2, weightsGB * 0.08) > hw.memory * cards) + warnings.push( + `估计权重与最小运行余量约${Math.round(weightsGB + Math.max(2, weightsGB * 0.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}超出范围。`); + 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 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循环放在模型节点内。'); + 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'; +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'}; + 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};} + 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); } - 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}未连到主输出,当前不计入主结果耗时。`); + 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 < results[a.id].first ? n : a), + null, + ); + const completeTarget = terminals.reduce( + (a, n) => (!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(!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}; + 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 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 * 0.75, + programScale: g.programScale * 0.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}无效。`); + 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}})); +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; i < nodes.length; i++) + for (const id of [...pending]) { + const parents = edges.filter((e) => e.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 index 8ceb647..ff47542 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,6 +1,10 @@ -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(); +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 index 21de4ad..84ec447 100644 --- a/src/presets.js +++ b/src/presets.js @@ -1,29 +1,191 @@ -import { DEFAULT_GLOBALS, autoLayout } from './engine.js'; +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' }; +export function nodeData(kind, label) { + return { + kind, + label, + mode: "estimate", + size: kind === "tts" ? 0.6 : kind === "asr" ? 0.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: 0.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}}); +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('空白流程','从左侧添加节点,也可用下方的连线表单连接。',[],[]), + native: preset( + "原生全双工", + "输入与输出在同一模型内持续运行;另设独立的打断控制路径。", + [ + n("in", "input", "用户停止说话"), + n("voice", "speech", "原生语音对话", { + mode: "reference", + profileId: "persona", + size: 7, + customRtf: 0.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}; +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 index 62338a5..c665af6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,9 +1,2942 @@ -: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}} +: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: 0.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 0.16s, + color 0.16s, + box-shadow 0.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: 0.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: 0.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: 0.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: 0.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 0.7px, transparent 0.7px); + background-size: 15px 15px; + opacity: 0.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: 0.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: 0.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: -0.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 0.15s, + border-color 0.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: 0.8fr 1.2fr; + gap: 9%; + margin-top: 40px; +} +.method-intro h3 { + font-weight: 500; + font-size: 27px; + letter-spacing: -0.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 index 5127fd6..21e876e 100644 --- a/test/engine.test.js +++ b/test/engine.test.js @@ -1,90 +1,300 @@ -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'; +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}`); +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) < 0.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("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("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("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: 0.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("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("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("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("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("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("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, 0.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("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 a = estimateNode( + { ...nodeData("asr", "ASR"), hardware: "h20", size: 0.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 < t1.end); }); -test('network and endpoint wait are counted once at their boundaries',()=>{ - 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("network and endpoint wait are counted once at their boundaries", () => { + 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("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("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("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);} +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); + } });