fix(qa): QaPanel 加 qa:level 监听 + LevelBar 电平条 (closes #162)
后端 coordinator.rs:1743 在 QA 录音时每帧 33ms 推 qa:level 事件,
但 QaPanel 之前只 listen qa:state / qa:dismiss,qa:level 完全无人接 →
QA 录音用户看不到电平反馈,不知道麦克风是否收音。
修复:
- QaPanel 加 listen('qa:level') 累积 level state(cleanup 加 unlistenLevel)
- 新增 LevelBar 组件:4px 高蓝色进度条,宽度 = level × 100%
- RecordingHeader / TurnIndicator(recording) 都加 LevelBar 渲染
- 状态切换非 recording 时 setLevel(0) 避免残留
测试:
- npm run build ✅
- macOS 实测:QA 录音电平条跟随声音起伏(同 capsule 体验)
This commit is contained in:
1 parent
490b391022
commit
a8f4b891a0
1 file changed
+47
-2
@@ -31,6 +31,8 @@ export function QaPanel() {
|
||||
const [pinned, setPinned] = useState(false);
|
||||
/** 流式 LLM 答案:answer_delta 累积、answer 事件来时清空(最终内容已落到 messages)。 */
|
||||
const [streamingAnswer, setStreamingAnswer] = useState<string>('');
|
||||
/** 录音电平:0..1。后端每帧 33ms 通过 qa:level emit。详见 issue #162。 */
|
||||
const [level, setLevel] = useState<number>(0);
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
|
||||
@@ -39,6 +41,7 @@ export function QaPanel() {
|
||||
if (!isTauri) return;
|
||||
let unlistenState: (() => void) | undefined;
|
||||
let unlistenDismiss: (() => void) | undefined;
|
||||
let unlistenLevel: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
@@ -54,6 +57,7 @@ export function QaPanel() {
|
||||
setSelectionPreview('');
|
||||
setErrorMsg('');
|
||||
setStreamingAnswer('');
|
||||
setLevel(0);
|
||||
break;
|
||||
case 'recording':
|
||||
setStatus('recording');
|
||||
@@ -66,6 +70,7 @@ export function QaPanel() {
|
||||
setSelectionPreview('');
|
||||
setErrorMsg('');
|
||||
setStreamingAnswer('');
|
||||
setLevel(0);
|
||||
break;
|
||||
case 'answer_delta':
|
||||
// 流式增量。仍保持 thinking 状态——直到 answer 事件落定后才回 idle。
|
||||
@@ -79,11 +84,13 @@ export function QaPanel() {
|
||||
setErrorMsg('');
|
||||
// messages 已被上面的 setMessages 落定,清掉流式 buffer 避免和最终气泡重影。
|
||||
setStreamingAnswer('');
|
||||
setLevel(0);
|
||||
break;
|
||||
case 'error':
|
||||
setStatus('error');
|
||||
setErrorMsg(payload.error ?? tRef.current('qa.error'));
|
||||
setStreamingAnswer('');
|
||||
setLevel(0);
|
||||
break;
|
||||
}
|
||||
});
|
||||
@@ -91,12 +98,18 @@ export function QaPanel() {
|
||||
setPinned(false);
|
||||
void qaWindowDismiss();
|
||||
});
|
||||
// qa:level — 录音电平,节流 ~33ms/帧。详见 issue #162。
|
||||
const levelHandle = await listen<{ level: number }>('qa:level', event => {
|
||||
setLevel(event.payload.level ?? 0);
|
||||
});
|
||||
if (cancelled) {
|
||||
stateHandle();
|
||||
dismissHandle();
|
||||
levelHandle();
|
||||
} else {
|
||||
unlistenState = stateHandle;
|
||||
unlistenDismiss = dismissHandle;
|
||||
unlistenLevel = levelHandle;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[QaPanel] listener setup failed', error);
|
||||
@@ -106,6 +119,7 @@ export function QaPanel() {
|
||||
cancelled = true;
|
||||
unlistenState?.();
|
||||
unlistenDismiss?.();
|
||||
unlistenLevel?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -145,11 +159,11 @@ export function QaPanel() {
|
||||
<div ref={scrollRef} style={contentStyle}>
|
||||
{messages.length === 0 && status === 'idle' && <EmptyHint t={t} />}
|
||||
{messages.length === 0 && status === 'recording' && (
|
||||
<RecordingHeader preview={selectionPreview} t={t} />
|
||||
<RecordingHeader preview={selectionPreview} t={t} level={level} />
|
||||
)}
|
||||
<MessageList messages={messages} />
|
||||
{status === 'recording' && messages.length > 0 && (
|
||||
<TurnIndicator kind="recording" t={t} preview={selectionPreview} />
|
||||
<TurnIndicator kind="recording" t={t} preview={selectionPreview} level={level} />
|
||||
)}
|
||||
{streamingAnswer && (
|
||||
<StreamingAssistantBubble markdown={streamingAnswer} />
|
||||
@@ -248,9 +262,11 @@ function EmptyHint({ t }: { t: ReturnType<typeof useTranslation>['t'] }) {
|
||||
function RecordingHeader({
|
||||
preview,
|
||||
t,
|
||||
level,
|
||||
}: {
|
||||
preview: string;
|
||||
t: ReturnType<typeof useTranslation>['t'];
|
||||
level: number;
|
||||
}) {
|
||||
const truncated = useMemo(() => truncate(preview, SELECTION_PREVIEW_MAX), [preview]);
|
||||
return (
|
||||
@@ -267,6 +283,32 @@ function RecordingHeader({
|
||||
<span style={recordingDotStyle} />
|
||||
{t('qa.recordingHint')}
|
||||
</div>
|
||||
<LevelBar level={level} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** QA 录音电平条。后端 qa:level 每帧 ~33ms 推一次 0..1。详见 issue #162。 */
|
||||
function LevelBar({ level }: { level: number }) {
|
||||
const pct = Math.min(100, Math.max(0, level * 100));
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: 4,
|
||||
width: '100%',
|
||||
background: 'rgba(0,0,0,0.06)',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${pct}%`,
|
||||
background: 'var(--ol-blue)',
|
||||
transition: 'width 0.08s ease-out',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -366,10 +408,12 @@ function TurnIndicator({
|
||||
kind,
|
||||
preview,
|
||||
t,
|
||||
level,
|
||||
}: {
|
||||
kind: 'recording' | 'thinking';
|
||||
preview?: string;
|
||||
t: ReturnType<typeof useTranslation>['t'];
|
||||
level?: number;
|
||||
}) {
|
||||
if (kind === 'recording') {
|
||||
const truncated = preview ? truncate(preview, SELECTION_PREVIEW_MAX) : '';
|
||||
@@ -387,6 +431,7 @@ function TurnIndicator({
|
||||
<span style={recordingDotStyle} />
|
||||
{t('qa.recordingHint')}
|
||||
</div>
|
||||
<LevelBar level={level ?? 0} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user