feat(desktop): 全屏 JARVIS HUD 补齐概念版完整度——开机自检 + 遥测 + 解码文字

对齐概念版 artifact 的完整效果:
- 开机自检序列(INITIALIZING→AUDIO/UPLINK/VOICE MODEL OK→ONLINE),进场淡出,reduced-motion 跳过
- 右上遥测读数(SIGNAL/LATENCY/GAIN/SESSION),SIGNAL/LATENCY/GAIN 由真实电平实时驱动+抖动
- 回答走解码式打字机(逐字定稿、前沿字符跳乱码 glyph scramble),接真实 reply 流
- 修 ESC 按钮与遥测重叠:ESC 常显于右上角,遥测下移到其下,不再压住 SIGNAL

真机(浏览器预览)验证:遥测四行齐全无遮挡,STANDBY 呼吸,品牌位星期五。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-22 15:03:42 +08:00
parent a4b9897eaf
commit 4153046e0e
@@ -1,19 +1,19 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { X } from "lucide-react"; import { X } from "lucide-react";
import type { VoiceState } from "../lib/voice"; import type { VoiceState } from "../lib/voice";
// 全屏「JARVIS 模式」HUD:弧反应堆核心 + 随真实声音反应的环形频谱 + 状态编排 // 全屏「JARVIS 模式」HUD:弧反应堆核心 + 随真实声音反应的环形频谱 + 遥测 + 解码文字 + 开机自检
// 视觉数据全来自真实语音会话:level 由 VoiceClient 的麦克风/TTS 探针实时给出(getLevel), // 视觉数据全来自真实语音会话:level 由 VoiceClient 的麦克风/TTS 探针实时给出(getLevel),
// state 是会话状态机;transcript/reply 是当轮问答。点中心说话,右上角退出。 // state 是会话状态机;transcript/reply 是当轮问答。点中心说话,右上角/ESC 退出。
interface Props { interface Props {
name: string; // 助手名(HUD 品牌位) name: string;
state: VoiceState; state: VoiceState;
getLevel: () => number; // 0..1 实时电平 getLevel: () => number;
transcript: string; // 我说的 transcript: string;
reply: string; // JARVIS 回答(流式) reply: string;
hint: string; // 当前状态提示 hint: string;
onMic: () => void; // 点中心:开始/结束说话 onMic: () => void;
onClose: () => void; onClose: () => void;
} }
@@ -28,13 +28,29 @@ const CONF: Record<VoiceState, Conf> = {
thinking: { accent: AM, activity: 0.3, ringSpin: 0.8, label: "PROCESSING" }, thinking: { accent: AM, activity: 0.3, ringSpin: 0.8, label: "PROCESSING" },
speaking: { accent: CY, activity: 0.85, ringSpin: 0.22, label: "SPEAKING" }, speaking: { accent: CY, activity: 0.85, ringSpin: 0.22, label: "SPEAKING" },
}; };
const GLYPH = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%@*<>/\\";
export function JarvisHud({ name, state, getLevel, transcript, reply, hint, onMic, onClose }: Props) { export function JarvisHud({ name, state, getLevel, transcript, reply, hint, onMic, onClose }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const decRef = useRef<HTMLSpanElement>(null);
const sigRef = useRef<HTMLElement>(null);
const latRef = useRef<HTMLElement>(null);
const gainRef = useRef<HTMLElement>(null);
const [booting, setBooting] = useState(true);
// 用 ref 让 RAF 闭包读到最新 props,无需重启动画。 // 用 ref 让 RAF 闭包读到最新 props,无需重启动画。
const P = useRef({ state, getLevel, transcript, reply }); const P = useRef({ state, getLevel, transcript, reply });
P.current = { state, getLevel, transcript, reply }; P.current = { state, getLevel, transcript, reply };
// 开机自检:进场放一段 INITIALIZING…ONLINE,约 2.4s 后淡出(reduced-motion 直接跳过)。
useEffect(() => {
if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
setBooting(false);
return;
}
const id = setTimeout(() => setBooting(false), 2400);
return () => clearTimeout(id);
}, []);
useEffect(() => { useEffect(() => {
const cv = canvasRef.current!; const cv = canvasRef.current!;
const ctx = cv.getContext("2d")!; const ctx = cv.getContext("2d")!;
@@ -56,17 +72,18 @@ export function JarvisHud({ name, state, getLevel, transcript, reply, hint, onMi
const accent = CY.slice(); const accent = CY.slice();
let level = 0.18, think = 0, t = 0, last = performance.now(); let level = 0.18, think = 0, t = 0, last = performance.now();
const parts = Array.from({ length: 64 }, () => ({ a: Math.random() * 6.28, r: Math.random(), s: 0.3 + Math.random() * 0.9 })); const parts = Array.from({ length: 64 }, () => ({ a: Math.random() * 6.28, r: Math.random(), s: 0.3 + Math.random() * 0.9 }));
// 解码文字状态:shown=已定稿字数,scr=当前字的乱码累积。
let decShown = 0, scr = 0;
const synth = (s: VoiceState) => const synth = (s: VoiceState) => (s === "thinking" ? 0.2 + 0.06 * Math.sin(t * 3) : 0.16 + 0.05 * Math.sin(t * 1.6));
s === "thinking" ? 0.2 + 0.06 * Math.sin(t * 3) : 0.16 + 0.05 * Math.sin(t * 1.6);
const draw = (now: number) => { const draw = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000); last = now; t += dt; const dt = Math.min(0.05, (now - last) / 1000); last = now; t += dt;
const st = P.current.state; const st = P.current.state;
const c = CONF[st] ?? CONF.idle; const c = CONF[st] ?? CONF.idle;
const active = st === "listening" || st === "speaking"; const activeAudio = st === "listening" || st === "speaking";
const tgt = active ? Math.max(P.current.getLevel(), 0) : synth(st); const tgt = activeAudio ? Math.max(P.current.getLevel(), 0) : synth(st);
level += (tgt - level) * Math.min(1, dt * (active ? 16 : 6)); level += (tgt - level) * Math.min(1, dt * (activeAudio ? 16 : 6));
for (let i = 0; i < 3; i++) accent[i] = lerp(accent[i], c.accent[i], Math.min(1, dt * 4)); for (let i = 0; i < 3; i++) accent[i] = lerp(accent[i], c.accent[i], Math.min(1, dt * 4));
think += ((st === "thinking" ? 1 : 0) - think) * Math.min(1, dt * 3); think += ((st === "thinking" ? 1 : 0) - think) * Math.min(1, dt * 3);
@@ -140,6 +157,22 @@ export function JarvisHud({ name, state, getLevel, transcript, reply, hint, onMi
ctx.closePath(); ctx.stroke(); ctx.closePath(); ctx.stroke();
ctx.globalCompositeOperation = "source-over"; ctx.globalCompositeOperation = "source-over";
// 遥测读数
if (sigRef.current) sigRef.current.textContent = String(Math.round(level * 100)).padStart(2, "0");
if (latRef.current) latRef.current.textContent = String(90 + Math.round(level * 60 + Math.sin(t * 9) * 8)).padStart(3, "0");
if (gainRef.current) gainRef.current.textContent = (0.8 + level * 2.4).toFixed(1);
// 解码式回答:让文字逐字"定稿",前沿几个字符跳乱码。
const rep = P.current.reply;
if (decShown > rep.length) decShown = 0; // 新一轮回答,重置
if (decShown < rep.length) { scr += 0.5; if (scr >= 1) { scr = 0; decShown++; } }
if (decRef.current) {
let out = rep.slice(0, decShown);
const frontier = Math.min(6, rep.length - decShown);
for (let i = 0; i < frontier; i++) out += `<span class="jhud-scr">${GLYPH[(Math.random() * GLYPH.length) | 0]}</span>`;
decRef.current.innerHTML = out;
}
raf = requestAnimationFrame(draw); raf = requestAnimationFrame(draw);
}; };
raf = requestAnimationFrame(draw); raf = requestAnimationFrame(draw);
@@ -148,6 +181,7 @@ export function JarvisHud({ name, state, getLevel, transcript, reply, hint, onMi
const c = CONF[state] ?? CONF.idle; const c = CONF[state] ?? CONF.idle;
const amber = state === "thinking"; const amber = state === "thinking";
const NAME = (name || "JARVIS").toUpperCase();
return ( return (
<div className="jhud" role="dialog" aria-label="JARVIS 全屏模式"> <div className="jhud" role="dialog" aria-label="JARVIS 全屏模式">
@@ -158,31 +192,44 @@ export function JarvisHud({ name, state, getLevel, transcript, reply, hint, onMi
<span className="jhud-corner bl" /><span className="jhud-corner br" /> <span className="jhud-corner bl" /><span className="jhud-corner br" />
<div className="jhud-status"> <div className="jhud-status">
<div className="jhud-brand">{(name || "JARVIS").toUpperCase()}</div> <div className="jhud-brand">{NAME}</div>
<div className="jhud-row"> <div className="jhud-row">
<span className={"jhud-dot" + (state === "listening" ? " live" : amber ? " amber" : "")} /> <span className={"jhud-dot" + (state === "listening" ? " live" : amber ? " amber" : "")} />
<b>{c.label}</b> <b>{c.label}</b>
</div> </div>
</div> </div>
<div className="jhud-tel">
<div>SIGNAL <b ref={sigRef}>00</b>%</div>
<div>LATENCY <b ref={latRef}>000</b>ms</div>
<div>GAIN <b ref={gainRef}>0.0</b></div>
<div>SESSION <b>#7F2A</b></div>
</div>
<button className="jhud-close" onClick={onClose} aria-label="退出 JARVIS 模式"> <button className="jhud-close" onClick={onClose} aria-label="退出 JARVIS 模式">
<X className="h-4 w-4" /> ESC <X className="h-4 w-4" /> ESC
</button> </button>
{/* 点中心说话 */}
<button className="jhud-core" onClick={onMic} aria-label={hint} title={hint} /> <button className="jhud-core" onClick={onMic} aria-label={hint} title={hint} />
<div className={"jhud-word" + (amber ? " amber" : state === "listening" ? " live" : "")}>{hint}</div> <div className={"jhud-word" + (amber ? " amber" : state === "listening" ? " live" : "")}>{hint}</div>
<div className="jhud-decode"> <div className="jhud-decode">
{transcript && <p className="jhud-me"><span></span>{transcript}</p>} {transcript && <p className="jhud-me"><span></span>{transcript}</p>}
{reply && ( <p className="jhud-reply" style={{ visibility: reply ? "visible" : "hidden" }}>
<p className="jhud-reply"> <span>{NAME}</span><span ref={decRef} />
<span>{(name || "JARVIS").toUpperCase()}</span>{reply} {(state === "thinking" || state === "speaking") && <i className="jhud-cursor" />}
{(state === "thinking" || state === "speaking") && <i className="jhud-cursor" />} </p>
</p>
)}
</div> </div>
{booting && (
<div className="jhud-boot" aria-hidden="true">
<div><span className="b"></span> INITIALIZING {NAME} CORE</div>
<div><span className="b"></span> AUDIO SUBSYSTEM <span className="ok"> OK</span></div>
<div><span className="b"></span> NEURAL UPLINK <span className="ok"> OK</span></div>
<div><span className="b"></span> VOICE MODEL · v4-flash <span className="ok"> OK</span></div>
<div className="big">ONLINE</div>
</div>
)}
</div> </div>
); );
} }
@@ -190,8 +237,7 @@ export function JarvisHud({ name, state, getLevel, transcript, reply, hint, onMi
const HUD_CSS = ` const HUD_CSS = `
.jhud{position:fixed;inset:0;z-index:60;overflow:hidden;color:#d6f2fc;user-select:none; .jhud{position:fixed;inset:0;z-index:60;overflow:hidden;color:#d6f2fc;user-select:none;
background:radial-gradient(120% 80% at 50% 44%,#08131d 0%,#04070d 62%,#02040a 100%); background:radial-gradient(120% 80% at 50% 44%,#08131d 0%,#04070d 62%,#02040a 100%);
font-family:ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace; font-family:ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace;animation:jhudIn .5s ease}
animation:jhudIn .5s ease}
@keyframes jhudIn{from{opacity:0}to{opacity:1}} @keyframes jhudIn{from{opacity:0}to{opacity:1}}
.jhud::before{content:"";position:absolute;inset:0;pointer-events:none;opacity:.5; .jhud::before{content:"";position:absolute;inset:0;pointer-events:none;opacity:.5;
background-image:linear-gradient(transparent 0 3px,rgba(56,225,255,.035) 3px 4px),linear-gradient(90deg,transparent 0 3px,rgba(56,225,255,.028) 3px 4px); background-image:linear-gradient(transparent 0 3px,rgba(56,225,255,.035) 3px 4px),linear-gradient(90deg,transparent 0 3px,rgba(56,225,255,.028) 3px 4px);
@@ -213,22 +259,32 @@ const HUD_CSS = `
.jhud-dot.live{background:#ff4d5e;box-shadow:0 0 10px #ff4d5e;animation:jhudBlink 1s steps(2) infinite} .jhud-dot.live{background:#ff4d5e;box-shadow:0 0 10px #ff4d5e;animation:jhudBlink 1s steps(2) infinite}
.jhud-dot.amber{background:#ffb638;box-shadow:0 0 10px #ffb638} .jhud-dot.amber{background:#ffb638;box-shadow:0 0 10px #ffb638}
@keyframes jhudBlink{50%{opacity:.25}} @keyframes jhudBlink{50%{opacity:.25}}
.jhud-tel{position:absolute;top:66px;right:40px;z-index:5;text-align:right;font-size:11px;line-height:2.1;letter-spacing:.14em;color:#5f8496}
.jhud-tel b{color:#38e1ff;font-weight:500;font-variant-numeric:tabular-nums}
.jhud-close{position:absolute;top:26px;right:34px;z-index:6;display:flex;align-items:center;gap:6px; .jhud-close{position:absolute;top:26px;right:34px;z-index:6;display:flex;align-items:center;gap:6px;
background:rgba(8,22,32,.6);border:1px solid #0e2a38;color:#7fa6b6;border-radius:999px;padding:7px 13px; background:rgba(8,22,32,.6);border:1px solid #0e2a38;color:#7fa6b6;border-radius:999px;padding:7px 13px;
font-family:inherit;font-size:11px;letter-spacing:.14em;cursor:pointer;backdrop-filter:blur(6px);transition:.18s} font-family:inherit;font-size:11px;letter-spacing:.14em;cursor:pointer;backdrop-filter:blur(6px);transition:.18s}
.jhud-close:hover{border-color:#38e1ff;color:#d6f2fc} .jhud-close:hover{border-color:#38e1ff;color:#d6f2fc}
.jhud-core{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:26vmin;height:26vmin; .jhud-core{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:26vmin;height:26vmin;border-radius:50%;background:transparent;border:0;cursor:pointer;z-index:5}
border-radius:50%;background:transparent;border:0;cursor:pointer;z-index:5} .jhud-word{position:absolute;left:50%;top:calc(50% + 20vmin);transform:translateX(-50%);z-index:5;font-size:13px;letter-spacing:.5em;color:#38e1ff;text-shadow:0 0 16px rgba(56,225,255,.5);text-transform:uppercase;white-space:nowrap}
.jhud-word{position:absolute;left:50%;top:calc(50% + 20vmin);transform:translateX(-50%);z-index:5;
font-size:13px;letter-spacing:.5em;color:#38e1ff;text-shadow:0 0 16px rgba(56,225,255,.5);text-transform:uppercase;white-space:nowrap}
.jhud-word.amber{color:#ffb638;text-shadow:0 0 16px rgba(255,182,56,.5)} .jhud-word.amber{color:#ffb638;text-shadow:0 0 16px rgba(255,182,56,.5)}
.jhud-word.live{color:#ff4d5e;text-shadow:0 0 16px rgba(255,77,94,.5)} .jhud-word.live{color:#ff4d5e;text-shadow:0 0 16px rgba(255,77,94,.5)}
.jhud-decode{position:absolute;left:50%;bottom:56px;transform:translateX(-50%);z-index:5; .jhud-decode{position:absolute;left:50%;bottom:56px;transform:translateX(-50%);z-index:5;width:min(760px,88vw);text-align:center;line-height:1.55}
width:min(760px,88vw);text-align:center;line-height:1.55}
.jhud-me{color:#5f8496;font-size:13px;margin:0 0 10px} .jhud-me{color:#5f8496;font-size:13px;margin:0 0 10px}
.jhud-me span,.jhud-reply span{font-size:10px;letter-spacing:.24em;margin-right:8px;opacity:.75} .jhud-me span,.jhud-reply>span:first-child{font-size:10px;letter-spacing:.24em;margin-right:8px;opacity:.75}
.jhud-reply{color:#eaf8ff;font-size:16px;margin:0;text-shadow:0 0 10px rgba(56,225,255,.25)} .jhud-reply{color:#eaf8ff;font-size:16px;margin:0;min-height:24px;text-shadow:0 0 10px rgba(56,225,255,.25)}
.jhud-reply span{color:#38e1ff} .jhud-reply>span:first-child{color:#38e1ff}
.jhud-scr{color:#38e1ff;opacity:.9}
.jhud-cursor{display:inline-block;width:8px;height:2px;background:#38e1ff;margin-left:3px;vertical-align:middle;box-shadow:0 0 8px #38e1ff;animation:jhudBlink .8s steps(2) infinite} .jhud-cursor{display:inline-block;width:8px;height:2px;background:#38e1ff;margin-left:3px;vertical-align:middle;box-shadow:0 0 8px #38e1ff;animation:jhudBlink .8s steps(2) infinite}
@media (prefers-reduced-motion: reduce){.jhud::after{display:none}.jhud-dot.live,.jhud-cursor{animation:none}} .jhud-boot{position:absolute;inset:0;z-index:8;background:#02040a;display:flex;flex-direction:column;justify-content:center;padding-left:12%;gap:6px;font-size:14px;letter-spacing:.06em;color:#38e1ff;animation:jhudBootOut .6s ease 1.8s forwards}
.jhud-boot>div{opacity:0;animation:jhudBootLine .3s ease forwards}
.jhud-boot>div:nth-child(1){animation-delay:.1s}
.jhud-boot>div:nth-child(2){animation-delay:.5s}
.jhud-boot>div:nth-child(3){animation-delay:.9s}
.jhud-boot>div:nth-child(4){animation-delay:1.2s}
.jhud-boot .b{color:#3d6475}.jhud-boot .ok{color:#38e1ff}
.jhud-boot .big{font-size:26px;letter-spacing:.4em;margin-top:14px;color:#eaf8ff;text-shadow:0 0 20px rgba(56,225,255,.6);animation-delay:1.5s}
@keyframes jhudBootLine{from{opacity:0;transform:translateX(-8px)}to{opacity:1;transform:none}}
@keyframes jhudBootOut{to{opacity:0}}
@media (prefers-reduced-motion: reduce){.jhud::after,.jhud-boot{display:none}.jhud-dot.live,.jhud-cursor{animation:none}}
`; `;