Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 387be14a2e fix(usage): keep the sidebar usage summary current while it is shown
The summary fetched reports once on mount and never again: no interval,
window-focus and reconnect refetches are off app-wide, and the daemon does
not push report changes. It only updated when the Usage modal or Settings
mounted the same query and refetched it. It now re-reads every minute while
visible; the daemon answers from its five-minute cache, so source APIs are
still hit at most once per cache window.
2026-11-06 04:11:48 +01:00
7 changed files with 35 additions and 94 deletions
@@ -469,3 +469,27 @@ test("visible usage summary requests reports on load", async ({ page }) => {
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
expect(usage.listRequests()).toHaveLength(1);
});
test("visible usage summary picks up changed reports without opening Usage", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, {
lists: [() => claudeSessionAt(31), () => claudeSessionAt(80)],
});
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await page.setViewportSize(WIDE);
await page.clock.install();
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
expect(usage.listRequests()).toHaveLength(1);
await page.clock.fastForward(61_000);
await expectPinnedUsage(page, ["80% 5h", "54% wk", "7% 5h", "12% wk"]);
await expect(usageModal(page)).toHaveCount(0);
});
function claudeSessionAt(usedPct: number) {
const reports = claudeAndCodexReports();
const claude = reports[0]!;
if (claude.report.status === "available") claude.report.windows[0]!.usedPct = usedPct;
return reports;
}
+3 -1
View File
@@ -166,7 +166,8 @@ const NO_REPORTS: UsageReportEntry[] = [];
/**
* The reports of the sidebar's usage host, which is connected and reports usage; none until they
* load, or without a host.
* load, or without a host. Re-read every minute while the summary is shown, since the daemon never
* pushes report changes.
*/
export function useUsageHostReports(serverId: string | null): UsageReportEntry[] {
const setting = useUsageSetting(serverId);
@@ -177,6 +178,7 @@ export function useUsageHostReports(serverId: string | null): UsageReportEntry[]
queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken,
dataShape: "list",
staleTimeMs: REPORTS_STALE_TIME_MS,
refetchInterval: REPORTS_STALE_TIME_MS,
});
return setting.enabled ? (query.data ?? NO_REPORTS) : NO_REPORTS;
}
@@ -33,9 +33,9 @@ async function main(): Promise<void> {
const apiKey = requireEnv("OPENAI_API_KEY");
const transcriptionModel = process.env.STT_MODEL ?? "gpt-4o-transcribe";
// Mirrors the daemon: no built-in prompt, only an explicit override. Whisper
// treats `prompt` as preceding transcript, so a default would bias the baseline.
const prompt = process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT?.trim() || undefined;
const prompt =
process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT ??
"Transcribe only what the speaker says. Do not add words. Preserve punctuation and casing. If the audio is silence or non-speech noise, return an empty transcript.";
const openai = new OpenAI({ apiKey });
@@ -43,7 +43,7 @@ async function main(): Promise<void> {
file: await import("node:fs").then((fs) => fs.createReadStream(audioPath)),
language: "en",
model: transcriptionModel,
...(prompt ? { prompt } : {}),
prompt,
response_format: "json",
// Aim for determinism.
temperature: 0,
@@ -57,13 +57,11 @@ class FakeRealtimeSession extends EventEmitter implements StreamingTranscription
class FakeSttProvider implements SpeechToTextProvider {
public readonly id = "fake";
public lastLanguage?: string;
public lastPrompt?: string;
constructor(private readonly session: FakeRealtimeSession) {}
createSession(
params: Parameters<SpeechToTextProvider["createSession"]>[0],
): StreamingTranscriptionSession {
this.lastLanguage = params.language;
this.lastPrompt = params.prompt;
return this.session;
}
}
@@ -596,58 +594,3 @@ it("closes every dictation stream when one provider cleanup fails", async () =>
expect(second.closed).toBe(true);
expect(manager.hasDemand).toBe(false);
});
describe("DictationStreamManager (transcription prompt)", () => {
const originalPromptEnv = process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT;
afterEach(() => {
if (originalPromptEnv === undefined) {
delete process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT;
} else {
process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT = originalPromptEnv;
}
});
const startDictation = async (
options: { language?: string } = {},
): Promise<{ provider: FakeSttProvider; manager: DictationStreamManager }> => {
const session = new FakeRealtimeSession();
const provider = new FakeSttProvider(session);
const manager = new DictationStreamManager({
logger: pino({ level: "silent" }),
emit: () => {},
sessionId: "prompt",
stt: provider,
...(options.language ? { language: options.language } : {}),
});
await manager.handleStart("d1", "audio/pcm;rate=24000;bits=16");
manager.cleanupAll();
return { provider, manager };
};
it("sends no prompt by default", async () => {
delete process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT;
const { provider } = await startDictation();
expect(provider.lastPrompt).toBeUndefined();
});
it("sends no prompt for non-English dictation by default", async () => {
delete process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT;
const { provider } = await startDictation({ language: "zh" });
expect(provider.lastLanguage).toBe("zh");
expect(provider.lastPrompt).toBeUndefined();
});
it("passes an explicit prompt override through", async () => {
process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT =
" Vocabulary: Paseo, Dokploy, Kubernetes. ";
const { provider } = await startDictation();
expect(provider.lastPrompt).toBe("Vocabulary: Paseo, Dokploy, Kubernetes.");
});
it.each(["", " "])("treats a blank override (%j) as no prompt", async (value) => {
process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT = value;
const { provider } = await startDictation();
expect(provider.lastPrompt).toBeUndefined();
});
});
@@ -38,12 +38,6 @@ function parseNonNegativeNumber(value: string | undefined): number | null {
return parsed;
}
/** Unset, empty, and whitespace-only overrides all mean "send no prompt". */
function readTranscriptionPromptOverride(env: NodeJS.ProcessEnv): string | undefined {
const value = env.PASEO_DICTATION_TRANSCRIPTION_PROMPT?.trim();
return value ? value : undefined;
}
function convertPCMToWavBuffer(
pcmBuffer: Buffer,
sampleRate: number,
@@ -191,16 +185,16 @@ export class DictationStreamManager {
return;
}
// Whisper uses the prompt as transcript context, not silence instructions.
// Keep context opt-in so it does not bias speech or introduce unspoken text.
const transcriptionPrompt = readTranscriptionPromptOverride(process.env);
const transcriptionPrompt =
process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT ??
"Transcribe only what the speaker says. Do not add words. Preserve punctuation and casing. If the audio is silence or non-speech noise, return an empty transcript.";
let stt: ReturnType<SpeechToTextProvider["createSession"]>;
try {
stt = sttProvider.createSession({
logger: this.logger.child({ dictationId }),
language: this.language,
...(transcriptionPrompt ? { prompt: transcriptionPrompt } : {}),
prompt: transcriptionPrompt,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
-11
View File
@@ -142,19 +142,8 @@ Keys and base URLs follow the fallback rule above.
- `PASEO_VOICE_LOCAL_STT_MODEL`, `PASEO_VOICE_LOCAL_TTS_MODEL`, local voice STT/TTS model IDs
- `PASEO_DICTATION_LANGUAGE`, dictation STT language
- `PASEO_VOICE_LANGUAGE`, voice mode STT language; falls back to `PASEO_DICTATION_LANGUAGE` when unset
- `PASEO_DICTATION_TRANSCRIPTION_PROMPT`, optional OpenAI dictation transcription prompt (including compatible servers). Unset by default; empty or whitespace-only is treated as unset
- `PASEO_VOICE_LOCAL_TTS_SPEAKER_ID`, `PASEO_VOICE_LOCAL_TTS_SPEED`, optional local voice TTS tuning
## Transcription Prompt
With the OpenAI provider, including compatible servers, Paseo sends no transcription prompt by default. Whisper-family models use `prompt` as transcript context, so instruction text can distort the transcript or bias its language. Omitting it does not prevent every hallucination on silence or noise.
For OpenAI dictation, set `PASEO_DICTATION_TRANSCRIPTION_PROMPT` to send a prompt explicitly, for example to bias toward project vocabulary. An empty or whitespace-only value means no prompt. Local providers ignore this setting, and voice mode never sends a prompt.
```bash
PASEO_DICTATION_TRANSCRIPTION_PROMPT="Vocabulary: Paseo, TypeScript."
```
## Operational Notes
Voice mode can launch and control agents. Treat voice prompts with the same care as direct agent instructions, especially when specifying working directories or destructive operations.
-11
View File
@@ -120,19 +120,8 @@ Paseo 在配置的 OpenAI base URL 下使用以下路径:
- `PASEO_VOICE_LOCAL_STT_MODEL`、`PASEO_VOICE_LOCAL_TTS_MODEL`:本地语音 STT/TTS 模型 ID
- `PASEO_DICTATION_LANGUAGE`:口述 STT 语言(仅限 OpenAI STT;本地 Parakeet 会忽略)
- `PASEO_VOICE_LANGUAGE`:语音模式 STT 语言;未设置时退回到 `PASEO_DICTATION_LANGUAGE`(仅限 OpenAI STT;本地 Parakeet 会忽略)
- `PASEO_DICTATION_TRANSCRIPTION_PROMPT`:可选的 OpenAI 口述转录提示词(包括兼容服务器)。默认不设置;空值或仅含空白字符的值视为未设置
- `PASEO_VOICE_LOCAL_TTS_SPEAKER_ID`、`PASEO_VOICE_LOCAL_TTS_SPEED`:可选的本地语音 TTS 调优
## 转录提示词 {#transcription-prompt}
使用 OpenAI Provider(包括兼容服务器)进行口述时,Paseo 默认不发送转录提示词。Whisper 系列模型把 `prompt` 当作转录上下文,因此指令文本可能使转录失真或影响输出语言。不发送提示词也无法消除静音或噪声中的所有幻觉。
对于 OpenAI 口述,可以设置 `PASEO_DICTATION_TRANSCRIPTION_PROMPT` 来显式发送提示词,例如提示项目术语。空值或仅含空白字符的值表示不发送提示词。本地 Provider 会忽略此设置,语音模式也不会发送提示词。
```bash
PASEO_DICTATION_TRANSCRIPTION_PROMPT="Vocabulary: Paseo, TypeScript."
```
## 使用须知 {#operational-notes}
语音模式可以启动和控制 Agent。请像对待直接的 Agent 指令一样谨慎对待语音提示词,尤其是在指定工作目录或执行破坏性操作时。