From dd79e94c2447eab4a3abeb03e68db86579ce89e3 Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Sun, 8 Nov 2026 22:11:03 +0800 Subject: [PATCH 01/22] fix(macos): recover live input before first write --- .../app/e2e/live-input-settings.spec.ts | 27 ++++++ .../app/src-tauri/src/core_adapters.rs | 89 +++++++++++++++++-- .../app/src-tauri/src/host_document/macos.rs | 15 ++++ openless-all/app/src/i18n/de.ts | 6 +- openless-all/app/src/i18n/en.ts | 6 +- openless-all/app/src/i18n/es.ts | 6 +- openless-all/app/src/i18n/fr.ts | 6 +- openless-all/app/src/i18n/ja.ts | 6 +- openless-all/app/src/i18n/ko.ts | 6 +- openless-all/app/src/i18n/zh-CN.ts | 6 +- openless-all/app/src/i18n/zh-TW.ts | 6 +- .../pages/settings/RecordingInputSection.tsx | 22 ----- .../app/src/pages/settings/navigation.ts | 7 ++ openless-all/app/src/pages/settings/tabs.tsx | 40 +++++++++ 14 files changed, 203 insertions(+), 45 deletions(-) create mode 100644 openless-all/app/e2e/live-input-settings.spec.ts diff --git a/openless-all/app/e2e/live-input-settings.spec.ts b/openless-all/app/e2e/live-input-settings.spec.ts new file mode 100644 index 000000000..17abeef9b --- /dev/null +++ b/openless-all/app/e2e/live-input-settings.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from '@playwright/test'; + +for (const [os, ua, platform] of [ + ['macOS', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)', 'MacIntel'], + ['Windows', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', 'Win32'], +] as const) { + test(`live transcript input has a dedicated advanced settings page on ${os}`, async ({ + page, + }) => { + await page.addInitScript( + ({ ua, platform }) => { + Object.defineProperty(navigator, 'userAgent', { value: ua }); + Object.defineProperty(navigator, 'platform', { value: platform }); + Object.defineProperty(navigator, 'userAgentData', { value: undefined }); + }, + { ua, platform }, + ); + await page.goto('/'); + await page.getByRole('button', { name: '设置', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: '设置', exact: true }); + await dialog.getByRole('button', { name: '高级功能', exact: true }).click(); + await dialog.locator('[data-ol-advanced-entry="liveInput"]').click(); + const panel = dialog.locator('[data-ol-advanced-page="liveInput"]'); + await expect(panel).toBeVisible(); + await expect(panel.getByText('实时原文显示在输入框', { exact: true })).toBeVisible(); + }); +} diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index ed357d2a2..dec9b817d 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -5004,10 +5004,10 @@ impl TauriTextInsertionSession { let live_final = final_text.clone(); let live_result = tauri::async_runtime::spawn_blocking(move || { let mut guard = live.lock(); - guard.take().map(|mut live| match live_final { - Some(text) => live.replace(&text), - None => live.cancel(), - }) + match live_final { + Some(text) => Some(replace_live_composition(&mut guard, &text)), + None => guard.take().map(|mut live| live.cancel()), + } }) .await .map_err(|e| BackendError::new(BackendErrorCode::Platform, e.to_string()))?; @@ -5067,6 +5067,41 @@ impl TauriTextInsertionSession { } } +#[cfg(target_os = "macos")] +fn replace_live_composition( + slot: &mut Option, + text: &str, +) -> Result<(), String> { + replace_unwritten_target( + slot, + text, + crate::host_document::LiveComposition::capture, + |live, value| live.replace(value), + |live| live.has_written(), + ) +} + +#[cfg(any(test, target_os = "macos"))] +fn replace_unwritten_target( + slot: &mut Option, + text: &str, + capture: impl FnOnce() -> Result, + mut replace: impl FnMut(&mut T, &str) -> Result<(), String>, + written: impl Fn(&T) -> bool, +) -> Result<(), String> { + if let Some(live) = slot.as_mut() { + match replace(live, text) { + Ok(()) => return Ok(()), + Err(error) if written(live) => return Err(error), + Err(_) => *slot = None, + } + } + let mut live = capture()?; + let result = replace(&mut live, text); + *slot = Some(live); + result +} + impl TextInsertionSession for TauriTextInsertionSession { fn supports_streaming(&self) -> bool { #[cfg(target_os = "macos")] @@ -5104,9 +5139,10 @@ impl TextInsertionSession for TauriTextInsertionSession { } tauri::async_runtime::spawn_blocking(move || { let mut live = session.live_composition.lock(); - live.as_mut() - .ok_or_else(|| "live input is unavailable in this editor".to_string())? - .replace(&text) + match replace_live_composition(&mut live, &text) { + Err(_) if live.as_ref().is_none_or(|target| !target.has_written()) => Ok(()), + result => result, + } }) .await .map_err(|e| BackendError::new(BackendErrorCode::Platform, e.to_string()))? @@ -5595,6 +5631,45 @@ fn map_tauri_error(error: impl std::fmt::Display) -> BackendError { #[cfg(test)] mod tests { + #[test] + fn live_input_retargets_only_before_any_write() { + #[derive(Debug)] + struct Target { written: bool, current: bool } + let mut slot = Some(Target { written: false, current: false }); + let capture = || Ok(Target { written: false, current: true }); + let replace = |target: &mut Target, _: &str| { + if !target.current { return Err("target changed".into()); } + target.written = true; + Ok(()) + }; + let written = |target: &Target| target.written; + super::replace_unwritten_target(&mut slot, "text", capture, replace, written).unwrap(); + assert!(slot.as_ref().unwrap().written); + slot.as_mut().unwrap().current = false; + let result = super::replace_unwritten_target( + &mut slot, + "more", + || panic!("a written target must never be replaced"), + replace, + written, + ); + assert_eq!(result.unwrap_err(), "target changed"); + } + + #[test] + fn live_input_keeps_target_when_write_outcome_is_uncertain() { + let mut slot = None; + let result = super::replace_unwritten_target( + &mut slot, + "text", + || Ok(false), + |written, _| { *written = true; Err("verification failed".into()) }, + |written| *written, + ); + assert_eq!(result.unwrap_err(), "verification failed"); + assert_eq!(slot, Some(true)); + } + use super::*; #[cfg(target_os = "windows")] diff --git a/openless-all/app/src-tauri/src/host_document/macos.rs b/openless-all/app/src-tauri/src/host_document/macos.rs index 62fb99fd8..e59151413 100644 --- a/openless-all/app/src-tauri/src/host_document/macos.rs +++ b/openless-all/app/src-tauri/src/host_document/macos.rs @@ -1669,8 +1669,23 @@ pub(crate) struct LiveComposition { } impl LiveComposition { + pub(crate) fn has_written(&self) -> bool { + self.wrote + } + pub(crate) fn capture() -> Result { + match crate::selection::current_front_app_pid() { + Some(pid) if pid != std::process::id() as i32 => {} + _ => return Err("liveInputTargetUnavailable".into()), + } let (_, _, target) = capture_voice_edit_target()?; + let mut owner_pid = 0; + if unsafe { AXUIElementGetPid(target.element.as_ref(), &mut owner_pid) } != AX_ERROR_SUCCESS + || owner_pid <= 0 + || owner_pid == std::process::id() as i32 + { + return Err("liveInputTargetUnavailable".into()); + } let start = utf16_offset_to_char_offset(&target.text, target.selection_utf16.location as usize); let end = utf16_offset_to_char_offset( diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index ab0b4695d..14580c741 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1208,6 +1208,7 @@ export const de: typeof zhCN = { phoneDesc: 'Ein Telefon oder ein anderes Gerät für die Ferneingabe verbinden.', }, advancedPages: { + liveInputTitle: 'Texteingabe', experiments: 'Experimentelle Funktionen und Erweiterungen konfigurieren.', voiceEnhancementTitle: 'Sprachverbesserung und Wiedergabeunterdrückung', cursorContextTitle: 'Cursorkontext und kurze Eingaben', @@ -2206,10 +2207,10 @@ export const de: typeof zhCN = { 'SendInput gibt Unicode direkt ein und umgeht TSF / IME. Ein Wechsel der Eingabemethode ist nicht erforderlich.', streamingInsertHintLinux: 'Verwendet das fcitx5-Plugin zur Textübermittlung. Laufendes Einfügen simuliert Tasten über enigo + XTest.', - macosCompositionInputLabel: 'Live-Transkript im Eingabefeld (macOS)', + macosCompositionInputLabel: 'Live-Transkript im Eingabefeld', macosCompositionInputDesc: 'Zeigt die Erkennung im ursprünglichen Eingabefeld und ersetzt sie anschließend durch den überarbeiteten oder übersetzten Text. Erfordert Bedienungshilfen und einen kompatiblen Editor. Cursorbewegungen oder manuelle Änderungen stoppen die Ersetzung.', - windowsCompositionInputLabel: 'Live-Transkript im Eingabefeld anzeigen (Windows)', + windowsCompositionInputLabel: 'Live-Transkript im Eingabefeld anzeigen', windowsCompositionInputDesc: 'Während du sprichst, erscheint der erkannte Text unterstrichen im Eingabefeld und wird beim Beenden durch das Endergebnis ersetzt. Erfordert die OpenLess-TSF-Eingabekomponente und eine App mit Unterstützung für TSF.', streamingInsertSaveClipboardLabel: 'In Zwischenablage kopieren', @@ -2428,6 +2429,7 @@ export const de: typeof zhCN = { backToAdvanced: 'Zurück zu erweiterten Funktionen', back: 'Zurück', advancedPages: { + liveInput: 'Live-Transkript im Eingabefeld konfigurieren.', experiments: 'Experimentelle Funktionen und Erweiterungen konfigurieren.', customSyncServer: 'Nur für selbst gehostete Dienste; die offizielle Synchronisierung benötigt keine Konfiguration.', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index ab16b95e2..16964cd3d 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1241,6 +1241,7 @@ export const en: typeof zhCN = { phoneDesc: 'Connect a phone or another device for remote input.', }, advancedPages: { + liveInputTitle: 'Text insertion', experiments: 'Configure experimental features and extensions.', voiceEnhancementTitle: 'Voice enhancement and playback suppression', cursorContextTitle: 'Cursor context and short-input behavior', @@ -2145,10 +2146,10 @@ export const en: typeof zhCN = { 'SendInput Unicode types directly, bypassing TSF / IME — no input-method switching needed.', streamingInsertHintLinux: 'Uses fcitx5 plugin for text submission; streaming insertion uses enigo + XTest for keystroke synthesis.', - macosCompositionInputLabel: 'Live transcript in the input field (macOS)', + macosCompositionInputLabel: 'Live transcript in the input field', macosCompositionInputDesc: 'Shows recognition in the captured editor, then replaces it with the final polished or translated text. Requires Accessibility and editor support. Moving the caret or editing stops replacement.', - windowsCompositionInputLabel: 'Show live transcript in the input box (Windows)', + windowsCompositionInputLabel: 'Show live transcript in the input box', windowsCompositionInputDesc: 'While you speak, the recognized text appears underlined in the input box and is replaced by the final result when you finish. Requires the OpenLess TSF input component and a target app that supports the Text Services Framework (TSF).', streamingInsertSaveClipboardLabel: 'Copy to clipboard', @@ -2362,6 +2363,7 @@ export const en: typeof zhCN = { backToAdvanced: 'Back to advanced features', back: 'Back', advancedPages: { + liveInput: 'Configure the live transcript in the input field.', experiments: 'Configure experimental features and extensions.', customSyncServer: 'For self-hosted services only; official sync needs no configuration.', localModels: experimentalAsr.description, diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 2b8fdac9b..cd85688b1 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1201,6 +1201,7 @@ export const es: typeof zhCN = { phoneDesc: 'Conecta un teléfono u otro dispositivo para la entrada remota.', }, advancedPages: { + liveInputTitle: 'Inserción de texto', experiments: 'Configura funciones experimentales y extensiones.', voiceEnhancementTitle: 'Mejora de voz y supresión de reproducción', cursorContextTitle: 'Contexto del cursor y frases cortas', @@ -2185,10 +2186,10 @@ export const es: typeof zhCN = { 'SendInput Unicode escribe directamente, sin pasar por TSF / IME y sin cambiar de método de entrada.', streamingInsertHintLinux: 'Usa el complemento fcitx5 para enviar texto; la inserción progresiva simula teclas mediante enigo + XTest.', - macosCompositionInputLabel: 'Transcripción en directo en el campo de entrada (macOS)', + macosCompositionInputLabel: 'Transcripción en directo en el campo de entrada', macosCompositionInputDesc: 'Muestra el reconocimiento en el campo original y después lo sustituye por el texto corregido o traducido. Requiere permiso de Accesibilidad y un editor compatible. Mover el cursor o editar el texto detiene la sustitución.', - windowsCompositionInputLabel: 'Mostrar la transcripción en vivo en el campo (Windows)', + windowsCompositionInputLabel: 'Mostrar la transcripción en vivo en el campo', windowsCompositionInputDesc: 'Mientras hablas, el texto reconocido aparece subrayado en el campo y se reemplaza por el resultado final al terminar. Requiere el componente de entrada TSF de OpenLess y una aplicación compatible con TSF.', streamingInsertSaveClipboardLabel: 'Copiar al portapapeles', @@ -2407,6 +2408,7 @@ export const es: typeof zhCN = { backToAdvanced: 'Volver a funciones avanzadas', back: 'Atrás', advancedPages: { + liveInput: 'Configurar la transcripción en directo en el campo.', experiments: 'Configura funciones experimentales y extensiones.', customSyncServer: 'Solo para servicios propios; la sincronización oficial no requiere configuración.', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index e3e8a859b..c3ca9ecfb 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1218,6 +1218,7 @@ export const fr: typeof zhCN = { phoneDesc: 'Connecter un téléphone ou un autre appareil pour la saisie à distance.', }, advancedPages: { + liveInputTitle: 'Insertion de texte', experiments: 'Configurer les fonctions expérimentales et les extensions.', voiceEnhancementTitle: 'Amélioration vocale et suppression de lecture', cursorContextTitle: 'Contexte du curseur et phrases courtes', @@ -2212,10 +2213,10 @@ export const fr: typeof zhCN = { 'SendInput Unicode écrit directement, sans passer par TSF / IME et sans changer de méthode de saisie.', streamingInsertHintLinux: 'Utilise le complément fcitx5 pour envoyer le texte ; l’insertion progressive simule les touches avec enigo + XTest.', - macosCompositionInputLabel: 'Transcription en direct dans le champ de saisie (macOS)', + macosCompositionInputLabel: 'Transcription en direct dans le champ de saisie', macosCompositionInputDesc: 'Affiche la transcription dans le champ initial, puis la remplace par le résultat corrigé ou traduit. Nécessite les autorisations d’accessibilité et un éditeur compatible. Déplacer le curseur ou modifier le texte arrête le remplacement.', - windowsCompositionInputLabel: 'Afficher la transcription en direct dans le champ (Windows)', + windowsCompositionInputLabel: 'Afficher la transcription en direct dans le champ', windowsCompositionInputDesc: "Pendant la dictée, le texte reconnu apparaît souligné dans le champ puis est remplacé par le résultat final. Nécessite le composant de saisie TSF d'OpenLess et une application compatible avec TSF.", streamingInsertSaveClipboardLabel: 'Copier dans le presse-papiers', @@ -2437,6 +2438,7 @@ export const fr: typeof zhCN = { backToAdvanced: 'Retour aux fonctions avancées', back: 'Retour', advancedPages: { + liveInput: 'Configurer la transcription en direct dans le champ.', experiments: 'Configurer les fonctions expérimentales et les extensions.', customSyncServer: 'Pour les services auto-hébergés uniquement ; la synchronisation officielle ne nécessite aucune configuration.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 54aac4abb..19aaa1a1f 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1172,6 +1172,7 @@ export const ja: typeof zhCN = { phoneDesc: 'スマホや別の端末を接続してリモート入力します。', }, advancedPages: { + liveInputTitle: '文字入力', experiments: '実験的な機能と拡張オプションを設定します。', voiceEnhancementTitle: '音声強調と再生抑制', cursorContextTitle: 'カーソル文脈と短文の動作', @@ -2115,10 +2116,10 @@ export const ja: typeof zhCN = { 'SendInput Unicode で TSF / IME を迂回。入力ソースの切替は不要です。', streamingInsertHintLinux: 'fcitx5 プラグインで文字を送信。ストリーミング入力は enigo + XTest でキー合成。', - macosCompositionInputLabel: '入力欄に認識中の原文を表示(macOS)', + macosCompositionInputLabel: '入力欄に認識中の原文を表示', macosCompositionInputDesc: '録音中の認識結果を元の入力欄に表示し、終了後に整形または翻訳した文章へ置き換えます。アクセシビリティ権限と対応エディタが必要です。カーソル移動や手動編集で置き換えを停止します。', - windowsCompositionInputLabel: 'リアルタイム原文を入力欄に表示(Windows)', + windowsCompositionInputLabel: 'リアルタイム原文を入力欄に表示', windowsCompositionInputDesc: '録音中、認識された文字を下線付きの変換中テキストとして入力欄に表示し、終了時に最終結果に置き換えます。OpenLess の TSF 入力コンポーネントと、テキストサービスフレームワーク(TSF)に対応したアプリが必要です。', streamingInsertSaveClipboardLabel: 'クリップボードに保存', @@ -2330,6 +2331,7 @@ export const ja: typeof zhCN = { backToAdvanced: '高度な機能に戻る', back: '戻る', advancedPages: { + liveInput: '入力欄に表示するリアルタイム原文を設定します。', experiments: '実験的な機能と拡張オプションを設定します。', customSyncServer: 'セルフホスト専用です。公式の同期には設定は不要です。', localModels: experimentalAsr.description, diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index f7d5555a6..5cbb12426 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1165,6 +1165,7 @@ export const ko: typeof zhCN = { phoneDesc: '휴대폰이나 다른 기기를 연결해 원격으로 입력합니다.', }, advancedPages: { + liveInputTitle: '텍스트 삽입', experiments: '실험 기능과 확장 옵션을 설정합니다.', voiceEnhancementTitle: '음성 향상과 재생 억제', cursorContextTitle: '커서 문맥과 짧은 입력', @@ -2101,10 +2102,10 @@ export const ko: typeof zhCN = { streamingInsertHintWindows: 'SendInput Unicode 로 TSF / IME 를 우회. 입력 소스 전환 불필요.', streamingInsertHintLinux: 'fcitx5 플러그인으로 텍스트 전송. 스트리밍 입력은 enigo + XTest 키 합성 사용.', - macosCompositionInputLabel: '입력란에 실시간 원문 표시(macOS)', + macosCompositionInputLabel: '입력란에 실시간 원문 표시', macosCompositionInputDesc: '녹음 중 인식한 원문을 원래 입력란에 표시하고 완료 후 정리하거나 번역한 결과로 바꿉니다. 손쉬운 사용 권한과 지원되는 편집기가 필요합니다. 커서를 이동하거나 직접 편집하면 실시간 교체를 중지합니다.', - windowsCompositionInputLabel: '실시간 원문을 입력창에 표시 (Windows)', + windowsCompositionInputLabel: '실시간 원문을 입력창에 표시', windowsCompositionInputDesc: '녹음 중 인식된 텍스트를 밑줄이 그어진 조합 텍스트로 입력창에 표시하고, 완료되면 최종 결과로 바꿉니다. OpenLess TSF 입력 구성 요소와 TSF를 지원하는 앱이 필요합니다.', streamingInsertSaveClipboardLabel: '클립보드에 저장', @@ -2314,6 +2315,7 @@ export const ko: typeof zhCN = { backToAdvanced: '고급 기능으로 돌아가기', back: '뒤로', advancedPages: { + liveInput: '입력란의 실시간 원문 표시를 설정합니다.', experiments: '실험 기능과 확장 옵션을 설정합니다.', customSyncServer: '자체 호스팅 서비스 전용입니다. 공식 동기화는 설정이 필요하지 않습니다.', localModels: experimentalAsr.description, diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index f5c02d176..357edf098 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1190,6 +1190,7 @@ export const zhCN = { phoneDesc: '连接手机或其他设备进行远程输入。', }, advancedPages: { + liveInputTitle: '文字插入', experiments: '配置实验功能与扩展选项。', voiceEnhancementTitle: '语音增强与播放抑制', cursorContextTitle: '光标上下文与短句行为', @@ -2020,10 +2021,10 @@ export const zhCN = { streamingInsertHintMac: '临时切到 ABC 输入源,避免 CJK IME 拦截,会话结束后自动切回。', streamingInsertHintWindows: 'SendInput Unicode 直接送字符,绕过 TSF / IME,不切输入法。', streamingInsertHintLinux: '通过 fcitx5 插件提交文字;流式输入使用 enigo + XTest 合成按键。', - macosCompositionInputLabel: '实时原文显示在输入框(macOS)', + macosCompositionInputLabel: '实时原文显示在输入框', macosCompositionInputDesc: '录音时在原输入框显示识别文字,结束后整体替换为整理或翻译结果。需要辅助功能权限及编辑器支持;移动光标或编辑原文会停止实时替换。', - windowsCompositionInputLabel: '实时原文显示在输入框(Windows)', + windowsCompositionInputLabel: '实时原文显示在输入框', windowsCompositionInputDesc: '录音时把识别到的文字以带下划线的组合文本显示在输入框中,结束后替换为最终结果。需要启用 OpenLess 的 TSF 输入法组件,且目标应用支持文本服务框架(TSF)。', streamingInsertSaveClipboardLabel: '同步到剪贴板', @@ -2228,6 +2229,7 @@ export const zhCN = { backToAdvanced: '返回高级功能', back: '返回', advancedPages: { + liveInput: '配置实时原文在输入框中的显示。', experiments: '配置实验功能与扩展选项。', customSyncServer: '仅供自建服务使用;默认使用官方同步,无需配置。', localModels: experimentalAsr.description, diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 5ae126bc9..352e672c1 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1135,6 +1135,7 @@ export const zhTW: typeof zhCN = { phoneDesc: '連接手機或其他裝置進行遠端輸入。', }, advancedPages: { + liveInputTitle: '文字插入', experiments: '設定實驗功能與擴充選項。', voiceEnhancementTitle: '語音增強與播放抑制', cursorContextTitle: '游標上下文與短句行為', @@ -2021,10 +2022,10 @@ export const zhTW: typeof zhCN = { streamingInsertHintMac: '臨時切到 ABC 輸入源,避免 CJK IME 攔截,會話結束後自動切回。', streamingInsertHintWindows: 'SendInput Unicode 直接送字元,繞過 TSF / IME,不切輸入法。', streamingInsertHintLinux: '透過 fcitx5 外掛提交文字;串流輸入使用 enigo + XTest 合成按鍵。', - macosCompositionInputLabel: '即時原文顯示在輸入框(macOS)', + macosCompositionInputLabel: '即時原文顯示在輸入框', macosCompositionInputDesc: '錄音時在原輸入框顯示辨識文字,完成後整段取代為整理或翻譯結果。需要輔助使用授權及編輯器支援;移動游標或手動編輯會停止即時取代。', - windowsCompositionInputLabel: '即時原文顯示在輸入框(Windows)', + windowsCompositionInputLabel: '即時原文顯示在輸入框', windowsCompositionInputDesc: '錄音時把辨識到的文字以帶底線的組合文字顯示在輸入框中,結束後替換為最終結果。需要啟用 OpenLess 的 TSF 輸入法元件,且目標應用支援文字服務框架(TSF)。', streamingInsertSaveClipboardLabel: '同步到剪貼簿', @@ -2228,6 +2229,7 @@ export const zhTW: typeof zhCN = { backToAdvanced: '返回進階功能', back: '返回', advancedPages: { + liveInput: '設定即時原文在輸入框中的顯示。', experiments: '設定實驗功能與擴充選項。', customSyncServer: '僅供自建服務使用;預設使用官方同步,無須設定。', localModels: experimentalAsr.description, diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index f866cc2fc..a4260d22f 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -900,28 +900,6 @@ export function RecordingInputSection({ onToggle={(next) => void savePrefs({ ...prefs, streamingInsertSaveClipboard: next })} /> - {capability.adapter === 'macEventTap' && ( - - void savePrefs({ ...prefs, macosCompositionInput: next })} - /> - - )} - {capability.adapter === 'windowsLowLevel' && prefs.windowsInsertionMode === 'tsf' && ( - - void savePrefs({ ...prefs, windowsCompositionInput: next })} - /> - - )} )} {surface === 'text' && showDesktopStartup && ( diff --git a/openless-all/app/src/pages/settings/navigation.ts b/openless-all/app/src/pages/settings/navigation.ts index 525fc4adb..3d879e0e5 100644 --- a/openless-all/app/src/pages/settings/navigation.ts +++ b/openless-all/app/src/pages/settings/navigation.ts @@ -20,6 +20,12 @@ export const ADVANCED_PAGES = [ titleKey: 'settings.advancedPages.voiceEnhancementTitle', beta: true, }, + { + id: 'liveInput', + icon: 'keyboard', + titleKey: 'settings.advancedPages.liveInputTitle', + beta: true, + }, { id: 'cursorContext', icon: 'search', @@ -46,6 +52,7 @@ export type AdvancedPageId = AdvancedPage['id']; export function visibleAdvancedPages(platform: PlatformKind | undefined, os: OS): AdvancedPage[] { return ADVANCED_PAGES.filter((page) => { + if (page.id === 'liveInput') return platform === 'desktop' && (os === 'mac' || os === 'win'); if (page.id === 'localModels') return platform === 'desktop' && os === 'mac'; if (page.id === 'vocabularyLearning') return platform === 'desktop' || platform === 'android'; if (page.id === 'debug') diff --git a/openless-all/app/src/pages/settings/tabs.tsx b/openless-all/app/src/pages/settings/tabs.tsx index 63e33c700..d0857ee0b 100644 --- a/openless-all/app/src/pages/settings/tabs.tsx +++ b/openless-all/app/src/pages/settings/tabs.tsx @@ -617,6 +617,45 @@ function CursorContextSection() { ); } +function LiveInputSection() { + const { t } = useTranslation(); + const { prefs, updatePrefs } = useHotkeySettings(); + const os = detectOS(); + if (!prefs || (os !== 'mac' && os !== 'win')) return null; + const mac = os === 'mac'; + return ( + + + {t('settings.advancedPages.liveInputTitle')} + + + + void updatePrefs((current) => + mac + ? { ...current, macosCompositionInput: next } + : { ...current, windowsCompositionInput: next }, + ) + } + /> + + + ); +} + // Privacy: local-first explanation + permission management · data storage. export function PrivacyTab() { const { t } = useTranslation(); @@ -705,6 +744,7 @@ export function AdvancedTab({ )} {item.id === 'voiceEnhancement' && } + {item.id === 'liveInput' && } {item.id === 'cursorContext' && } {item.id === 'vocabularyLearning' && } {item.id === 'startupDiagnostics' && } From 3725b445a772611dcfd2a4bb105228d801e50983 Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Sun, 8 Nov 2026 22:46:39 +0800 Subject: [PATCH 02/22] fix(macos): retain target after uncertain AX write --- openless-all/app/src-tauri/src/host_document/macos.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/host_document/macos.rs b/openless-all/app/src-tauri/src/host_document/macos.rs index e59151413..8a2e2ad85 100644 --- a/openless-all/app/src-tauri/src/host_document/macos.rs +++ b/openless-all/app/src-tauri/src/host_document/macos.rs @@ -1750,13 +1750,14 @@ impl LiveComposition { K_CF_STRING_ENCODING_UTF8, ); let attr = cfstring_from_static(b"AXSelectedText\0").ok_or("liveInputUnavailable")?; + // A failed AX call can have an unknown outcome; never retarget after submission. + self.wrote = true; let result = AXUIElementSetAttributeValue(self.target.element.as_ref(), attr, value); CFRelease(attr); CFRelease(value); if result != AX_ERROR_SUCCESS { return Err("liveInputUnsupportedEditor".into()); } - self.wrote = true; let expected = format!("{start}{text}{suffix}"); if voice_edit_text(self.target.element.as_ref()).as_deref() != Some(&expected) { return Err("liveInputEditorTransformedText".into()); From 835afa385f32b11f696451a7f55a874f19efe607 Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Sun, 8 Nov 2026 22:58:15 +0800 Subject: [PATCH 03/22] test(macos): verify late live-input capture in native editor --- .../app/src-tauri/src/core_adapters.rs | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index dec9b817d..ba32b1b54 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -5634,11 +5634,22 @@ mod tests { #[test] fn live_input_retargets_only_before_any_write() { #[derive(Debug)] - struct Target { written: bool, current: bool } - let mut slot = Some(Target { written: false, current: false }); - let capture = || Ok(Target { written: false, current: true }); + struct Target { + written: bool, + current: bool, + } + let mut slot = Some(Target { + written: false, + current: false, + }); + let capture = || Ok(Target { + written: false, + current: true, + }); let replace = |target: &mut Target, _: &str| { - if !target.current { return Err("target changed".into()); } + if !target.current { + return Err("target changed".into()); + } target.written = true; Ok(()) }; @@ -5663,13 +5674,29 @@ mod tests { &mut slot, "text", || Ok(false), - |written, _| { *written = true; Err("verification failed".into()) }, + |written, _| { + *written = true; + Err("verification failed".into()) + }, |written| *written, ); assert_eq!(result.unwrap_err(), "verification failed"); assert_eq!(slot, Some(true)); } + #[cfg(target_os = "macos")] + #[test] + #[ignore = "requires the foreground disposable native-live-input-fixture app"] + fn live_input_captures_late_focused_native_editor() { + let (_, bundle) = crate::selection::current_front_app_parts(); + assert_eq!(bundle.as_deref(), Some("org.openless.live-input-fixture")); + let mut slot = None; + super::replace_live_composition(&mut slot, "实时原文").unwrap(); + let mut live = slot.expect("captured live editor"); + assert!(live.has_written()); + live.cancel().unwrap(); + } + use super::*; #[cfg(target_os = "windows")] From bcc7ad128d7ae54e5106e087de6a14e5b9bcfd94 Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Sun, 8 Nov 2026 22:25:59 +0800 Subject: [PATCH 04/22] fix(capsule): stabilize translation language popup --- openless-all/app/e2e/ask-anything.spec.ts | 8 ++++++ .../app/src/components/CapsuleStyles.css | 6 +++++ .../components/CapsuleTranslationBadge.tsx | 25 +++++++++++++------ openless-all/app/src/components/ui/Select.tsx | 7 +++++- .../app/src/components/ui/SelectLite.tsx | 11 +++++++- 5 files changed, 47 insertions(+), 10 deletions(-) diff --git a/openless-all/app/e2e/ask-anything.spec.ts b/openless-all/app/e2e/ask-anything.spec.ts index ff59e264b..b8a7666a2 100644 --- a/openless-all/app/e2e/ask-anything.spec.ts +++ b/openless-all/app/e2e/ask-anything.spec.ts @@ -19,6 +19,14 @@ test('translation uses the shared popup and native headroom; save failure preser await picker.click(); const popup = page.locator('.ol-select-popup'); await expect(popup).toBeVisible(); + await expect(popup).toHaveClass(/ol-capsule-language-popup/); + await expect(popup).toHaveCSS('backdrop-filter', 'none'); + await popup.evaluate((node) => node.setAttribute('data-test-retained', 'true')); + for (let i = 0; i < 8; i += 1) { + await page.evaluate((level) => (window as any).fixture.setCapsule({ level }), i / 10); + await expect(popup).toHaveAttribute('data-test-retained', 'true'); + await expect(popup).toHaveCSS('opacity', '1'); + } const clipped = await popup.evaluate((node) => getComputedStyle(node).clipPath.startsWith('shape('), ); diff --git a/openless-all/app/src/components/CapsuleStyles.css b/openless-all/app/src/components/CapsuleStyles.css index 7e1db9b47..e0d7937ce 100644 --- a/openless-all/app/src/components/CapsuleStyles.css +++ b/openless-all/app/src/components/CapsuleStyles.css @@ -105,6 +105,12 @@ pointer-events: auto; max-width: 174px; } +.ol-select-popup.ol-capsule-language-popup { + background: var(--ol-select-popover-bg); + backdrop-filter: none; + -webkit-backdrop-filter: none; + transition: none; +} .ol-translation-language .ol-select-trigger { height: 22px; min-width: 92px; diff --git a/openless-all/app/src/components/CapsuleTranslationBadge.tsx b/openless-all/app/src/components/CapsuleTranslationBadge.tsx index 74d7d13f6..ac8093712 100644 --- a/openless-all/app/src/components/CapsuleTranslationBadge.tsx +++ b/openless-all/app/src/components/CapsuleTranslationBadge.tsx @@ -25,6 +25,7 @@ export function CapsuleTranslationBadge({ visible = true }: { visible?: boolean const [menuOpen, setMenuOpen] = useState(false); const menuEpoch = useRef(0); const menuOwner = useRef(null); + const opening = useRef(false); const closeTimer = useRef>(); const shown = useRef(visible); shown.current = visible; @@ -96,12 +97,19 @@ export function CapsuleTranslationBadge({ visible = true }: { visible?: boolean [i18n.language, target], ); const selected = languages.find((language) => language.nativeName === target); - const options = languages.filter( - (language) => - language.nativeName === target || - !isTranslationTargetRedundant(language.nativeName, snapshot?.workingLanguages ?? []), + const options = useMemo( + () => + languages + .filter( + (language) => + language.nativeName === target || + !isTranslationTargetRedundant(language.nativeName, snapshot?.workingLanguages ?? []), + ) + .map((language) => ({ value: language.nativeName, label: language.displayName })), + [languages, target, snapshot?.workingLanguages], ); const openMenu = async (next: boolean) => { + if (next && opening.current) return; const epoch = ++menuEpoch.current; if (closeTimer.current) clearTimeout(closeTimer.current); if (!next) { @@ -122,6 +130,7 @@ export function CapsuleTranslationBadge({ visible = true }: { visible?: boolean if (!shown.current || !active?.mutable) return; pickerSession.current = active.sessionId; menuOwner.current = active.sessionId; + opening.current = true; try { await setCapsuleLanguageMenu(true, active.sessionId); await new Promise((resolve) => @@ -139,6 +148,8 @@ export function CapsuleTranslationBadge({ visible = true }: { visible?: boolean } } catch { if (epoch === menuEpoch.current) setError(true); + } finally { + opening.current = false; } }; useEffect(() => { @@ -207,10 +218,7 @@ export function CapsuleTranslationBadge({ visible = true }: { visible?: boolean
({ - value: language.nativeName, - label: language.displayName, - }))} + options={options} placeholder={selected?.displayName || target || '…'} ariaLabel={t('translation.target.title')} disabled={!visible || busy || !snapshot?.mutable} @@ -219,6 +227,7 @@ export function CapsuleTranslationBadge({ visible = true }: { visible?: boolean onChange={(value) => void change(value)} side="top" popupStyle={{ width: 240 }} + popupClassName="ol-capsule-language-popup" />
{error && ( diff --git a/openless-all/app/src/components/ui/Select.tsx b/openless-all/app/src/components/ui/Select.tsx index 6430d6aa0..c42e364d5 100644 --- a/openless-all/app/src/components/ui/Select.tsx +++ b/openless-all/app/src/components/ui/Select.tsx @@ -14,6 +14,7 @@ interface SelectProps { open?: boolean; side?: 'top' | 'bottom'; popupStyle?: CSSProperties; + popupClassName?: string; } /** Standard Base UI Select; keyboard navigation, typeahead and focus return stay upstream. */ @@ -29,6 +30,7 @@ export function Select({ open, side = 'bottom', popupStyle, + popupClassName, }: SelectProps) { return ( - + {options.map((option) => ( @@ -104,6 +107,7 @@ export function SelectLite({ open: controlledOpen, side, popupStyle, + popupClassName, }} onOpenChange={handleOpenChange} /> @@ -124,6 +128,7 @@ function SearchableSelect({ open, side = 'bottom', popupStyle, + popupClassName, }: SelectLiteProps) { const [query, setQuery] = useState(''); const inputRef = useRef(null); @@ -181,7 +186,11 @@ function SearchableSelect({ sideOffset={8} collisionPadding={8} > - +
Date: Sun, 8 Nov 2026 22:49:01 +0800 Subject: [PATCH 05/22] test(ui): await corrections page before geometry checks --- openless-all/app/e2e/control-geometry-regressions.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openless-all/app/e2e/control-geometry-regressions.spec.ts b/openless-all/app/e2e/control-geometry-regressions.spec.ts index 1ab9af29f..49e5ce18c 100644 --- a/openless-all/app/e2e/control-geometry-regressions.spec.ts +++ b/openless-all/app/e2e/control-geometry-regressions.spec.ts @@ -138,6 +138,11 @@ test('history list keeps its rounded surface and native trackpad scrolling; corr await page.screenshot({ path: test.info().outputPath('history-list.png') }); await page.getByRole('button', { name: '工具', exact: true }).click(); await page.getByRole('button', { name: '纠正规则', exact: true }).click(); + await expect(page.locator('[data-ol-page="corrections"]')).toHaveAttribute( + 'data-transition-phase', + 'settled', + { timeout: 15_000 }, + ); await settleNavigation(page); const add = page.getByRole('button', { name: '添加', exact: true }); await expect(add).toBeVisible(); From 46f9c1d6f11f86f66ee2dfbf25e0718052228b7a Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Sun, 8 Nov 2026 22:50:12 +0800 Subject: [PATCH 06/22] test(ui): wait for tools navigation to expand --- .../app/e2e/control-geometry-regressions.spec.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/openless-all/app/e2e/control-geometry-regressions.spec.ts b/openless-all/app/e2e/control-geometry-regressions.spec.ts index 49e5ce18c..f3ba12a22 100644 --- a/openless-all/app/e2e/control-geometry-regressions.spec.ts +++ b/openless-all/app/e2e/control-geometry-regressions.spec.ts @@ -136,8 +136,18 @@ test('history list keeps its rounded surface and native trackpad scrolling; corr }); expect(wheelPrevented).toBe(false); await page.screenshot({ path: test.info().outputPath('history-list.png') }); - await page.getByRole('button', { name: '工具', exact: true }).click(); - await page.getByRole('button', { name: '纠正规则', exact: true }).click(); + const tools = page.getByRole('button', { name: '工具', exact: true }); + await tools.click(); + await tools.evaluate(async (node) => { + await Promise.all( + node + .parentElement!.getAnimations({ subtree: true }) + .map((animation) => animation.finished.catch(() => {})), + ); + }); + const corrections = page.getByRole('button', { name: '纠正规则', exact: true }); + await corrections.click(); + await expect(corrections).toHaveAttribute('aria-current', 'page'); await expect(page.locator('[data-ol-page="corrections"]')).toHaveAttribute( 'data-transition-phase', 'settled', From 3f62739852a8c3e4ece23c5a6ed7059a5bef3159 Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Sun, 8 Nov 2026 21:03:44 +0800 Subject: [PATCH 07/22] fix(desktop): align mode selector and restore action corners --- openless-all/app/e2e/motion.spec.ts | 33 ++++++++++++++++++++++++ openless-all/app/e2e/refinements.spec.ts | 14 ++++++++++ openless-all/app/src/pages/Vocab.tsx | 2 +- openless-all/app/src/pages/_atoms.tsx | 2 +- openless-all/app/src/styles/global.css | 12 +++++++++ 5 files changed, 61 insertions(+), 2 deletions(-) diff --git a/openless-all/app/e2e/motion.spec.ts b/openless-all/app/e2e/motion.spec.ts index e8d81d9c8..6dc232fd8 100644 --- a/openless-all/app/e2e/motion.spec.ts +++ b/openless-all/app/e2e/motion.spec.ts @@ -331,6 +331,39 @@ test('multimodal settings fit one desktop page and keep inactive notices below l }); }); +test('pipeline selection stays evenly inset in both modes', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.getByRole('button', { name: '设置', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: '设置', exact: true }); + await dialog.getByRole('button', { name: 'AI 与模型', exact: true }).click(); + const track = dialog.locator('.ol-service-pipeline-track'); + for (const name of ['传统模式', '多模态模式']) { + await dialog.getByRole('button', { name, exact: true }).click(); + await expect + .poll(() => + track.evaluate((element, selectedName) => { + const outer = element.getBoundingClientRect(); + const selected = element.querySelector('button[aria-pressed="true"]')!; + const chip = element.querySelector('.ol-segmented-indicator')!; + const item = selected.getBoundingClientRect(); + const inner = chip.getBoundingClientRect(); + return Math.max( + Math.abs(inner.top - item.top), + Math.abs(inner.left - item.left), + Math.abs(inner.width - item.width), + Math.abs(inner.height - item.height), + Math.abs(inner.top - outer.top - (outer.bottom - inner.bottom)), + Math.abs( + (selectedName === '传统模式' ? inner.left - outer.left : outer.right - inner.right) - + (inner.top - outer.top), + ), + ); + }, name), + ) + .toBeLessThan(0.75); + } +}); + test('selection highlights move and settle correctly after a rapid mode return', async ({ page, }) => { diff --git a/openless-all/app/e2e/refinements.spec.ts b/openless-all/app/e2e/refinements.spec.ts index fd85274c4..9e48e7236 100644 --- a/openless-all/app/e2e/refinements.spec.ts +++ b/openless-all/app/e2e/refinements.spec.ts @@ -2,6 +2,20 @@ import { openMicrophoneSettings, settleNavigation } from './support/navigation'; import { test, expect, type Page } from '@playwright/test'; const fixture = 'http://127.0.0.1:5326/e2e/fixtures/refinements.html'; + +test('style and dictionary action buttons retain compact corners', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: '风格', exact: true }).click(); + await page.getByRole('button', { name: '润色模式', exact: true }).click(); + await expect(page.getByRole('button', { name: '导入 ZIP' })).toHaveCSS('border-radius', '8px'); + await expect(page.getByRole('button', { name: '激活', exact: true }).first()).toHaveCSS( + 'border-radius', + '8px', + ); + await page.getByRole('button', { name: '词典', exact: true }).click(); + await expect(page.locator('.ol-action-button').first()).toHaveCSS('border-radius', '8px'); +}); + async function openShortcuts(page: Page) { await page.goto('/'); await page.getByRole('button', { name: '设置', exact: true }).click(); diff --git a/openless-all/app/src/pages/Vocab.tsx b/openless-all/app/src/pages/Vocab.tsx index efb29e8f4..bda6c5731 100644 --- a/openless-all/app/src/pages/Vocab.tsx +++ b/openless-all/app/src/pages/Vocab.tsx @@ -610,7 +610,7 @@ export function Vocab() { icon={wordAdded ? 'check' : 'plus'} onClick={() => void addNewWord()} style={{ - borderRadius: 'var(--ol-radius)', + borderRadius: 8, height: 38, minWidth: 92, justifyContent: 'center', diff --git a/openless-all/app/src/pages/_atoms.tsx b/openless-all/app/src/pages/_atoms.tsx index 21634806a..6844767d4 100644 --- a/openless-all/app/src/pages/_atoms.tsx +++ b/openless-all/app/src/pages/_atoms.tsx @@ -242,7 +242,7 @@ export function Btn({ const muted = disabled && (variant === 'primary' || variant === 'blue'); return ( +

+ {t('settings.expressionLearning.clearHint')} +

+ {error &&

{t('settings.expressionLearning.error')}

} + + ); +} diff --git a/openless-all/app/src/pages/settings/tabs.tsx b/openless-all/app/src/pages/settings/tabs.tsx index 5ad411456..7d85322ab 100644 --- a/openless-all/app/src/pages/settings/tabs.tsx +++ b/openless-all/app/src/pages/settings/tabs.tsx @@ -28,6 +28,7 @@ import { LocalModelsSection } from './models/LocalModelsSection'; import { LocalModelsNavContext } from './models/modelsNav'; import { DebugToolsSection } from './DebugToolsSection'; import { VocabularyLearningSection } from './VocabularyLearningSection'; +import { ExpressionLearningSection } from './ExpressionLearningSection'; import { AudioDspSettings } from './AudioDspSettings'; import { BetaChannelSection } from './BetaChannelSection'; import { AutoUpdateSection } from './AutoUpdateSection'; @@ -753,6 +754,8 @@ export function AdvancedTab({ {showsCursorContextControl(platformCaps?.platform, detectOS()) && ( )} + {platformCaps?.platform === 'desktop' && + (detectOS() === 'mac' || detectOS() === 'win') && } )} {item.id === 'startupDiagnostics' && } diff --git a/openless-all/app/src/styles/refinements.css b/openless-all/app/src/styles/refinements.css index 974c39cc3..ff0c067ec 100644 --- a/openless-all/app/src/styles/refinements.css +++ b/openless-all/app/src/styles/refinements.css @@ -163,6 +163,9 @@ button:disabled { outline-offset: 1px; box-shadow: inset 0 0 0 1px var(--ol-blue); } +.ol-expression-clear { + --ol-corner-radius: 8px; +} .ol-shortcuts-toolbar { display: flex; gap: 12px; From 5301a6d90f32ea3c153f3371a455ff5af89ba21d Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Sun, 8 Nov 2026 23:51:06 +0800 Subject: [PATCH 10/22] test(harmony): update desktop expression IPC audit --- openless-all/app/harmony/ipc-audit.json | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/openless-all/app/harmony/ipc-audit.json b/openless-all/app/harmony/ipc-audit.json index 6d3af1a0a..fc59d8a6d 100644 --- a/openless-all/app/harmony/ipc-audit.json +++ b/openless-all/app/harmony/ipc-audit.json @@ -1,8 +1,8 @@ { "scope": "Static shared IPC command coverage; excludes event subscriptions, dynamic commands and desktop-only calls outside src/lib/ipc.", - "total": 183, + "total": 185, "wired": 103, - "missing": 80, + "missing": 82, "commands": [ { "name": "accept_pending_correction", @@ -109,6 +109,13 @@ ], "implementation": "arkts" }, + { + "name": "clear_expression_profile", + "calledFrom": [ + "src/lib/ipc/expression.ts" + ], + "implementation": "unimplemented" + }, { "name": "clear_history", "calledFrom": [ @@ -438,6 +445,13 @@ ], "implementation": "unimplemented" }, + { + "name": "get_expression_profile", + "calledFrom": [ + "src/lib/ipc/expression.ts" + ], + "implementation": "unimplemented" + }, { "name": "get_hotkey_capability", "calledFrom": [ From d263501d823b26ea38cd602961a3713af92fc70c Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Mon, 9 Nov 2026 00:02:56 +0800 Subject: [PATCH 11/22] fix(core): satisfy cross-platform expression checks --- openless-all/app/crates/openless-core/src/api.rs | 13 +++++++++++-- .../openless-core/src/expression_observation.rs | 3 ++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index e39cbac6d..1af8a63f5 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -7448,7 +7448,7 @@ impl OpenLessBackend { let vocabulary = preferences .vocabulary_learning_enabled .then(|| CoreEditObservationSink { - settings: settings.clone(), + settings, expected_generation, generation: Arc::clone(&self.edit_observation_generation), typed_text: typed_text.to_string(), @@ -9755,12 +9755,21 @@ mod tests { prefs.expression_learning_enabled = true; backend.set_preferences(prefs).unwrap(); let gate = Arc::clone(&backend.settings_write_gate); - let _guard = gate.lock().unwrap(); + let (locked_tx, locked_rx) = std::sync::mpsc::sync_channel(1); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let holder = std::thread::spawn(move || { + let _guard = gate.lock().unwrap(); + locked_tx.send(()).unwrap(); + let _ = release_rx.recv(); + }); + locked_rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap(); let options = DictationStartOptions::default(); let context = tokio::time::timeout( std::time::Duration::from_secs(2), backend.capture_dictation_context(&options, DictationContextPurpose::Dictation), ).await.expect("busy settings must not block capture").unwrap(); + release_tx.send(()).unwrap(); + holder.join().unwrap(); assert!(!context.polish.style_system_prompt.contains("用户经多次编辑")); } diff --git a/openless-all/app/crates/openless-core/src/expression_observation.rs b/openless-all/app/crates/openless-core/src/expression_observation.rs index cd2dcb966..5f4cee41a 100644 --- a/openless-all/app/crates/openless-core/src/expression_observation.rs +++ b/openless-all/app/crates/openless-core/src/expression_observation.rs @@ -271,7 +271,8 @@ impl ExpressionProfile { } pub fn stable_directions(&self) -> Vec { - let selectors: [(ExpressionFeature, fn(ExpressionSignal) -> i8); 3] = [ + type FeatureSelector = (ExpressionFeature, fn(ExpressionSignal) -> i8); + let selectors: [FeatureSelector; 3] = [ (ExpressionFeature::LineBreaks, |signal| signal.line_breaks), (ExpressionFeature::Exclamations, |signal| { signal.exclamations From 4627b9d8823e167373fe2ae07fe5122747a53fa1 Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Mon, 9 Nov 2026 00:19:58 +0800 Subject: [PATCH 12/22] fix(core): exclude factual and sensitive vocabulary edits --- docs/awareness-follow-up.md | 2 +- .../app/crates/openless-core/src/api.rs | 24 ++++ .../src/correction_observation.rs | 122 ++++++++++++++++++ .../src/expression_observation.rs | 5 + .../app/crates/openless-core/src/lib.rs | 1 + 5 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 openless-all/app/crates/openless-core/src/correction_observation.rs diff --git a/docs/awareness-follow-up.md b/docs/awareness-follow-up.md index 3a9dec2bf..0abd9c671 100644 --- a/docs/awareness-follow-up.md +++ b/docs/awareness-follow-up.md @@ -13,5 +13,5 @@ The reviewed implementation separates app/control context, cursor context, corre - Define independently controlled, opt-in learning and capture boundaries; keep secure fields, sensitive apps, uncertain target changes and blacklist entries excluded. Describe when contextual text may be sent to the configured model. - The initial implementation accepts only uniquely anchored formatting edits, rejects sensitive or content-changing observations, and stores bounded text-free global evidence. Opt-out stops observation and new-session application; users can clear the profile. Mixed edits and richer classification remain unsupported. - Extend the profile to app/category scopes with explicit precedence and decay, plus a user-maintained exclusion list. Validate reset and opt-out races across all hosts, and ensure form directives cannot override facts, explicit corrections or user instructions. -- Separate correction suggestion detection from style learning; retain explicit user confirmation for learned vocabulary. Cover conflicting candidates and repeated sessions without treating one edit as a stable habit. +- Correction suggestions now reject changes to numeric or negation signatures and sensitive contexts, while retaining explicit user confirmation. Add a richer lexical affinity model, conflicting-candidate handling and repeated-session evidence without treating one edit as a stable habit. - Integrate the controls into the single Awareness page and exercise opt-out, clearing, app exclusion, restart and prompt behavior across supported hosts. Add deterministic classification, persistence, safety, stale-observer and native integration tests before enabling the feature. diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 1af8a63f5..cbbded797 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -2743,6 +2743,9 @@ impl EditObservationSink for CoreEditObservationSink { if self.generation.load(Ordering::Acquire) != self.expected_generation { return false; } + if !crate::correction_observation::permits_vocabulary_suggestion(&self.typed_text, &edit) { + return false; + } let rules = crate::host_document::learned_rules_with_max_chars( &edit, self.settings.max_phrase_chars as usize, @@ -9802,6 +9805,27 @@ mod tests { assert_eq!(backend.pending_corrections().len(), 1); } + #[test] + fn vocabulary_observer_does_not_offer_fact_changes_as_words() { + let (backend, _) = backend(); + let sink = CoreEditObservationSink { + settings: crate::shared_types::VocabularyLearningSettings::default(), + expected_generation: backend.edit_observation_generation.load(Ordering::Acquire), + generation: Arc::clone(&backend.edit_observation_generation), + typed_text: "今天买了十个苹果".into(), + pending: Arc::clone(&backend.pending_corrections), + events: Arc::clone(&backend.events), + }; + let edit = crate::host_document::EditPair { + before: "今天买了".into(), + source: "十".into(), + target: "九".into(), + after: "个苹果".into(), + }; + assert!(!sink.publish(edit)); + assert!(backend.pending_corrections().is_empty()); + } + #[test] fn vocabulary_learning_config_change_invalidates_pending_and_stale_sink() { let (backend, _) = backend(); diff --git a/openless-all/app/crates/openless-core/src/correction_observation.rs b/openless-all/app/crates/openless-core/src/correction_observation.rs new file mode 100644 index 000000000..d68ba3ba5 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/correction_observation.rs @@ -0,0 +1,122 @@ +//! Conservative safeguards for locally observed vocabulary suggestions. + +use crate::host_document::EditPair; +use regex::Regex; +use std::sync::OnceLock; +use unicode_normalization::UnicodeNormalization; + +fn normalized(value: &str) -> String { + value.nfkc().flat_map(char::to_lowercase).collect() +} + +fn number_signature(value: &str) -> String { + value + .chars() + .filter(|character| { + character.is_numeric() + || "零〇一二两兩三四五六七八九十百千万萬亿億壹贰貳叁參肆伍陆陸柒捌玖拾佰仟" + .contains(*character) + }) + .collect() +} + +fn negation_pattern() -> &'static Regex { + static PATTERN: OnceLock = OnceLock::new(); + PATTERN.get_or_init(|| { + Regex::new(r"(?-u:\b)(?:no|not|never|neither|nor)(?-u:\b)|n['’]t|[不没沒無无非别別]") + .expect("fixed negation expression is valid") + }) +} + +fn negation_signature(value: &str) -> Vec<&str> { + negation_pattern() + .find_iter(value) + .map(|found| found.as_str()) + .collect() +} + +/// Refuse suggestions when the observed edit could alter a number, negation, +/// or sensitive content. The caller still requires explicit user confirmation. +pub(crate) fn permits_vocabulary_suggestion(inserted: &str, edit: &EditPair) -> bool { + if crate::expression_observation::contains_sensitive_text(inserted) { + return false; + } + let before = normalized(&format!("{}{}{}", edit.before, edit.source, edit.after)); + let after = normalized(&format!("{}{}{}", edit.before, edit.target, edit.after)); + if crate::expression_observation::contains_sensitive_text(&before) + || crate::expression_observation::contains_sensitive_text(&after) + { + return false; + } + let source = normalized(&edit.source); + let target = normalized(&edit.target); + if [&source, &target].iter().any(|changed| { + !number_signature(changed).is_empty() || !negation_signature(changed).is_empty() + }) { + return false; + } + number_signature(&before) == number_signature(&after) + && negation_signature(&before) == negation_signature(&after) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn edit(before: &str, source: &str, target: &str, after: &str) -> EditPair { + EditPair { + before: before.into(), + source: source.into(), + target: target.into(), + after: after.into(), + } + } + + #[test] + fn permits_ordinary_technical_word_corrections() { + assert!(permits_vocabulary_suggestion( + "请导出 ZIP 文件", + &edit("请导出 ", "ZIP", "VIP", " 文件") + )); + assert!(permits_vocabulary_suggestion( + "使用 codx 完成任务", + &edit("使用 ", "codx", "codex", " 完成任务") + )); + } + + #[test] + fn rejects_fact_and_negation_changes() { + assert!(!permits_vocabulary_suggestion( + "今天买了十个苹果", + &edit("今天买了", "十", "九", "个苹果") + )); + assert!(!permits_vocabulary_suggestion( + "订单编号是 10", + &edit("订单编号是 ", "10", "11", "") + )); + assert!(!permits_vocabulary_suggestion( + "这件事情不能做", + &edit("这件事情", "不", "", "能做") + )); + assert!(!permits_vocabulary_suggestion( + "I can't send this", + &edit("I can", "'t", "", " send this") + )); + assert!(!permits_vocabulary_suggestion( + "回复no即可", + &edit("回复", "no", "yes", "即可") + )); + } + + #[test] + fn rejects_sensitive_context_without_persisting_it() { + assert!(!permits_vocabulary_suggestion( + "联系 user@example.com 处理 ZIP", + &edit("处理 ", "ZIP", "VIP", "") + )); + assert!(!permits_vocabulary_suggestion( + "无敏感文本", + &edit("账户 ", "12345678", "87654321", "") + )); + } +} diff --git a/openless-all/app/crates/openless-core/src/expression_observation.rs b/openless-all/app/crates/openless-core/src/expression_observation.rs index 5f4cee41a..42a75d7e1 100644 --- a/openless-all/app/crates/openless-core/src/expression_observation.rs +++ b/openless-all/app/crates/openless-core/src/expression_observation.rs @@ -366,6 +366,11 @@ fn normalized(text: &str) -> String { text.nfkc().flat_map(char::to_lowercase).collect() } +pub(crate) fn contains_sensitive_text(text: &str) -> bool { + text.chars().take(MAX_CHARS + 1).count() > MAX_CHARS + || sensitive_patterns().is_match(&normalized(text)) +} + fn skeleton(text: &str) -> String { text.chars() .filter(|character| !character.is_whitespace() && !",.;:!,。;:!、".contains(*character)) diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index 2f4892f00..fbe028ec2 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -24,6 +24,7 @@ mod cloud_sync_transaction; mod cloud_sync_types; mod cloud_sync_validation; pub mod coding_agent; +mod correction_observation; pub mod coding_agent_guard; pub mod config; pub mod correction; From 3a900b420d3e4d7024cbf756e2f73b5447d29786 Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Sun, 8 Nov 2026 21:37:46 +0800 Subject: [PATCH 13/22] feat(desktop): add retained-history usage view --- docs/usage-accounting-follow-up.md | 7 + openless-all/app/e2e/usage.spec.ts | 14 ++ .../app/src/components/FloatingShell.tsx | 15 +- openless-all/app/src/i18n/de.ts | 1 + openless-all/app/src/i18n/en.ts | 1 + openless-all/app/src/i18n/es.ts | 1 + openless-all/app/src/i18n/fr.ts | 1 + openless-all/app/src/i18n/ja.ts | 1 + openless-all/app/src/i18n/ko.ts | 1 + openless-all/app/src/i18n/zh-CN.ts | 1 + openless-all/app/src/i18n/zh-TW.ts | 1 + openless-all/app/src/pages/Usage.tsx | 211 ++++++++++++++++++ openless-all/app/src/state/useAppState.ts | 1 + 13 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 docs/usage-accounting-follow-up.md create mode 100644 openless-all/app/e2e/usage.spec.ts create mode 100644 openless-all/app/src/pages/Usage.tsx diff --git a/docs/usage-accounting-follow-up.md b/docs/usage-accounting-follow-up.md new file mode 100644 index 000000000..131273004 --- /dev/null +++ b/docs/usage-accounting-follow-up.md @@ -0,0 +1,7 @@ +# Service usage accounting follow-up + +The initial desktop page reads retained dictation history, which is capped and can be cleared. It is not a service usage ledger. Provider labels in those records do not represent retries or complete billing data. + +To satisfy complete usage accounting, add a separately persisted, bounded, local ledger at the service-call boundary. Record a unique attempt identifier, timestamp, operation (including dictation post-processing, translation, selection, QA and other call sites), provider and model, outcome, and provider-reported quantities. Preserve unknown quantities as unknown, not zero; never infer token counts or price from characters. Capture ASR audio units and TTS units where the underlying provider reports them. Cover streaming, non-streaming, retries, cancellation, errors, multimodal calls and supported provider protocols, with tests proving one record per actual attempt and no secret or text content in the ledger. Distinguish missing legacy data from a zero-usage interval. Do not silently attach current model prices to historical requests. + +Expose aggregated date ranges and paginated, filterable records through the existing host contract, independent of history deletion. Add migration, retention and explicit deletion controls, deterministic aggregation tests, and UI tests for partial data, empty states and failed reads. The current history-derived view must be replaced or clearly separated when the ledger exists. diff --git a/openless-all/app/e2e/usage.spec.ts b/openless-all/app/e2e/usage.spec.ts new file mode 100644 index 000000000..71bacd19d --- /dev/null +++ b/openless-all/app/e2e/usage.spec.ts @@ -0,0 +1,14 @@ +import { expect, test } from '@playwright/test'; + +test('usage describes retained history coverage and switches period', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: '用量', exact: true }).click(); + await expect(page.getByRole('heading', { name: '用量', exact: true })).toBeVisible(); + await expect(page.getByText(/最多 200 条/)).toBeVisible(); + await expect(page.getByText('语音识别记录')).toBeVisible(); + await page.getByRole('button', { name: '近 7 天' }).click(); + await expect(page.getByRole('button', { name: '近 7 天' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); +}); diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index 6520b5bf8..82dff12f4 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -21,6 +21,7 @@ import type { ServiceViewId } from '../pages/settings/navigation'; import { CloudSyncSetupPrompt } from './CloudSyncSetupPrompt'; import { Overview } from '../pages/Overview'; const History = lazy(() => import('../pages/History').then((m) => ({ default: m.History }))); +const Usage = lazy(() => import('../pages/Usage').then((m) => ({ default: m.Usage }))); const Vocab = lazy(() => import('../pages/Vocab').then((m) => ({ default: m.Vocab }))); const Style = lazy(() => import('../pages/Style').then((m) => ({ default: m.Style }))); const Marketplace = lazy(() => @@ -70,6 +71,7 @@ const SIDEBAR_WIDTH = 226; const PAGE_CMP: Record, ComponentType> = { overview: Overview, history: History, + usage: Usage, vocab: Vocab, style: Style, marketplace: Marketplace, @@ -481,14 +483,14 @@ function FloatingShellBody({
- {/* Footer holds only the settings button. */} -
+
+
)} diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index e2f3a789a..4b21a369c 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -549,6 +549,7 @@ export const de: typeof zhCN = { settings: 'Einstellungen: Kurzbefehle, Dienste, Datenschutz und Updates', }, footer: { + usage: 'Nutzung', account: 'Konto', feedback: 'Rückmeldung', settings: 'Einstellungen', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index e4de09598..34445f983 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -542,6 +542,7 @@ export const en: typeof zhCN = { settings: 'Preferences: shortcuts, providers, privacy and updates', }, footer: { + usage: 'Usage', account: 'Account', feedback: 'Feedback', settings: 'Settings', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 7f8b05159..d3ffe03d6 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -546,6 +546,7 @@ export const es: typeof zhCN = { settings: 'Preferencias: atajos, proveedores, privacidad y actualizaciones', }, footer: { + usage: 'Uso', account: 'Cuenta', feedback: 'Comentarios', settings: 'Ajustes', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 76cd5b7c3..f0d1004f0 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -554,6 +554,7 @@ export const fr: typeof zhCN = { settings: 'Préférences : raccourcis, fournisseurs, confidentialité et mises à jour', }, footer: { + usage: 'Utilisation', account: 'Compte', feedback: 'Commentaires', settings: 'Réglages', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index e035f03e9..337ca4b29 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -533,6 +533,7 @@ export const ja: typeof zhCN = { settings: '環境設定:ショートカット・プロバイダー・プライバシー・更新', }, footer: { + usage: '使用量', account: 'アカウント', feedback: 'フィードバック', settings: '設定', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index fbf5aaf02..cb4201439 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -527,6 +527,7 @@ export const ko: typeof zhCN = { settings: '환경설정: 단축키, 제공자, 개인정보 및 업데이트', }, footer: { + usage: '사용량', account: '계정', feedback: '피드백', settings: '설정', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 45d8053b5..cfb6f4f3b 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -512,6 +512,7 @@ export const zhCN = { settings: '偏好设置:快捷键、提供商、隐私与更新', }, footer: { + usage: '用量', account: '账户', feedback: '反馈', settings: '设置', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index f55948c94..9ab9bed2c 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -512,6 +512,7 @@ export const zhTW: typeof zhCN = { settings: '偏好設定:快捷鍵、提供商、隱私與更新', }, footer: { + usage: '用量', account: '帳號', feedback: '反饋', settings: '設定', diff --git a/openless-all/app/src/pages/Usage.tsx b/openless-all/app/src/pages/Usage.tsx new file mode 100644 index 000000000..d3f66121e --- /dev/null +++ b/openless-all/app/src/pages/Usage.tsx @@ -0,0 +1,211 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { listHistory } from '../lib/ipc/history'; +import type { DictationSession } from '../lib/types'; +import { Card, PageHeader } from './_atoms'; + +export function Usage() { + const { i18n } = useTranslation(); + const zh = i18n.resolvedLanguage?.startsWith('zh') ?? false; + const text = (cn: string, en: string) => (zh ? cn : en); + const [history, setHistory] = useState(null); + const [error, setError] = useState(false); + const [days, setDays] = useState(30); + useEffect(() => { + let live = true; + void listHistory().then( + (rows) => { + if (live) setHistory(rows); + }, + () => { + if (live) setError(true); + }, + ); + return () => { + live = false; + }; + }, []); + const rows = useMemo(() => { + const cutoff = Date.now() - days * 86_400_000; + return (history ?? []).filter( + (row) => + (!row.source || row.source === 'voice') && new Date(row.createdAt).getTime() >= cutoff, + ); + }, [history, days]); + const asr = rows.filter((row) => row.pipelineMode !== 'multimodal' && row.asrProvider); + const llm = rows.filter((row) => row.pipelineMode !== 'multimodal' && row.llmProvider); + const omni = rows.filter((row) => row.pipelineMode === 'multimodal' && row.llmProvider); + const seconds = rows.reduce((total, row) => total + (row.durationMs ?? 0), 0) / 1000; + const stats = [ + [text('语音识别记录', 'ASR records'), asr.length], + [text('文字处理记录', 'Text processing records'), llm.length], + [text('多模态记录', 'Multimodal records'), omni.length], + [ + text('录音时长', 'Recording duration'), + rows.some((row) => row.durationMs == null) + ? '—' + : `${Math.round(seconds / 60)} ${text('分钟', 'min')}`, + ], + ]; + const grouped = new Map(); + for (const row of rows) { + const stages = + row.pipelineMode === 'multimodal' + ? [[text('多模态', 'Multimodal'), row.llmProvider, row.llmModel]] + : [ + [text('语音识别', 'ASR'), row.asrProvider, row.asrModel], + [ + row.translationActive + ? text('翻译', 'Translation') + : text('听写后处理', 'Dictation processing'), + row.llmProvider, + row.llmModel, + ], + ]; + for (const [stage, provider, model] of stages) { + if (!provider) continue; + const key = [stage, provider, model].filter(Boolean).join(' · '); + grouped.set(key, (grouped.get(key) ?? 0) + 1); + } + } + const daily = Array.from({ length: days }, (_, index) => { + const date = new Date(); + date.setHours(0, 0, 0, 0); + date.setDate(date.getDate() - (days - index - 1)); + const end = new Date(date); + end.setDate(end.getDate() + 1); + return { + date, + count: rows.filter((row) => { + const time = new Date(row.createdAt).getTime(); + return time >= date.getTime() && time < end.getTime(); + }).length, + }; + }); + const maxDaily = Math.max(1, ...daily.map((day) => day.count)); + return ( + <> + +

+ {text( + '当前仅统计本机保留的听写历史(最多 200 条),不含其他工具调用。历史未记录的 token、费用和 TTS 用量暂不可用。', + 'These figures cover retained dictation history only (up to 200 entries), not other tools. Tokens, costs, and TTS usage were not recorded and are unavailable.', + )} +

+
+ {[7, 30].map((day) => ( + + ))} +
+ {error ? ( +

{text('无法读取历史记录。', 'Could not load history.')}

+ ) : history === null ? ( +

{text('加载中…', 'Loading…')}

+ ) : ( + <> +
+ {stats.map(([label, value]) => ( + +
{label}
+
{value}
+
+ ))} +
+ +

+ {text('每日记录', 'Daily records')} +

+
+ {daily.map(({ date, count }) => ( +
+ ))} +
+ + +

+ {text('服务与用途', 'Services and purposes')} +

+ {grouped.size === 0 ? ( +

{text('没有可归属服务的记录。', 'No attributable records.')}

+ ) : ( + [...grouped.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([name, count]) => ( +
+ {name} + {count} +
+ )) + )} +
+ +

+ {text('最近记录', 'Recent records')} +

+ {rows.slice(0, 30).map((row) => ( +
+ + {[row.asrProvider, row.llmProvider].filter(Boolean).join(' · ') || '—'} +
+ ))} +
+ + )} + + ); +} diff --git a/openless-all/app/src/state/useAppState.ts b/openless-all/app/src/state/useAppState.ts index e9e9bcd1b..268d1f5d2 100644 --- a/openless-all/app/src/state/useAppState.ts +++ b/openless-all/app/src/state/useAppState.ts @@ -5,6 +5,7 @@ import { useState } from 'react'; export type AppTab = | 'overview' | 'history' + | 'usage' | 'vocab' | 'style' | 'marketplace' From 6e7106fadf93678caa852f5043232d9b6a453e7e Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Sun, 8 Nov 2026 23:54:25 +0800 Subject: [PATCH 14/22] feat(ui): show unavailable service usage explicitly --- openless-all/app/e2e/usage.spec.ts | 2 ++ openless-all/app/src/pages/Usage.tsx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/openless-all/app/e2e/usage.spec.ts b/openless-all/app/e2e/usage.spec.ts index 71bacd19d..f00fec584 100644 --- a/openless-all/app/e2e/usage.spec.ts +++ b/openless-all/app/e2e/usage.spec.ts @@ -6,6 +6,8 @@ test('usage describes retained history coverage and switches period', async ({ p await expect(page.getByRole('heading', { name: '用量', exact: true })).toBeVisible(); await expect(page.getByText(/最多 200 条/)).toBeVisible(); await expect(page.getByText('语音识别记录')).toBeVisible(); + await expect(page.getByText('TTS 用量', { exact: true })).toBeVisible(); + await expect(page.getByText('模型 Token', { exact: true })).toBeVisible(); await page.getByRole('button', { name: '近 7 天' }).click(); await expect(page.getByRole('button', { name: '近 7 天' })).toHaveAttribute( 'aria-pressed', diff --git a/openless-all/app/src/pages/Usage.tsx b/openless-all/app/src/pages/Usage.tsx index d3f66121e..79849f111 100644 --- a/openless-all/app/src/pages/Usage.tsx +++ b/openless-all/app/src/pages/Usage.tsx @@ -40,6 +40,8 @@ export function Usage() { [text('语音识别记录', 'ASR records'), asr.length], [text('文字处理记录', 'Text processing records'), llm.length], [text('多模态记录', 'Multimodal records'), omni.length], + [text('TTS 用量', 'TTS usage'), '—'], + [text('模型 Token', 'Model tokens'), '—'], [ text('录音时长', 'Recording duration'), rows.some((row) => row.durationMs == null) From 9a9af07bcc47728df81906b3ef639fdda45b70a9 Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Mon, 9 Nov 2026 00:01:11 +0800 Subject: [PATCH 15/22] feat(core): add local service usage ledger foundation --- .../app/crates/openless-core/src/lib.rs | 1 + .../crates/openless-core/src/usage_ledger.rs | 365 ++++++++++++++++++ 2 files changed, 366 insertions(+) create mode 100644 openless-all/app/crates/openless-core/src/usage_ledger.rs diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index fbe028ec2..44b455fe8 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -80,6 +80,7 @@ pub mod style_pack_store; pub mod style_packs; pub mod testing; pub mod types; +pub mod usage_ledger; pub mod vocabulary; pub mod voice_activation; pub mod voice_edit_session; diff --git a/openless-all/app/crates/openless-core/src/usage_ledger.rs b/openless-all/app/crates/openless-core/src/usage_ledger.rs new file mode 100644 index 000000000..4802f6c46 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/usage_ledger.rs @@ -0,0 +1,365 @@ +//! Local service-use metadata. Content, credentials and inferred prices are never stored. + +use crate::errors::BackendError; +use crate::persistence::{atomic_write, persistence_error}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::io::Read; +use std::path::PathBuf; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Mutex, +}; +use uuid::Uuid; + +const MAX_RECORDS: usize = 10_000; +const MAX_BYTES: u64 = 8 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ServiceKind { + Asr, + Llm, + Tts, + Multimodal, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ServicePurpose { + Dictation, + DictationPostprocess, + Translation, + Question, + Selection, + Other, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ServiceOutcome { + Succeeded, + Failed, + Cancelled, + Unknown, +} + +/// Values are retained as the provider reported them. Cache accounting differs +/// between protocols, so these fields must not be summed implicitly. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ReportedTokens { + pub input: Option, + pub output: Option, + pub cached_input: Option, +} + +impl ReportedTokens { + pub fn from_response(value: &serde_json::Value) -> Option { + let usage = value + .get("usage") + .or_else(|| value.pointer("/response/usage")) + .or_else(|| value.pointer("/message/usage")) + .or_else(|| value.get("usageMetadata"))?; + let tokens = Self { + input: usage + .get("input_tokens") + .or_else(|| usage.get("prompt_tokens")) + .or_else(|| usage.get("promptTokenCount")) + .and_then(|value| value.as_u64()), + output: usage + .get("output_tokens") + .or_else(|| usage.get("completion_tokens")) + .or_else(|| usage.get("candidatesTokenCount")) + .and_then(|value| value.as_u64()), + cached_input: usage + .pointer("/input_tokens_details/cached_tokens") + .or_else(|| usage.pointer("/prompt_tokens_details/cached_tokens")) + .or_else(|| usage.get("cache_read_input_tokens")) + .or_else(|| usage.get("cachedContentTokenCount")) + .and_then(|value| value.as_u64()), + }; + (tokens != Self::default()).then_some(tokens) + } + + pub fn merge_report(&mut self, newer: Self) { + if newer.input.is_some() { + self.input = newer.input; + } + if newer.output.is_some() { + self.output = newer.output; + } + if newer.cached_input.is_some() { + self.cached_input = newer.cached_input; + } + } +} + +/// Quantities are optional because provider responses may not disclose them. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UsageRecord { + pub id: Uuid, + pub occurred_at_ms: i64, + pub kind: ServiceKind, + pub purpose: ServicePurpose, + pub outcome: ServiceOutcome, + pub provider: Option, + pub model: Option, + pub audio_ms: Option, + pub input_tokens: Option, + pub output_tokens: Option, + pub cached_input_tokens: Option, + pub synthesized_characters: Option, +} + +fn identifier_valid(value: &str) -> bool { + !value.is_empty() + && value.len() <= 160 + && !value.contains("://") + && value + .chars() + .all(|character| character.is_alphanumeric() || "._-/+:".contains(character)) +} + +impl UsageRecord { + fn valid(&self) -> bool { + self.occurred_at_ms > 0 + && self.provider.as_deref().is_none_or(identifier_valid) + && self.model.as_deref().is_none_or(identifier_valid) + } +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct UsageDocument { + version: u32, + records: Vec, +} + +/// A single shared instance must own a given file within a process. +pub struct UsageLedger { + path: PathBuf, + records: Mutex>>, + generation: AtomicU64, +} + +impl UsageLedger { + pub fn open(path: PathBuf) -> Result { + let mut records = Vec::new(); + match std::fs::File::open(&path) { + Ok(file) => { + let mut bytes = Vec::new(); + file.take(MAX_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| persistence_error("read service usage"))?; + if bytes.len() as u64 > MAX_BYTES { + return Err(persistence_error("service usage too large")); + } + let document: UsageDocument = serde_json::from_slice(&bytes) + .map_err(|_| persistence_error("decode service usage"))?; + if document.version != 1 || document.records.len() > MAX_RECORDS { + return Err(persistence_error("invalid service usage")); + } + let mut ids = HashSet::new(); + if document + .records + .iter() + .any(|record| !record.valid() || !ids.insert(record.id)) + { + return Err(persistence_error("invalid service usage record")); + } + records = document.records; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(persistence_error("read service usage")), + } + Ok(Self { + path, + records: Mutex::new(Some(records)), + generation: AtomicU64::new(0), + }) + } + + pub fn generation(&self) -> u64 { + self.generation.load(Ordering::Acquire) + } + + pub fn append( + &self, + expected_generation: u64, + record: UsageRecord, + ) -> Result { + if !record.valid() { + return Err(persistence_error("invalid service usage record")); + } + let mut state = self + .records + .lock() + .map_err(|_| persistence_error("lock service usage"))?; + if self.generation.load(Ordering::Acquire) != expected_generation { + return Ok(false); + } + let mut next = state + .as_ref() + .ok_or_else(|| persistence_error("service usage needs reload"))? + .clone(); + if next.iter().any(|existing| existing.id == record.id) { + return Ok(false); + } + next.push(record); + next.sort_unstable_by_key(|record| record.occurred_at_ms); + if next.len() > MAX_RECORDS { + next.drain(..next.len() - MAX_RECORDS); + } + if let Err(error) = self.persist(&next) { + *state = None; + return Err(error); + } + *state = Some(next); + Ok(true) + } + + pub fn list(&self) -> Result, BackendError> { + self.records + .lock() + .map_err(|_| persistence_error("lock service usage"))? + .clone() + .ok_or_else(|| persistence_error("service usage needs reload")) + } + + pub fn clear(&self) -> Result<(), BackendError> { + let mut state = self + .records + .lock() + .map_err(|_| persistence_error("lock service usage"))?; + self.generation.fetch_add(1, Ordering::AcqRel); + if state.is_none() { + return Err(persistence_error("service usage needs reload")); + } + if let Err(error) = self.persist(&[]) { + *state = None; + return Err(error); + } + *state = Some(Vec::new()); + Ok(()) + } + + fn persist(&self, records: &[UsageRecord]) -> Result<(), BackendError> { + let bytes = serde_json::to_vec(&UsageDocument { + version: 1, + records: records.to_vec(), + }) + .map_err(|_| persistence_error("encode service usage"))?; + atomic_write(&self.path, &bytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record() -> UsageRecord { + UsageRecord { + id: Uuid::new_v4(), + occurred_at_ms: 1_800_000_000_000, + kind: ServiceKind::Llm, + purpose: ServicePurpose::DictationPostprocess, + outcome: ServiceOutcome::Succeeded, + provider: Some("provider".into()), + model: Some("model-v1".into()), + audio_ms: None, + input_tokens: Some(80), + output_tokens: Some(20), + cached_input_tokens: None, + synthesized_characters: None, + } + } + + #[test] + fn persists_reported_quantities_and_deduplicates_without_text() { + let dir = std::env::temp_dir().join(format!("openless-usage-{}", Uuid::new_v4())); + let path = dir.join("usage.json"); + let ledger = UsageLedger::open(path.clone()).unwrap(); + let event = record(); + assert!(ledger.append(ledger.generation(), event.clone()).unwrap()); + assert!(!ledger.append(ledger.generation(), event.clone()).unwrap()); + let content = std::fs::read_to_string(&path).unwrap(); + assert!(!content.contains("transcript")); + assert!(!content.contains("apiKey")); + drop(ledger); + assert_eq!( + UsageLedger::open(path).unwrap().list().unwrap(), + vec![event] + ); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn clear_invalidates_old_writers_and_unknown_files_are_preserved() { + let dir = std::env::temp_dir().join(format!("openless-usage-{}", Uuid::new_v4())); + let path = dir.join("usage.json"); + let ledger = UsageLedger::open(path.clone()).unwrap(); + let generation = ledger.generation(); + assert!(ledger.append(generation, record()).unwrap()); + ledger.clear().unwrap(); + assert!(!ledger.append(generation, record()).unwrap()); + assert!(ledger.list().unwrap().is_empty()); + drop(ledger); + let unknown = br#"{"version":2,"records":[],"newField":true}"#; + std::fs::write(&path, unknown).unwrap(); + assert!(UsageLedger::open(path.clone()).is_err()); + assert_eq!(std::fs::read(path).unwrap(), unknown); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn rejects_invalid_identifiers_without_inventing_token_semantics() { + let dir = std::env::temp_dir().join(format!("openless-usage-{}", Uuid::new_v4())); + let ledger = UsageLedger::open(dir.join("usage.json")).unwrap(); + let mut event = record(); + event.model = Some("https://example.invalid/?key=secret".into()); + assert!(ledger.append(ledger.generation(), event).is_err()); + let mut event = record(); + event.cached_input_tokens = Some(81); + assert!(ledger.append(ledger.generation(), event).unwrap()); + } + + #[test] + fn reads_only_provider_reported_token_fields() { + use serde_json::json; + let chat = ReportedTokens::from_response(&json!({"usage":{"prompt_tokens":100,"completion_tokens":8,"prompt_tokens_details":{"cached_tokens":30}}})).unwrap(); + assert_eq!( + chat, + ReportedTokens { + input: Some(100), + output: Some(8), + cached_input: Some(30) + } + ); + let response = ReportedTokens::from_response( + &json!({"response":{"usage":{"input_tokens":40,"output_tokens":5}}}), + ) + .unwrap(); + assert_eq!(response.input, Some(40)); + assert_eq!(response.output, Some(5)); + let mut messages = ReportedTokens::from_response( + &json!({"message":{"usage":{"input_tokens":10,"cache_read_input_tokens":90}}}), + ) + .unwrap(); + messages.merge_report( + ReportedTokens::from_response(&json!({"usage":{"output_tokens":7}})).unwrap(), + ); + assert_eq!( + messages, + ReportedTokens { + input: Some(10), + output: Some(7), + cached_input: Some(90) + } + ); + let gemini = ReportedTokens::from_response(&json!({"usageMetadata":{"promptTokenCount":12,"candidatesTokenCount":3,"cachedContentTokenCount":4}})).unwrap(); + assert_eq!(gemini.cached_input, Some(4)); + assert!(ReportedTokens::from_response(&json!({"usage":{"total_tokens":999}})).is_none()); + assert!(ReportedTokens::from_response(&json!({"text":"never stored"})).is_none()); + } +} From f6ffc596c81743e4d54f5d6d58266be4594f3fd1 Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Mon, 9 Nov 2026 00:10:22 +0800 Subject: [PATCH 16/22] feat(core): parse provider-reported token usage --- docs/usage-accounting-follow-up.md | 2 ++ .../crates/openless-core/src/llm_protocol.rs | 35 +++++++++++++++++-- .../crates/openless-core/src/usage_ledger.rs | 35 ++++++++++++++++--- 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/docs/usage-accounting-follow-up.md b/docs/usage-accounting-follow-up.md index 131273004..94fd6ff08 100644 --- a/docs/usage-accounting-follow-up.md +++ b/docs/usage-accounting-follow-up.md @@ -2,6 +2,8 @@ The initial desktop page reads retained dictation history, which is capped and can be cleared. It is not a service usage ledger. Provider labels in those records do not represent retries or complete billing data. +An initial bounded, text-free local ledger and provider-reported token parser now exist in Core. The SSE decoder can collect reports without interpreting cache fields as additive. They are not yet connected to service calls or the desktop page, and therefore do not provide usage data to users. No historical token values can be recovered from the existing history. + To satisfy complete usage accounting, add a separately persisted, bounded, local ledger at the service-call boundary. Record a unique attempt identifier, timestamp, operation (including dictation post-processing, translation, selection, QA and other call sites), provider and model, outcome, and provider-reported quantities. Preserve unknown quantities as unknown, not zero; never infer token counts or price from characters. Capture ASR audio units and TTS units where the underlying provider reports them. Cover streaming, non-streaming, retries, cancellation, errors, multimodal calls and supported provider protocols, with tests proving one record per actual attempt and no secret or text content in the ledger. Distinguish missing legacy data from a zero-usage interval. Do not silently attach current model prices to historical requests. Expose aggregated date ranges and paginated, filterable records through the existing host contract, independent of history deletion. Add migration, retention and explicit deletion controls, deterministic aggregation tests, and UI tests for partial data, empty states and failed reads. The current history-derived view must be replaced or clearly separated when the ledger exists. diff --git a/openless-all/app/crates/openless-core/src/llm_protocol.rs b/openless-all/app/crates/openless-core/src/llm_protocol.rs index 41d6ad20a..8cf845d3d 100644 --- a/openless-all/app/crates/openless-core/src/llm_protocol.rs +++ b/openless-all/app/crates/openless-core/src/llm_protocol.rs @@ -410,6 +410,7 @@ pub(crate) struct TextEventStream { pub done: bool, siwc: bool, request_id: Option, + reported_tokens: crate::usage_ledger::ReportedTokens, } impl TextEventStream { @@ -422,6 +423,7 @@ impl TextEventStream { done: false, siwc: false, request_id: None, + reported_tokens: crate::usage_ledger::ReportedTokens::default(), } } @@ -463,6 +465,9 @@ impl TextEventStream { } Err(_) => return Err(response_error("invalid LLM SSE JSON")), }; + if let Some(reported) = crate::usage_ledger::ReportedTokens::from_response(&value) { + self.reported_tokens.merge_report(reported); + } let kind = value["type"] .as_str() .or_else(|| { @@ -502,8 +507,10 @@ impl TextEventStream { None } "response.failed" | "response.incomplete" => { - if self.siwc { return Err(LLMError::Siwc(crate::siwc::error("response_incomplete"))); } - return Err(response_error("llmResponseIncomplete")) + if self.siwc { + return Err(LLMError::Siwc(crate::siwc::error("response_incomplete"))); + } + return Err(response_error("llmResponseIncomplete")); } _ => None, }, @@ -555,6 +562,30 @@ mod tests { use super::*; use crate::credentials::{InMemoryCredentialStore, SecretValue}; + #[test] + fn streaming_usage_is_collected_without_discarding_text_or_guessing_counts() { + let mut stream = TextEventStream::new(LlmRequestFormat::ChatCompletions); + stream.push(b"data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\ndata: {\"choices\":[],\"usage\":{\"prompt_tokens\":42,\"completion_tokens\":5}}\n\ndata: [DONE]\n\n").unwrap(); + let mut text = String::new(); + while let Some(event) = stream.next().unwrap() { + if let StreamEvent::Text(delta) = event { + text.push_str(&delta); + } + } + assert_eq!(text, "hello"); + assert_eq!(stream.reported_tokens.input, Some(42)); + assert_eq!(stream.reported_tokens.output, Some(5)); + assert_eq!(stream.reported_tokens.cached_input, None); + + let mut no_usage = TextEventStream::new(LlmRequestFormat::ChatCompletions); + no_usage.push(b"data: [DONE]\n\n").unwrap(); + assert!(matches!(no_usage.next().unwrap(), Some(StreamEvent::Done))); + assert_eq!( + no_usage.reported_tokens, + crate::usage_ledger::ReportedTokens::default() + ); + } + #[tokio::test] async fn protocol_defaults_overrides_and_channels_are_independent() { let store = InMemoryCredentialStore::default(); diff --git a/openless-all/app/crates/openless-core/src/usage_ledger.rs b/openless-all/app/crates/openless-core/src/usage_ledger.rs index 4802f6c46..4111f4f70 100644 --- a/openless-all/app/crates/openless-core/src/usage_ledger.rs +++ b/openless-all/app/crates/openless-core/src/usage_ledger.rs @@ -51,6 +51,7 @@ pub struct ReportedTokens { pub input: Option, pub output: Option, pub cached_input: Option, + pub cache_write_input: Option, } impl ReportedTokens { @@ -77,6 +78,9 @@ impl ReportedTokens { .or_else(|| usage.get("cache_read_input_tokens")) .or_else(|| usage.get("cachedContentTokenCount")) .and_then(|value| value.as_u64()), + cache_write_input: usage + .get("cache_creation_input_tokens") + .and_then(|value| value.as_u64()), }; (tokens != Self::default()).then_some(tokens) } @@ -91,6 +95,9 @@ impl ReportedTokens { if newer.cached_input.is_some() { self.cached_input = newer.cached_input; } + if newer.cache_write_input.is_some() { + self.cache_write_input = newer.cache_write_input; + } } } @@ -109,13 +116,17 @@ pub struct UsageRecord { pub input_tokens: Option, pub output_tokens: Option, pub cached_input_tokens: Option, + pub cache_write_input_tokens: Option, pub synthesized_characters: Option, } fn identifier_valid(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); !value.is_empty() && value.len() <= 160 && !value.contains("://") + && !lower.starts_with("sk-") + && !lower.starts_with("bearer") && value .chars() .all(|character| character.is_alphanumeric() || "._-/+:".contains(character)) @@ -123,7 +134,8 @@ fn identifier_valid(value: &str) -> bool { impl UsageRecord { fn valid(&self) -> bool { - self.occurred_at_ms > 0 + !self.id.is_nil() + && self.occurred_at_ms > 0 && self.provider.as_deref().is_none_or(identifier_valid) && self.model.as_deref().is_none_or(identifier_valid) } @@ -144,6 +156,12 @@ pub struct UsageLedger { } impl UsageLedger { + /// Replacing an unreadable ledger requires an explicit user reset. + pub fn reset(path: PathBuf) -> Result { + atomic_write(&path, br#"{"version":1,"records":[]}"#)?; + Self::open(path) + } + pub fn open(path: PathBuf) -> Result { let mut records = Vec::new(); match std::fs::File::open(&path) { @@ -271,6 +289,7 @@ mod tests { input_tokens: Some(80), output_tokens: Some(20), cached_input_tokens: None, + cache_write_input_tokens: None, synthesized_characters: None, } } @@ -308,7 +327,8 @@ mod tests { let unknown = br#"{"version":2,"records":[],"newField":true}"#; std::fs::write(&path, unknown).unwrap(); assert!(UsageLedger::open(path.clone()).is_err()); - assert_eq!(std::fs::read(path).unwrap(), unknown); + assert_eq!(std::fs::read(&path).unwrap(), unknown); + assert!(UsageLedger::reset(path).unwrap().list().unwrap().is_empty()); std::fs::remove_dir_all(dir).unwrap(); } @@ -320,6 +340,9 @@ mod tests { event.model = Some("https://example.invalid/?key=secret".into()); assert!(ledger.append(ledger.generation(), event).is_err()); let mut event = record(); + event.model = Some("sk-example-secret".into()); + assert!(ledger.append(ledger.generation(), event).is_err()); + let mut event = record(); event.cached_input_tokens = Some(81); assert!(ledger.append(ledger.generation(), event).unwrap()); } @@ -333,7 +356,8 @@ mod tests { ReportedTokens { input: Some(100), output: Some(8), - cached_input: Some(30) + cached_input: Some(30), + cache_write_input: None, } ); let response = ReportedTokens::from_response( @@ -343,7 +367,7 @@ mod tests { assert_eq!(response.input, Some(40)); assert_eq!(response.output, Some(5)); let mut messages = ReportedTokens::from_response( - &json!({"message":{"usage":{"input_tokens":10,"cache_read_input_tokens":90}}}), + &json!({"message":{"usage":{"input_tokens":10,"cache_read_input_tokens":90,"cache_creation_input_tokens":12}}}), ) .unwrap(); messages.merge_report( @@ -354,7 +378,8 @@ mod tests { ReportedTokens { input: Some(10), output: Some(7), - cached_input: Some(90) + cached_input: Some(90), + cache_write_input: Some(12), } ); let gemini = ReportedTokens::from_response(&json!({"usageMetadata":{"promptTokenCount":12,"candidatesTokenCount":3,"cachedContentTokenCount":4}})).unwrap(); From 79b42c19f52b5b40baa00d51dac388dcf5344b38 Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Mon, 9 Nov 2026 00:23:59 +0800 Subject: [PATCH 17/22] fix(core): preserve provider cache token semantics --- .../crates/openless-core/src/usage_ledger.rs | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/usage_ledger.rs b/openless-all/app/crates/openless-core/src/usage_ledger.rs index 4111f4f70..214d66b79 100644 --- a/openless-all/app/crates/openless-core/src/usage_ledger.rs +++ b/openless-all/app/crates/openless-core/src/usage_ledger.rs @@ -58,9 +58,18 @@ impl ReportedTokens { pub fn from_response(value: &serde_json::Value) -> Option { let usage = value .get("usage") - .or_else(|| value.pointer("/response/usage")) - .or_else(|| value.pointer("/message/usage")) - .or_else(|| value.get("usageMetadata"))?; + .filter(|usage| usage.is_object()) + .or_else(|| { + value + .pointer("/response/usage") + .filter(|usage| usage.is_object()) + }) + .or_else(|| { + value + .pointer("/message/usage") + .filter(|usage| usage.is_object()) + }) + .or_else(|| value.get("usageMetadata").filter(|usage| usage.is_object()))?; let tokens = Self { input: usage .get("input_tokens") @@ -132,6 +141,12 @@ fn identifier_valid(value: &str) -> bool { .all(|character| character.is_alphanumeric() || "._-/+:".contains(character)) } +/// Adapters may omit untrusted or invalid identifiers without dropping usage. +pub fn sanitized_identifier(value: &str) -> Option { + let value = value.trim(); + identifier_valid(value).then(|| value.to_owned()) +} + impl UsageRecord { fn valid(&self) -> bool { !self.id.is_nil() @@ -342,6 +357,8 @@ mod tests { let mut event = record(); event.model = Some("sk-example-secret".into()); assert!(ledger.append(ledger.generation(), event).is_err()); + assert_eq!(sanitized_identifier("sk-example-secret"), None); + assert_eq!(sanitized_identifier(" model-v1 "), Some("model-v1".into())); let mut event = record(); event.cached_input_tokens = Some(81); assert!(ledger.append(ledger.generation(), event).unwrap()); @@ -366,6 +383,11 @@ mod tests { .unwrap(); assert_eq!(response.input, Some(40)); assert_eq!(response.output, Some(5)); + let nested = ReportedTokens::from_response( + &json!({"usage":null,"response":{"usage":{"input_tokens":40}}}), + ) + .unwrap(); + assert_eq!(nested.input, Some(40)); let mut messages = ReportedTokens::from_response( &json!({"message":{"usage":{"input_tokens":10,"cache_read_input_tokens":90,"cache_creation_input_tokens":12}}}), ) From 4a89fc8574d0fdc6ac6826a31b38f00006913f51 Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Mon, 9 Nov 2026 00:57:42 +0800 Subject: [PATCH 18/22] Record reported text processing usage --- docs/usage-accounting-follow-up.md | 4 +- .../openless-core/src/cloud_providers.rs | 125 ++++++++++ .../app/crates/openless-core/src/domains.rs | 2 + .../crates/openless-core/src/llm_protocol.rs | 4 + .../app/crates/openless-core/src/polish.rs | 197 ++++++++++++++++ .../crates/openless-core/src/usage_ledger.rs | 165 ++++++++++++- openless-all/app/e2e/usage.spec.ts | 5 +- openless-all/app/harmony/ipc-audit.json | 18 +- .../app/src-tauri/src/commands/dictionary.rs | 26 +++ openless-all/app/src-tauri/src/coordinator.rs | 10 + .../app/src-tauri/src/core_adapters.rs | 10 +- openless-all/app/src-tauri/src/lib.rs | 4 + openless-all/app/src/lib/ipc/service-usage.ts | 38 +++ .../app/src/lib/serviceUsageMetrics.test.ts | 58 +++++ .../app/src/lib/serviceUsageMetrics.ts | 43 ++++ openless-all/app/src/pages/Usage.tsx | 217 ++++++++++++++++-- 16 files changed, 902 insertions(+), 24 deletions(-) create mode 100644 openless-all/app/src/lib/ipc/service-usage.ts create mode 100644 openless-all/app/src/lib/serviceUsageMetrics.test.ts create mode 100644 openless-all/app/src/lib/serviceUsageMetrics.ts diff --git a/docs/usage-accounting-follow-up.md b/docs/usage-accounting-follow-up.md index 94fd6ff08..57dacac6e 100644 --- a/docs/usage-accounting-follow-up.md +++ b/docs/usage-accounting-follow-up.md @@ -2,7 +2,9 @@ The initial desktop page reads retained dictation history, which is capped and can be cleared. It is not a service usage ledger. Provider labels in those records do not represent retries or complete billing data. -An initial bounded, text-free local ledger and provider-reported token parser now exist in Core. The SSE decoder can collect reports without interpreting cache fields as additive. They are not yet connected to service calls or the desktop page, and therefore do not provide usage data to users. No historical token values can be recovered from the existing history. +An initial bounded, text-free local ledger and background writer now record explicitly reported tokens from desktop OpenAI-compatible text-polisher responses. The desktop page presents those reports separately from history-derived counts and labels them incomplete. Gemini, Codex, SIWC, multimodal, ASR, TTS, retries and all other call paths are not yet fully covered. The writer's error count is process-local; durable loss reporting and an explicit shutdown drain remain required. No historical token values can be recovered from the existing history. + +Official OpenAI Chat Completions streaming requests explicitly ask for usage only when using the official endpoint and an observer is attached. An interrupted stream may not return its final usage report. DeepSeek documents its own final-stream usage and cache fields. Do not apply an OpenAI-specific request option to arbitrary compatible endpoints: [OpenAI API reference](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create), [DeepSeek API reference](https://api-docs.deepseek.com/api/create-chat-completion/). To satisfy complete usage accounting, add a separately persisted, bounded, local ledger at the service-call boundary. Record a unique attempt identifier, timestamp, operation (including dictation post-processing, translation, selection, QA and other call sites), provider and model, outcome, and provider-reported quantities. Preserve unknown quantities as unknown, not zero; never infer token counts or price from characters. Capture ASR audio units and TTS units where the underlying provider reports them. Cover streaming, non-streaming, retries, cancellation, errors, multimodal calls and supported provider protocols, with tests proving one record per actual attempt and no secret or text content in the ledger. Distinguish missing legacy data from a zero-usage interval. Do not silently attach current model prices to historical requests. diff --git a/openless-all/app/crates/openless-core/src/cloud_providers.rs b/openless-all/app/crates/openless-core/src/cloud_providers.rs index 40b8eafef..05c6421c7 100644 --- a/openless-all/app/crates/openless-core/src/cloud_providers.rs +++ b/openless-all/app/crates/openless-core/src/cloud_providers.rs @@ -995,6 +995,7 @@ fn map_tencent_asr_error(error: TencentCloudASRError) -> BackendError { pub struct SharedCloudTextPolisher { credentials: Arc, active: Arc>>>, + usage_recorder: Option>, } impl SharedCloudTextPolisher { @@ -1002,8 +1003,14 @@ impl SharedCloudTextPolisher { Self { credentials, active: Arc::new(Mutex::new(HashMap::new())), + usage_recorder: None, } } + + pub fn with_usage_recorder(mut self, recorder: Arc) -> Self { + self.usage_recorder = Some(recorder); + self + } } #[derive(Default)] @@ -1056,6 +1063,24 @@ enum CloudPolisherProvider { Gemini(crate::llm_gemini::GeminiProvider), } +fn usage_purpose(context: &DictationContext) -> crate::usage_ledger::ServicePurpose { + use crate::dictation_context::DictationOutputTarget; + use crate::usage_ledger::ServicePurpose; + if context.polish.selection_input { + ServicePurpose::Selection + } else if context.polish.translation_active { + ServicePurpose::Translation + } else { + match context.output_target { + DictationOutputTarget::Qa => ServicePurpose::Question, + DictationOutputTarget::QuickNote => ServicePurpose::QuickNote, + DictationOutputTarget::VoiceEdit => ServicePurpose::VoiceEdit, + DictationOutputTarget::CloudNote => ServicePurpose::CloudNote, + _ => ServicePurpose::DictationPostprocess, + } + } +} + impl TextPolisher for SharedCloudTextPolisher { fn polish( &self, @@ -1087,6 +1112,8 @@ impl TextPolisher for SharedCloudTextPolisher { active: Arc::clone(&self.active), }; let credentials = Arc::clone(&self.credentials); + let usage_recorder = self.usage_recorder.clone(); + let usage_generation = usage_recorder.as_ref().map(|recorder| recorder.generation()); Box::pin(async move { let _registration = registration; if raw_text.trim().is_empty() { @@ -1097,11 +1124,41 @@ impl TextPolisher for SharedCloudTextPolisher { } let mut provider = build_cloud_polisher_provider(credentials.as_ref(), context.as_ref()).await?; + let usage_label = match &provider { + CloudPolisherProvider::OpenAi(provider) => Some(provider.call_label()), + CloudPolisherProvider::Gemini(_) => None, + }; if let CloudPolisherProvider::OpenAi(crate::polish::ActiveLLMProvider::OpenAI( provider, )) = &mut provider { provider.set_dictation_session(session_id); + if let (Some(recorder), Some(generation), Some(label)) = + (usage_recorder, usage_generation, usage_label) + { + let purpose = usage_purpose(&context); + let channel = crate::usage_ledger::sanitized_identifier(&context.llm.provider_id); + provider.set_usage_observer(Arc::new(move |id, usage| { + let record = crate::usage_ledger::UsageRecord { + id, + occurred_at_ms: chrono::Utc::now().timestamp_millis(), + kind: crate::usage_ledger::ServiceKind::Llm, + purpose, + outcome: crate::usage_ledger::ServiceOutcome::Unknown, + provider: crate::usage_ledger::sanitized_identifier(&label.provider), + channel: channel.clone(), + model: crate::usage_ledger::sanitized_identifier(&label.model), + audio_ms: None, + input_tokens: usage.input, + output_tokens: usage.output, + cached_input_tokens: usage.cached_input, + cache_write_input_tokens: usage.cache_write_input, + reasoning_tokens: usage.reasoning, + synthesized_characters: None, + }; + let _ = recorder.submit(generation, record); + })); + } } tokio::select! { _ = cancellation.cancelled() => Err(cancelled_provider_error()), @@ -3660,6 +3717,74 @@ mod tests { assert_eq!(error.message, "LLM API key is not configured"); } + #[tokio::test] + async fn cloud_polisher_records_only_provider_reported_tokens() { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + let mut bytes = Vec::new(); + let mut chunk = [0u8; 4096]; + loop { + let count = socket.read(&mut chunk).unwrap(); + assert!(count > 0); + bytes.extend_from_slice(&chunk[..count]); + if let Some(header_end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") { + let header_end = header_end + 4; + let headers = String::from_utf8_lossy(&bytes[..header_end]); + let length = headers.lines().find_map(|line| line.to_ascii_lowercase().strip_prefix("content-length:").and_then(|value| value.trim().parse::().ok())).unwrap_or(0); + if bytes.len() >= header_end + length { break; } + } + } + let events = b"data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\ndata: {\"choices\":[],\"usage\":{\"prompt_tokens\":30,\"completion_tokens\":4}}\n\ndata: [DONE]\n\n"; + write!(socket, "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", events.len()).unwrap(); + socket.write_all(events).unwrap(); + }); + let directory = std::env::temp_dir().join(format!("openless-polish-usage-{}", uuid::Uuid::new_v4())); + let ledger = Arc::new(crate::usage_ledger::UsageLedger::open(directory.join("usage.json")).unwrap()); + let recorder = Arc::new(crate::usage_ledger::UsageRecorder::start(ledger).unwrap()); + let credentials = Arc::new(InMemoryCredentialStore::default()); + write_channel_secret(&credentials, CredentialNamespace::Llm, "llm-channel", LLM_API_KEY_ACCOUNT, "fixture-key").await; + write_channel_secret(&credentials, CredentialNamespace::Llm, "llm-channel", LLM_ENDPOINT_ACCOUNT, &format!("http://{address}")).await; + let mut llm = ProviderInvocation::new("llm-channel", "deepseek"); + llm.model = Some("fixture-model".into()); + let context = Arc::new(DictationContext { llm, ..DictationContext::default() }); + let polisher = SharedCloudTextPolisher::new(credentials).with_usage_recorder(recorder.clone()); + let result = polisher.polish(SessionId::new(), context, "private text".into(), Arc::new(IgnoreTextStreamSink)).await.unwrap(); + assert_eq!(result.text, "hello"); + let (records, failures) = recorder.snapshot().unwrap(); + assert_eq!(failures, 0); + assert_eq!(records.len(), 1); + assert_eq!(records[0].input_tokens, Some(30)); + assert_eq!(records[0].output_tokens, Some(4)); + assert_eq!(records[0].channel.as_deref(), Some("llm-channel")); + assert_eq!(records[0].purpose, crate::usage_ledger::ServicePurpose::DictationPostprocess); + let stored = std::fs::read_to_string(directory.join("usage.json")).unwrap(); + assert!(!stored.contains("private text")); + assert!(!stored.contains("fixture-key")); + server.join().unwrap(); + drop(polisher); + drop(recorder); + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn usage_purpose_distinguishes_selection_translation_and_tools() { + use crate::dictation_context::DictationOutputTarget; + use crate::usage_ledger::ServicePurpose; + let mut context = DictationContext::default(); + assert_eq!(usage_purpose(&context), ServicePurpose::DictationPostprocess); + context.polish.translation_active = true; + assert_eq!(usage_purpose(&context), ServicePurpose::Translation); + context.polish.selection_input = true; + assert_eq!(usage_purpose(&context), ServicePurpose::Selection); + context.polish.selection_input = false; + context.polish.translation_active = false; + context.output_target = DictationOutputTarget::QuickNote; + assert_eq!(usage_purpose(&context), ServicePurpose::QuickNote); + } + #[tokio::test] async fn duplicate_polish_session_keeps_the_original_cancellation_route() { let credentials: Arc = Arc::new(InMemoryCredentialStore::default()); diff --git a/openless-all/app/crates/openless-core/src/domains.rs b/openless-all/app/crates/openless-core/src/domains.rs index b5783229e..ce70f23ea 100644 --- a/openless-all/app/crates/openless-core/src/domains.rs +++ b/openless-all/app/crates/openless-core/src/domains.rs @@ -2139,6 +2139,7 @@ pub struct BackendServices { pub platform: Arc, pub host_context: Arc, pub edit_observation: Arc, + pub usage_recorder: Option>, coding_agent_process: Option>, auxiliary_polisher: Option>, auxiliary_transcription: Option>, @@ -2168,6 +2169,7 @@ impl BackendServices { platform: Arc::new(UnsupportedDomainServices), host_context: Arc::new(crate::ports::NoopHostContextAdapter), edit_observation: Arc::new(crate::ports::NoopEditObservationAdapter), + usage_recorder: None, coding_agent_process: None, auxiliary_polisher: None, auxiliary_transcription: None, diff --git a/openless-all/app/crates/openless-core/src/llm_protocol.rs b/openless-all/app/crates/openless-core/src/llm_protocol.rs index 8cf845d3d..987f05781 100644 --- a/openless-all/app/crates/openless-core/src/llm_protocol.rs +++ b/openless-all/app/crates/openless-core/src/llm_protocol.rs @@ -437,6 +437,10 @@ impl TextEventStream { crate::polish::append_utf8_sse_chunk(&mut self.buffer, &mut self.pending, chunk) } + pub fn reported_tokens(&self) -> crate::usage_ledger::ReportedTokens { + self.reported_tokens + } + pub fn next(&mut self) -> Result, LLMError> { if self.done { return Ok(None); diff --git a/openless-all/app/crates/openless-core/src/polish.rs b/openless-all/app/crates/openless-core/src/polish.rs index 4ff06a1f5..a717716df 100644 --- a/openless-all/app/crates/openless-core/src/polish.rs +++ b/openless-all/app/crates/openless-core/src/polish.rs @@ -518,6 +518,9 @@ impl ActiveLLMProvider { } } +pub type LlmUsageObserver = + std::sync::Arc; + pub struct OpenAICompatibleLLMProvider { config: OpenAICompatibleConfig, siwc_access: Option>, @@ -531,6 +534,7 @@ pub struct OpenAICompatibleLLMProvider { /// connection pool — a fresh TLS handshake on every polish, exactly the cost that /// cache exists to avoid. A constant cap keeps the cache key single. polish_client: reqwest::Client, + usage_observer: Option, } impl OpenAICompatibleLLMProvider { @@ -553,6 +557,31 @@ impl OpenAICompatibleLLMProvider { dictation_session_id: None, siwc_access: None, polish_client, + usage_observer: None, + } + } + + /// The observer must return promptly and receives no request text. + pub fn with_usage_observer(mut self, observer: LlmUsageObserver) -> Self { + self.usage_observer = Some(observer); + self + } + + pub(crate) fn set_usage_observer(&mut self, observer: LlmUsageObserver) { + self.usage_observer = Some(observer); + } + + fn report_usage(&self, request_id: uuid::Uuid, usage: crate::usage_ledger::ReportedTokens) { + if usage != crate::usage_ledger::ReportedTokens::default() { + if let Some(observer) = &self.usage_observer { + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + observer(request_id, usage) + })) + .is_err() + { + log::warn!("usage observer panicked"); + } + } } } @@ -833,6 +862,17 @@ impl OpenAICompatibleLLMProvider { "stream": stream, "messages": messages, }); + if stream + && self.usage_observer.is_some() + && self.config.provider_id == "openai" + && url::Url::parse(&self.config.base_url) + .ok() + .and_then(|url| url.host_str().map(str::to_owned)) + .as_deref() + == Some("api.openai.com") + { + body["stream_options"] = json!({"include_usage": true}); + } if let Some(temperature) = self.config.temperature { // OpenAI 官方 gpt-5 / gpt-6 系列在 Chat Completions 只接受默认 temperature=1, // 传 0.3 会被 400 拒绝(issue #857 / #1101)。官方渠道的这些模型不下发该字段, @@ -944,6 +984,14 @@ impl OpenAICompatibleLLMProvider { }); } + if self.usage_observer.is_some() { + if let Ok(value) = serde_json::from_str::(&body_text) { + if let Some(usage) = crate::usage_ledger::ReportedTokens::from_response(&value) { + self.report_usage(request_id, usage); + } + } + } + let extracted = crate::llm_protocol::extract_text_raw(self.config.protocol.format, &body_text)?; let cleaned = clean_polish_output(&extracted); @@ -1128,6 +1176,7 @@ impl OpenAICompatibleLLMProvider { if !cancelled { events.finish()?; } + self.report_usage(request_id, events.reported_tokens()); let cleaned = clean_polish_output(&full_text); log::info!( "[llm] stream done request_id={} cancelled={} delta_count={} extracted_chars={} extracted_hash={} cleaned_chars={} cleaned_hash={} extracted_near_duplicates={} cleaned_near_duplicates={}", @@ -2505,6 +2554,154 @@ mod tests { assert!(err.to_string().contains("middle of a UTF-8 codepoint")); } + #[tokio::test] + async fn provider_reports_explicit_streaming_usage_without_changing_text() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let _ = read_http_request(&mut stream); + let events = b"data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\ndata: {\"choices\":[],\"usage\":{\"prompt_tokens\":21,\"completion_tokens\":4}}\n\ndata: [DONE]\n\n"; + write_chunked_sse_response(&mut stream, &[events]); + }); + let observed = std::sync::Arc::new(StdMutex::new(Vec::new())); + let output = observed.clone(); + let provider = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new( + "custom", + "Custom", + format!("http://{address}"), + "", + "fixture-model", + )) + .with_usage_observer(std::sync::Arc::new(move |id, usage| { + output.lock().unwrap().push((id, usage)) + })); + let text = provider + .chat_completion_messages_streaming( + vec![serde_json::json!({"role":"user","content":"private text"})], + StreamingTimeouts { + first_token: Duration::from_secs(2), + idle: Duration::from_secs(2), + }, + |_| {}, + || false, + ) + .await + .unwrap(); + assert_eq!(text, "hello"); + let observations = observed.lock().unwrap(); + assert_eq!(observations.len(), 1); + assert_eq!(observations[0].1.input, Some(21)); + assert_eq!(observations[0].1.output, Some(4)); + server.join().unwrap(); + } + + #[tokio::test] + async fn provider_reports_explicit_non_streaming_usage() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let _ = read_http_request(&mut stream); + let body = r#"{"choices":[{"message":{"content":"hello"}}],"usage":{"prompt_tokens":33,"completion_tokens":6}}"#; + write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body).unwrap(); + }); + let observed = std::sync::Arc::new(StdMutex::new(Vec::new())); + let output = observed.clone(); + let provider = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new( + "custom", + "Custom", + format!("http://{address}"), + "", + "fixture-model", + )) + .with_usage_observer(std::sync::Arc::new(move |id, usage| { + output.lock().unwrap().push((id, usage)) + })); + assert_eq!( + provider + .chat_completion("system", "private text", Duration::from_secs(2)) + .await + .unwrap(), + "hello" + ); + let observations = observed.lock().unwrap(); + assert_eq!(observations.len(), 1); + assert_eq!(observations[0].1.input, Some(33)); + assert_eq!(observations[0].1.output, Some(6)); + server.join().unwrap(); + } + + #[test] + fn usage_observer_panic_does_not_escape_provider() { + let provider = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new( + "custom", + "Custom", + "http://127.0.0.1:1", + "", + "fixture-model", + )) + .with_usage_observer(std::sync::Arc::new(|_, _| panic!("fixture observer"))); + provider.report_usage( + uuid::Uuid::new_v4(), + crate::usage_ledger::ReportedTokens { + input: Some(1), + ..Default::default() + }, + ); + } + + #[test] + fn missing_provider_usage_does_not_emit_zero_counts() { + let calls = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let captured = calls.clone(); + let provider = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new( + "custom", + "Custom", + "http://127.0.0.1:1", + "", + "fixture-model", + )) + .with_usage_observer(std::sync::Arc::new(move |_, _| { + captured.fetch_add(1, Ordering::Relaxed); + })); + provider.report_usage(uuid::Uuid::new_v4(), Default::default()); + assert_eq!(calls.load(Ordering::Relaxed), 0); + } + + #[test] + fn usage_stream_option_is_limited_to_official_openai_chat_requests() { + let observer: LlmUsageObserver = std::sync::Arc::new(|_, _| {}); + let official = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new( + "openai", + "OpenAI", + "https://api.openai.com/v1", + "", + "gpt-4o-mini", + )) + .with_usage_observer(observer.clone()); + assert_eq!( + official.chat_body(true, vec![])["stream_options"]["include_usage"], + true + ); + assert!(official + .chat_body(false, vec![]) + .get("stream_options") + .is_none()); + let proxy = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new( + "openai", + "Proxy", + "https://proxy.example/v1", + "", + "gpt-4o-mini", + )) + .with_usage_observer(observer); + assert!(proxy + .chat_body(true, vec![]) + .get("stream_options") + .is_none()); + } + #[tokio::test] async fn polish_streaming_handles_multibyte_split_in_http_chunk() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/openless-all/app/crates/openless-core/src/usage_ledger.rs b/openless-all/app/crates/openless-core/src/usage_ledger.rs index 214d66b79..a819c74f4 100644 --- a/openless-all/app/crates/openless-core/src/usage_ledger.rs +++ b/openless-all/app/crates/openless-core/src/usage_ledger.rs @@ -8,7 +8,7 @@ use std::io::Read; use std::path::PathBuf; use std::sync::{ atomic::{AtomicU64, Ordering}, - Mutex, + mpsc, Arc, Mutex, }; use uuid::Uuid; @@ -32,6 +32,9 @@ pub enum ServicePurpose { Translation, Question, Selection, + QuickNote, + VoiceEdit, + CloudNote, Other, } @@ -52,6 +55,7 @@ pub struct ReportedTokens { pub output: Option, pub cached_input: Option, pub cache_write_input: Option, + pub reasoning: Option, } impl ReportedTokens { @@ -84,11 +88,17 @@ impl ReportedTokens { cached_input: usage .pointer("/input_tokens_details/cached_tokens") .or_else(|| usage.pointer("/prompt_tokens_details/cached_tokens")) + .or_else(|| usage.get("prompt_cache_hit_tokens")) .or_else(|| usage.get("cache_read_input_tokens")) .or_else(|| usage.get("cachedContentTokenCount")) .and_then(|value| value.as_u64()), cache_write_input: usage .get("cache_creation_input_tokens") + .or_else(|| usage.pointer("/input_tokens_details/cache_write_tokens")) + .and_then(|value| value.as_u64()), + reasoning: usage + .pointer("/output_tokens_details/reasoning_tokens") + .or_else(|| usage.pointer("/completion_tokens_details/reasoning_tokens")) .and_then(|value| value.as_u64()), }; (tokens != Self::default()).then_some(tokens) @@ -107,6 +117,9 @@ impl ReportedTokens { if newer.cache_write_input.is_some() { self.cache_write_input = newer.cache_write_input; } + if newer.reasoning.is_some() { + self.reasoning = newer.reasoning; + } } } @@ -120,12 +133,14 @@ pub struct UsageRecord { pub purpose: ServicePurpose, pub outcome: ServiceOutcome, pub provider: Option, + pub channel: Option, pub model: Option, pub audio_ms: Option, pub input_tokens: Option, pub output_tokens: Option, pub cached_input_tokens: Option, pub cache_write_input_tokens: Option, + pub reasoning_tokens: Option, pub synthesized_characters: Option, } @@ -152,6 +167,7 @@ impl UsageRecord { !self.id.is_nil() && self.occurred_at_ms > 0 && self.provider.as_deref().is_none_or(identifier_valid) + && self.channel.as_deref().is_none_or(identifier_valid) && self.model.as_deref().is_none_or(identifier_valid) } } @@ -170,6 +186,101 @@ pub struct UsageLedger { generation: AtomicU64, } +enum WriterCommand { + Append(u64, Box), + Flush(mpsc::Sender<()>), + Clear(mpsc::Sender>), +} + +/// A bounded worker keeps disk writes off recognition and model response paths. +pub struct UsageRecorder { + ledger: Arc, + sender: mpsc::SyncSender, + dropped_or_failed: Arc, +} + +impl UsageRecorder { + pub fn start(ledger: Arc) -> Result { + let (sender, receiver) = mpsc::sync_channel(256); + let worker_ledger = Arc::clone(&ledger); + let dropped_or_failed = Arc::new(AtomicU64::new(0)); + let failures = Arc::clone(&dropped_or_failed); + std::thread::Builder::new() + .name("openless-usage-ledger".into()) + .spawn(move || { + while let Ok(command) = receiver.recv() { + match command { + WriterCommand::Append(generation, record) => { + if worker_ledger.append(generation, *record).is_err() { + failures.fetch_add(1, Ordering::Relaxed); + } + } + WriterCommand::Flush(reply) => { + let _ = reply.send(()); + } + WriterCommand::Clear(reply) => { + let _ = reply.send(worker_ledger.clear()); + } + } + } + }) + .map_err(|_| persistence_error("start service usage worker"))?; + Ok(Self { + ledger, + sender, + dropped_or_failed, + }) + } + + pub fn generation(&self) -> u64 { + self.ledger.generation() + } + + pub fn submit(&self, generation: u64, record: UsageRecord) -> bool { + if self + .sender + .try_send(WriterCommand::Append(generation, Box::new(record))) + .is_ok() + { + true + } else { + self.dropped_or_failed.fetch_add(1, Ordering::Relaxed); + false + } + } + + /// Call from a blocking worker, never from an audio or UI thread. + pub fn snapshot(&self) -> Result<(Vec, u64), BackendError> { + let (send, receive) = mpsc::channel(); + self.sender + .try_send(WriterCommand::Flush(send)) + .map_err(|_| persistence_error("flush service usage"))?; + receive + .recv_timeout(std::time::Duration::from_secs(5)) + .map_err(|_| persistence_error("flush service usage"))?; + Ok(( + self.ledger.list()?, + self.dropped_or_failed.load(Ordering::Relaxed), + )) + } + + /// Queued records precede the clear; in-flight pre-clear requests are rejected later. + pub fn clear(&self) -> Result<(), BackendError> { + let (send, receive) = mpsc::channel(); + self.sender + .try_send(WriterCommand::Clear(send)) + .map_err(|_| persistence_error("clear service usage"))?; + receive + .recv_timeout(std::time::Duration::from_secs(5)) + .map_err(|_| { + BackendError::new( + crate::errors::BackendErrorCode::OutcomeUnknown, + "service usage clear outcome is unknown", + ) + })? + } +} + impl UsageLedger { /// Replacing an unreadable ledger requires an explicit user reset. pub fn reset(path: PathBuf) -> Result { @@ -299,12 +410,14 @@ mod tests { purpose: ServicePurpose::DictationPostprocess, outcome: ServiceOutcome::Succeeded, provider: Some("provider".into()), + channel: Some("channel-1".into()), model: Some("model-v1".into()), audio_ms: None, input_tokens: Some(80), output_tokens: Some(20), cached_input_tokens: None, cache_write_input_tokens: None, + reasoning_tokens: None, synthesized_characters: None, } } @@ -347,6 +460,49 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + #[test] + fn older_records_without_optional_fields_remain_readable() { + let dir = std::env::temp_dir().join(format!("openless-usage-{}", Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("usage.json"); + let mut value = serde_json::to_value(record()).unwrap(); + value.as_object_mut().unwrap().remove("channel"); + value + .as_object_mut() + .unwrap() + .remove("cacheWriteInputTokens"); + value.as_object_mut().unwrap().remove("reasoningTokens"); + std::fs::write( + &path, + serde_json::to_vec(&serde_json::json!({"version":1,"records":[value]})).unwrap(), + ) + .unwrap(); + let saved = UsageLedger::open(path).unwrap().list().unwrap(); + assert_eq!(saved[0].channel, None); + assert_eq!(saved[0].cache_write_input_tokens, None); + assert_eq!(saved[0].reasoning_tokens, None); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn recorder_flushes_and_rejects_inflight_records_after_clear() { + let dir = std::env::temp_dir().join(format!("openless-usage-{}", Uuid::new_v4())); + let ledger = Arc::new(UsageLedger::open(dir.join("usage.json")).unwrap()); + let recorder = UsageRecorder::start(ledger).unwrap(); + let generation = recorder.generation(); + assert!(recorder.submit(generation, record())); + assert_eq!(recorder.snapshot().unwrap().0.len(), 1); + recorder.clear().unwrap(); + assert!(recorder.submit(generation, record())); + assert!(recorder.snapshot().unwrap().0.is_empty()); + let mut invalid = record(); + invalid.model = Some("sk-secret".into()); + assert!(recorder.submit(recorder.generation(), invalid)); + assert_eq!(recorder.snapshot().unwrap().1, 1); + drop(recorder); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn rejects_invalid_identifiers_without_inventing_token_semantics() { let dir = std::env::temp_dir().join(format!("openless-usage-{}", Uuid::new_v4())); @@ -375,6 +531,7 @@ mod tests { output: Some(8), cached_input: Some(30), cache_write_input: None, + reasoning: None, } ); let response = ReportedTokens::from_response( @@ -383,6 +540,11 @@ mod tests { .unwrap(); assert_eq!(response.input, Some(40)); assert_eq!(response.output, Some(5)); + let reasoning = ReportedTokens::from_response( + &json!({"usage":{"output_tokens":10,"output_tokens_details":{"reasoning_tokens":7}}}), + ) + .unwrap(); + assert_eq!(reasoning.reasoning, Some(7)); let nested = ReportedTokens::from_response( &json!({"usage":null,"response":{"usage":{"input_tokens":40}}}), ) @@ -402,6 +564,7 @@ mod tests { output: Some(7), cached_input: Some(90), cache_write_input: Some(12), + reasoning: None, } ); let gemini = ReportedTokens::from_response(&json!({"usageMetadata":{"promptTokenCount":12,"candidatesTokenCount":3,"cachedContentTokenCount":4}})).unwrap(); diff --git a/openless-all/app/e2e/usage.spec.ts b/openless-all/app/e2e/usage.spec.ts index f00fec584..b327da229 100644 --- a/openless-all/app/e2e/usage.spec.ts +++ b/openless-all/app/e2e/usage.spec.ts @@ -7,7 +7,10 @@ test('usage describes retained history coverage and switches period', async ({ p await expect(page.getByText(/最多 200 条/)).toBeVisible(); await expect(page.getByText('语音识别记录')).toBeVisible(); await expect(page.getByText('TTS 用量', { exact: true })).toBeVisible(); - await expect(page.getByText('模型 Token', { exact: true })).toBeVisible(); + await expect(page.getByText('已报告输入 Token', { exact: true })).toBeVisible(); + await expect(page.getByText('已报告输出 Token', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: '清除已记录 Token', exact: true }).click(); + await expect(page.getByText('该时段没有已保存的服务商 token 报告。')).toBeVisible(); await page.getByRole('button', { name: '近 7 天' }).click(); await expect(page.getByRole('button', { name: '近 7 天' })).toHaveAttribute( 'aria-pressed', diff --git a/openless-all/app/harmony/ipc-audit.json b/openless-all/app/harmony/ipc-audit.json index fc59d8a6d..cf764060e 100644 --- a/openless-all/app/harmony/ipc-audit.json +++ b/openless-all/app/harmony/ipc-audit.json @@ -1,8 +1,8 @@ { "scope": "Static shared IPC command coverage; excludes event subscriptions, dynamic commands and desktop-only calls outside src/lib/ipc.", - "total": 185, + "total": 187, "wired": 103, - "missing": 82, + "missing": 84, "commands": [ { "name": "accept_pending_correction", @@ -123,6 +123,13 @@ ], "implementation": "rust" }, + { + "name": "clear_service_usage", + "calledFrom": [ + "src/lib/ipc/service-usage.ts" + ], + "implementation": "unimplemented" + }, { "name": "cloud_sync_delete", "calledFrom": [ @@ -501,6 +508,13 @@ ], "implementation": "unimplemented" }, + { + "name": "get_service_usage", + "calledFrom": [ + "src/lib/ipc/service-usage.ts" + ], + "implementation": "unimplemented" + }, { "name": "get_settings", "calledFrom": [ diff --git a/openless-all/app/src-tauri/src/commands/dictionary.rs b/openless-all/app/src-tauri/src/commands/dictionary.rs index e96ebb074..cde0c136f 100644 --- a/openless-all/app/src-tauri/src/commands/dictionary.rs +++ b/openless-all/app/src-tauri/src/commands/dictionary.rs @@ -43,6 +43,32 @@ pub fn list_correction_rules(core: CoreState<'_>) -> Result, core.list_correction_rules().map_err(|e| e.to_string()) } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ServiceUsageSnapshot { + records: Vec, + dropped_or_failed: u64, +} + +#[tauri::command] +pub async fn get_service_usage(window: Window, core: CoreState<'_>) -> Result { + if window.label() != "main" { return Err("service usage is only available from the main window".into()); } + let recorder = core.services().usage_recorder.clone().ok_or("service usage is unavailable")?; + let (records, dropped_or_failed) = tauri::async_runtime::spawn_blocking(move || recorder.snapshot()) + .await.map_err(|_| "service usage read failed".to_string())? + .map_err(|error| error.to_string())?; + Ok(ServiceUsageSnapshot { records, dropped_or_failed }) +} + +#[tauri::command] +pub async fn clear_service_usage(window: Window, core: CoreState<'_>) -> Result<(), String> { + if window.label() != "main" { return Err("service usage is only available from the main window".into()); } + let recorder = core.services().usage_recorder.clone().ok_or("service usage is unavailable")?; + tauri::async_runtime::spawn_blocking(move || recorder.clear()) + .await.map_err(|_| "service usage clear failed".to_string())? + .map_err(|error| error.to_string()) +} + #[tauri::command] pub fn get_expression_profile(window: Window, core: CoreState<'_>) -> Result, String> { if window.label() != "main" { return Err("expression profile is only available from the main window".into()); } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 8c5652ec1..bbbbcc316 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -450,12 +450,22 @@ fn shared_backend_from_stores( correction_rules: correction_rules.core(), style_packs: style_packs.core(), }; + #[cfg(not(mobile))] + let usage_recorder = match openless_core::usage_ledger::UsageLedger::open(config.data_dir.join("service-usage.json")) + .and_then(|ledger| openless_core::usage_ledger::UsageRecorder::start(Arc::new(ledger))) + { + Ok(recorder) => Some(Arc::new(recorder)), + Err(error) => { log::warn!("service usage unavailable: {error}"); None } + }; + #[cfg(mobile)] + let usage_recorder = None; let backend_slot = crate::core_adapters::backend_slot(); let mut dependencies = crate::core_adapters::backend_dependencies( app, Arc::clone(&backend_slot), native_asr, Arc::clone(&repositories.preferences), + usage_recorder, hotkey_status, qa_context, ); diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index d9d7a85d3..5b3f09dc4 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -92,6 +92,7 @@ pub(crate) fn backend_dependencies( backend: BackendSlot, native_asr_dependencies: TauriNativeAsrDependencies, preferences: Arc, + usage_recorder: Option>, hotkey_status: Arc>, qa_host_context: Arc, ) -> openless_core::BackendDependencies { @@ -251,9 +252,11 @@ pub(crate) fn backend_dependencies( ) .expect("Apple system speech provider ID"); let polisher = Arc::new(openless_core::TextPolisherRouter::default()); - let production_polisher: Arc = Arc::new( - openless_core::SharedCloudTextPolisher::new(Arc::clone(&credential_store)), - ); + let production_polisher = openless_core::SharedCloudTextPolisher::new(Arc::clone(&credential_store)); + let production_polisher: Arc = Arc::new(match &usage_recorder { + Some(recorder) => production_polisher.with_usage_recorder(Arc::clone(recorder)), + None => production_polisher, + }); for provider_type in openless_core::SHARED_CLOUD_LLM_PROVIDER_TYPES { polisher .register(*provider_type, Arc::clone(&production_polisher)) @@ -307,6 +310,7 @@ pub(crate) fn backend_dependencies( .expect("built-in Omni provider ids are non-empty"); } let mut dependencies = openless_core::BackendDependencies::unsupported(); + dependencies.services.usage_recorder = usage_recorder; if let Some(model_store) = model_store.clone() { dependencies.services.configure_model_store(model_store); } diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 4645d60e2..c0c84b527 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -367,6 +367,8 @@ macro_rules! app_invoke_handler_desktop { commands::list_correction_rules, commands::get_expression_profile, commands::clear_expression_profile, + commands::get_service_usage, + commands::clear_service_usage, commands::add_correction_rule, commands::remove_correction_rule, commands::set_correction_rule_enabled, @@ -668,6 +670,8 @@ macro_rules! app_invoke_handler_mobile { $crate::commands::list_correction_rules, $crate::commands::get_expression_profile, $crate::commands::clear_expression_profile, + $crate::commands::get_service_usage, + $crate::commands::clear_service_usage, $crate::commands::add_correction_rule, $crate::commands::remove_correction_rule, $crate::commands::set_correction_rule_enabled, diff --git a/openless-all/app/src/lib/ipc/service-usage.ts b/openless-all/app/src/lib/ipc/service-usage.ts new file mode 100644 index 000000000..6d27a244c --- /dev/null +++ b/openless-all/app/src/lib/ipc/service-usage.ts @@ -0,0 +1,38 @@ +import { invokeOrMock } from './shared'; + +export interface ServiceUsageRecord { + id: string; + occurredAtMs: number; + kind: 'asr' | 'llm' | 'tts' | 'multimodal'; + purpose: + | 'dictation' + | 'dictationPostprocess' + | 'translation' + | 'question' + | 'selection' + | 'quickNote' + | 'voiceEdit' + | 'cloudNote' + | 'other'; + outcome: 'succeeded' | 'failed' | 'cancelled' | 'unknown'; + provider: string | null; + channel: string | null; + model: string | null; + audioMs: number | null; + inputTokens: number | null; + outputTokens: number | null; + cachedInputTokens: number | null; + cacheWriteInputTokens: number | null; + reasoningTokens: number | null; + synthesizedCharacters: number | null; +} +export interface ServiceUsageSnapshot { + records: ServiceUsageRecord[]; + droppedOrFailed: number; +} +export function getServiceUsage(): Promise { + return invokeOrMock('get_service_usage', undefined, () => ({ records: [], droppedOrFailed: 0 })); +} +export function clearServiceUsage(): Promise { + return invokeOrMock('clear_service_usage', undefined, () => undefined); +} diff --git a/openless-all/app/src/lib/serviceUsageMetrics.test.ts b/openless-all/app/src/lib/serviceUsageMetrics.test.ts new file mode 100644 index 000000000..5afba6be7 --- /dev/null +++ b/openless-all/app/src/lib/serviceUsageMetrics.test.ts @@ -0,0 +1,58 @@ +import { summarizeReportedUsage } from './serviceUsageMetrics'; +import type { ServiceUsageRecord } from './ipc/service-usage'; + +function equal(actual: unknown, expected: unknown, label: string) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error(label); +} +const make = (overrides: Partial): ServiceUsageRecord => ({ + id: 'id', + occurredAtMs: 100, + kind: 'llm', + purpose: 'dictationPostprocess', + outcome: 'unknown', + provider: 'example', + channel: 'channel', + model: 'model', + audioMs: null, + inputTokens: null, + outputTokens: null, + cachedInputTokens: null, + cacheWriteInputTokens: null, + reasoningTokens: null, + synthesizedCharacters: null, + ...overrides, +}); +const summary = summarizeReportedUsage( + [ + make({ id: 'a', inputTokens: 50, outputTokens: 0, cachedInputTokens: 20 }), + make({ id: 'b', purpose: 'translation', inputTokens: null, outputTokens: 7 }), + make({ id: 'c', occurredAtMs: 99, inputTokens: 999 }), + make({ id: 'd', occurredAtMs: 201, inputTokens: 999 }), + ], + 100, + 200, +); +equal(summary.totals, { input: 50, output: 7 }, 'totals use reported fields in range'); +equal( + summary.byPurpose, + [ + { purpose: 'dictationPostprocess', input: 50, output: 0 }, + { purpose: 'translation', input: null, output: 7 }, + ], + 'usage is grouped without adding cache counts', +); +equal( + summarizeReportedUsage([], 0, 200).totals, + { input: null, output: null }, + 'missing is unknown', +); +equal( + summarizeReportedUsage( + [make({ inputTokens: Number.MAX_SAFE_INTEGER }), make({ inputTokens: 1 })], + 0, + 200, + ).totals.input, + null, + 'unsafe sums are unknown', +); +console.log('reported service usage metrics tests passed'); diff --git a/openless-all/app/src/lib/serviceUsageMetrics.ts b/openless-all/app/src/lib/serviceUsageMetrics.ts new file mode 100644 index 000000000..0de49a345 --- /dev/null +++ b/openless-all/app/src/lib/serviceUsageMetrics.ts @@ -0,0 +1,43 @@ +import type { ServiceUsageRecord } from './ipc/service-usage'; + +export interface ReportedUsageTotals { + input: number | null; + output: number | null; +} + +function sumKnown(values: Array): number | null { + const known = values.filter( + (value): value is number => value !== null && Number.isSafeInteger(value) && value >= 0, + ); + if (!known.length) return null; + const total = known.reduce((sum, value) => sum + value, 0); + return Number.isSafeInteger(total) ? total : null; +} + +export function summarizeReportedUsage( + records: ServiceUsageRecord[], + fromMs: number, + toMs: number, +) { + const rows = records.filter( + (record) => + Number.isSafeInteger(record.occurredAtMs) && + record.occurredAtMs >= fromMs && + record.occurredAtMs <= toMs, + ); + const summarize = (group: ServiceUsageRecord[]): ReportedUsageTotals => ({ + input: sumKnown(group.map((record) => record.inputTokens)), + output: sumKnown(group.map((record) => record.outputTokens)), + }); + const groups = new Map(); + for (const row of rows) { + const group = groups.get(row.purpose) ?? []; + group.push(row); + groups.set(row.purpose, group); + } + return { + rows, + totals: summarize(rows), + byPurpose: [...groups].map(([purpose, group]) => ({ purpose, ...summarize(group) })), + }; +} diff --git a/openless-all/app/src/pages/Usage.tsx b/openless-all/app/src/pages/Usage.tsx index 79849f111..20e8fa6d6 100644 --- a/openless-all/app/src/pages/Usage.tsx +++ b/openless-all/app/src/pages/Usage.tsx @@ -1,8 +1,14 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { listHistory } from '../lib/ipc/history'; +import { + clearServiceUsage, + getServiceUsage, + type ServiceUsageSnapshot, +} from '../lib/ipc/service-usage'; import type { DictationSession } from '../lib/types'; -import { Card, PageHeader } from './_atoms'; +import { summarizeReportedUsage } from '../lib/serviceUsageMetrics'; +import { Btn, Card, PageHeader } from './_atoms'; export function Usage() { const { i18n } = useTranslation(); @@ -11,20 +17,48 @@ export function Usage() { const [history, setHistory] = useState(null); const [error, setError] = useState(false); const [days, setDays] = useState(30); + const [usage, setUsage] = useState(null); + const [usageError, setUsageError] = useState(false); + const [clearError, setClearError] = useState(false); + const [clearing, setClearing] = useState(false); + const mounted = useRef(false); + const clearingRef = useRef(false); + const request = useRef(0); + const refresh = useCallback(async () => { + if (clearingRef.current) return; + const id = ++request.current; + const [historyResult, usageResult] = await Promise.allSettled([ + listHistory(), + getServiceUsage(), + ]); + if (!mounted.current || request.current !== id) return; + if (historyResult.status === 'fulfilled') { + setHistory(historyResult.value); + setError(false); + } else { + setHistory(null); + setError(true); + } + if (usageResult.status === 'fulfilled') { + setUsage(usageResult.value); + setUsageError(false); + setClearError(false); + } else { + setUsage(null); + setUsageError(true); + } + }, []); useEffect(() => { - let live = true; - void listHistory().then( - (rows) => { - if (live) setHistory(rows); - }, - () => { - if (live) setError(true); - }, - ); + mounted.current = true; + void refresh(); + const onFocus = () => void refresh(); + window.addEventListener('focus', onFocus); return () => { - live = false; + mounted.current = false; + request.current += 1; + window.removeEventListener('focus', onFocus); }; - }, []); + }, [refresh]); const rows = useMemo(() => { const cutoff = Date.now() - days * 86_400_000; return (history ?? []).filter( @@ -35,13 +69,35 @@ export function Usage() { const asr = rows.filter((row) => row.pipelineMode !== 'multimodal' && row.asrProvider); const llm = rows.filter((row) => row.pipelineMode !== 'multimodal' && row.llmProvider); const omni = rows.filter((row) => row.pipelineMode === 'multimodal' && row.llmProvider); + const now = Date.now(); + const { + rows: reported, + totals, + byPurpose, + } = summarizeReportedUsage(usage?.records ?? [], now - days * 86_400_000, now); + const purposeName = (purpose: string) => { + const labels: Record = { + dictationPostprocess: ['听写后处理', 'Dictation processing'], + translation: ['翻译', 'Translation'], + selection: ['选区处理', 'Selection'], + question: ['问答', 'Questions'], + quickNote: ['速记', 'Quick note'], + voiceEdit: ['语音编辑', 'Voice editing'], + cloudNote: ['云笔记', 'Cloud note'], + }; + const label = labels[purpose]; + return label ? text(...label) : text('其他', 'Other'); + }; + const safeTokens = (value: number | null) => + value !== null && Number.isSafeInteger(value) && value >= 0 ? value.toLocaleString() : '—'; const seconds = rows.reduce((total, row) => total + (row.durationMs ?? 0), 0) / 1000; const stats = [ [text('语音识别记录', 'ASR records'), asr.length], [text('文字处理记录', 'Text processing records'), llm.length], [text('多模态记录', 'Multimodal records'), omni.length], [text('TTS 用量', 'TTS usage'), '—'], - [text('模型 Token', 'Model tokens'), '—'], + [text('已报告输入 Token', 'Reported input tokens'), totals.input?.toLocaleString() ?? '—'], + [text('已报告输出 Token', 'Reported output tokens'), totals.output?.toLocaleString() ?? '—'], [ text('录音时长', 'Recording duration'), rows.some((row) => row.durationMs == null) @@ -93,11 +149,16 @@ export function Usage() { '按服务与用途查看本机保留的使用记录。', 'Review retained local records by service and purpose.', )} + right={ + void refresh()}> + {text('刷新', 'Refresh')} + + } />

{text( - '当前仅统计本机保留的听写历史(最多 200 条),不含其他工具调用。历史未记录的 token、费用和 TTS 用量暂不可用。', - 'These figures cover retained dictation history only (up to 200 entries), not other tools. Tokens, costs, and TTS usage were not recorded and are unavailable.', + '记录数仅来自本机保留的听写历史(最多 200 条)。Token 仅包含桌面端兼容接口明确返回并成功保存的部分;其他调用、TTS 和费用尚未完整记录,不能作为账单。', + 'Record counts come only from retained dictation history (up to 200 entries). Tokens include only provider-reported values saved from supported desktop calls; other calls, TTS and costs are incomplete and are not a bill.', )}

@@ -120,6 +181,61 @@ export function Usage() { ))}
+ {usageError && ( +

+ {text('无法读取本地 Token 账本。', 'Could not read the local token ledger.')} +

+ )} + {clearError && ( +

+ {text( + '清除结果未确认,请刷新后检查。', + 'The clear result is unconfirmed. Refresh to check.', + )} +

+ )} + {usage && usage.droppedOrFailed > 0 && ( +

+ {text( + '本次运行中部分用量未能保存,统计不完整。', + 'Some usage could not be saved during this run.', + )} +

+ )} + {usage && ( + + )} + {usage && ( +

+ {text( + '清除前或清除期间已开始的请求可能不会计入新账本。', + 'Requests started before or during clearing may not appear in the new ledger.', + )} +

+ )} {error ? (

{text('无法读取历史记录。', 'Could not load history.')}

) : history === null ? ( @@ -186,6 +302,75 @@ export function Usage() { )) )} + +

+ {text('已报告的模型用量', 'Reported model usage')} +

+

+ {text( + '缓存和推理字段由各服务商报告,可能已包含在输入或输出中,不能直接相加。', + 'Provider-reported cache and reasoning fields may already be included in input or output; do not add them directly.', + )} +

+ {byPurpose.map(({ purpose, input, output }) => ( +
+ {purposeName(purpose)} + + {text('已报告输入', 'Reported input')}: {input?.toLocaleString() ?? '—'} ·{' '} + {text('已报告输出', 'Reported output')}: {output?.toLocaleString() ?? '—'} + +
+ ))} + {reported.length === 0 ? ( +

+ {text( + '该时段没有已保存的服务商 token 报告。', + 'No provider token reports were saved for this period.', + )} +

+ ) : ( + [...reported] + .sort((a, b) => b.occurredAtMs - a.occurredAtMs) + .slice(0, 30) + .map((record) => ( +
+ + {purposeName(record.purpose)} + + {[ + ...new Set([record.provider, record.channel, record.model].filter(Boolean)), + ].join(' · ') || '—'} + + + {text('输入', 'Input')}: {safeTokens(record.inputTokens)} ·{' '} + {text('输出', 'Output')}: {safeTokens(record.outputTokens)} +
+ {text('缓存读取', 'Cache read')}: {safeTokens(record.cachedInputTokens)} ·{' '} + {text('缓存写入', 'Cache write')}: {safeTokens(record.cacheWriteInputTokens)} +
+ {text('推理', 'Reasoning')}: {safeTokens(record.reasoningTokens)} +
+
+ )) + )} +

{text('最近记录', 'Recent records')} From dafb3dc080690aa1952b2161acad6db373921b28 Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Mon, 9 Nov 2026 13:22:42 +0800 Subject: [PATCH 19/22] Fix combined desktop settings imports --- openless-all/app/src/pages/settings/tabs.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/openless-all/app/src/pages/settings/tabs.tsx b/openless-all/app/src/pages/settings/tabs.tsx index 7d85322ab..d4558b8aa 100644 --- a/openless-all/app/src/pages/settings/tabs.tsx +++ b/openless-all/app/src/pages/settings/tabs.tsx @@ -1,4 +1,3 @@ -import { VocabularyLearningSection } from './VocabularyLearningSection'; // Content page for each sidebar tab in the settings dialog. Each tab is a vertical stack of section cards; // the real logic lives in the *Section files — this only decides which sections belong to which tab. From 75bcbada33effb9b95920cde1d21affe54633908 Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Mon, 9 Nov 2026 13:41:46 +0800 Subject: [PATCH 20/22] fix(desktop): restore final insertion and align usage and settings --- .../openless-core/src/dictation_context.rs | 34 +- .../app/e2e/live-input-settings.spec.ts | 8 +- .../app/e2e/settings-alignment.spec.ts | 72 ++++ openless-all/app/e2e/usage.spec.ts | 39 +- .../app/src-tauri/src/core_adapters.rs | 111 +++--- .../app/src/components/FloatingShell.tsx | 44 ++- openless-all/app/src/lib/motion.ts | 40 +- openless-all/app/src/pages/Usage.tsx | 346 ++++++++++-------- .../app/src/pages/settings/navigation.ts | 2 +- openless-all/app/src/pages/settings/tabs.tsx | 33 +- openless-all/app/src/styles/global.css | 4 +- 11 files changed, 461 insertions(+), 272 deletions(-) create mode 100644 openless-all/app/e2e/settings-alignment.spec.ts diff --git a/openless-all/app/crates/openless-core/src/dictation_context.rs b/openless-all/app/crates/openless-core/src/dictation_context.rs index ec30592a3..cfe5a7720 100644 --- a/openless-all/app/crates/openless-core/src/dictation_context.rs +++ b/openless-all/app/crates/openless-core/src/dictation_context.rs @@ -414,10 +414,9 @@ impl DictationContext { | DictationOutputTarget::VoiceEdit ) && preferences.streaming_insert, save_streamed_text_to_clipboard: preferences.streaming_insert_save_clipboard, - // Composition display is a TSF text-service feature; other insertion - // modes have no underlined preedit channel. - composition_input: (cfg!(target_os = "macos") && preferences.macos_composition_input) - || cfg!(target_os = "windows") + // macOS AX composition is retired. Ignore legacy enabled values so saved + // settings cannot bypass the regular final/streaming insertion protocol. + composition_input: cfg!(target_os = "windows") && preferences.windows_composition_input && preferences.windows_insertion_mode == WindowsInsertionMode::Tsf, restore_clipboard_after_paste: preferences.restore_clipboard_after_paste, @@ -888,6 +887,33 @@ mod tests { assert!(preferences.stable_transcription_enabled); } + #[test] + fn legacy_macos_live_input_does_not_enable_composition_in_either_pipeline() { + for pipeline_mode in [PipelineMode::Traditional, PipelineMode::Multimodal] { + let preferences = UserPreferences { + macos_composition_input: true, + windows_composition_input: false, + multimodal_pipeline_enabled: true, + pipeline_mode, + ..UserPreferences::default() + }; + let context = DictationContext::capture( + &preferences, + &builtin_style_pack_for_mode(PolishMode::Structured), + DictationProviderInvocations::new( + ProviderInvocation::for_provider("asr"), + ProviderInvocation::for_provider("llm"), + ProviderInvocation::for_provider("omni"), + ), + Vec::new(), + Vec::new(), + &DictationStartOptions::default(), + ); + assert_eq!(context.pipeline_mode, pipeline_mode); + assert!(!context.insertion.composition_input); + } + } + #[test] fn context_is_an_owned_snapshot_of_session_preferences() { let mut preferences = UserPreferences { diff --git a/openless-all/app/e2e/live-input-settings.spec.ts b/openless-all/app/e2e/live-input-settings.spec.ts index 17abeef9b..96d0c5658 100644 --- a/openless-all/app/e2e/live-input-settings.spec.ts +++ b/openless-all/app/e2e/live-input-settings.spec.ts @@ -4,9 +4,7 @@ for (const [os, ua, platform] of [ ['macOS', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)', 'MacIntel'], ['Windows', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', 'Win32'], ] as const) { - test(`live transcript input has a dedicated advanced settings page on ${os}`, async ({ - page, - }) => { + test(`live transcript input availability on ${os}`, async ({ page }) => { await page.addInitScript( ({ ua, platform }) => { Object.defineProperty(navigator, 'userAgent', { value: ua }); @@ -19,6 +17,10 @@ for (const [os, ua, platform] of [ await page.getByRole('button', { name: '设置', exact: true }).click(); const dialog = page.getByRole('dialog', { name: '设置', exact: true }); await dialog.getByRole('button', { name: '高级功能', exact: true }).click(); + if (os === 'macOS') { + await expect(dialog.locator('[data-ol-advanced-entry="liveInput"]')).toHaveCount(0); + return; + } await dialog.locator('[data-ol-advanced-entry="liveInput"]').click(); const panel = dialog.locator('[data-ol-advanced-page="liveInput"]'); await expect(panel).toBeVisible(); diff --git a/openless-all/app/e2e/settings-alignment.spec.ts b/openless-all/app/e2e/settings-alignment.spec.ts new file mode 100644 index 000000000..bfaf68c2f --- /dev/null +++ b/openless-all/app/e2e/settings-alignment.spec.ts @@ -0,0 +1,72 @@ +import { expect, test, type Locator } from '@playwright/test'; +import { openMicrophoneSettings, settleNavigation } from './support/navigation'; + +async function centered(group: Locator) { + await expect(group).toBeVisible(); + for (const zoom of ['1', '1.1']) { + await group.evaluate((node, value) => { + node.style.zoom = value; + }, zoom); + for (const option of await group.getByRole('button').all()) { + await option.click(); + await expect + .poll(() => + group.evaluate((node) => { + const selected = node + .querySelector('button[aria-pressed="true"]')! + .getBoundingClientRect(); + const indicator = node + .querySelector('.ol-segmented-indicator')! + .getBoundingClientRect(); + const track = node.getBoundingClientRect(); + return Math.max( + Math.abs(selected.top - indicator.top), + Math.abs(selected.left - indicator.left), + Math.abs(selected.width - indicator.width), + Math.abs(selected.height - indicator.height), + Math.abs(indicator.top - track.top - (track.bottom - indicator.bottom)), + ); + }), + ) + .toBeLessThan(0.3); + } + } +} + +test('shared selectors remain centered at 100 and 110 percent; awareness cards are separated', async ({ + page, +}) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, 'platform', { value: 'MacIntel' }); + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)', + }); + Object.defineProperty(navigator, 'userAgentData', { value: undefined }); + }); + await page.emulateMedia({ colorScheme: 'light' }); + await page.goto('/'); + await page.getByRole('button', { name: '设置', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: '设置', exact: true }); + await openMicrophoneSettings(dialog); + await centered(dialog.getByRole('group', { name: '录音方式' })); + await page.screenshot({ path: test.info().outputPath('recording-centered.png') }); + await dialog.getByRole('button', { name: 'AI 与模型', exact: true }).click(); + await settleNavigation(page); + await centered(dialog.getByRole('group', { name: '识别管线' })); + await dialog.getByRole('button', { name: '外观与语言', exact: true }).click(); + await settleNavigation(page); + await centered(dialog.getByRole('group', { name: '主题' })); + await page.screenshot({ path: test.info().outputPath('theme-centered.png') }); + await dialog.getByRole('button', { name: '高级功能', exact: true }).click(); + await settleNavigation(page); + await dialog.locator('[data-ol-advanced-entry="cursorContext"]').click(); + await settleNavigation(page); + await expect(dialog.locator('.ol-awareness-sections')).toBeVisible(); + const gaps = await dialog.locator('.ol-awareness-sections').evaluate((node) => { + const cards = Array.from(node.children).map((child) => child.getBoundingClientRect()); + return cards.slice(1).map((card, i) => card.top - cards[i].bottom); + }); + expect(gaps.length).toBeGreaterThan(0); + for (const gap of gaps) expect(gap).toBeGreaterThanOrEqual(15.5); + await page.screenshot({ path: test.info().outputPath('awareness-separated.png') }); +}); diff --git a/openless-all/app/e2e/usage.spec.ts b/openless-all/app/e2e/usage.spec.ts index b327da229..0a76f47ef 100644 --- a/openless-all/app/e2e/usage.spec.ts +++ b/openless-all/app/e2e/usage.spec.ts @@ -1,15 +1,44 @@ import { expect, test } from '@playwright/test'; -test('usage describes retained history coverage and switches period', async ({ page }) => { +test('usage separates the dashboard, services and history', async ({ page }) => { await page.goto('/'); - await page.getByRole('button', { name: '用量', exact: true }).click(); + const usage = page.getByRole('button', { name: '用量', exact: true }); + const settings = page.getByRole('button', { name: '设置', exact: true }); + await expect(usage).toHaveCSS( + 'font-size', + await settings.evaluate((node) => getComputedStyle(node).fontSize), + ); + await usage.click(); await expect(page.getByRole('heading', { name: '用量', exact: true })).toBeVisible(); - await expect(page.getByText(/最多 200 条/)).toBeVisible(); await expect(page.getByText('语音识别记录')).toBeVisible(); await expect(page.getByText('TTS 用量', { exact: true })).toBeVisible(); await expect(page.getByText('已报告输入 Token', { exact: true })).toBeVisible(); - await expect(page.getByText('已报告输出 Token', { exact: true })).toBeVisible(); - await page.getByRole('button', { name: '清除已记录 Token', exact: true }).click(); + await expect(page.getByRole('heading', { name: '每日记录' })).toBeVisible(); + await expect(page.getByRole('heading', { name: '服务与用途' })).toHaveCount(0); + await expect(page.locator('[data-ol-page="usage"]')).toHaveAttribute( + 'data-transition-phase', + 'settled', + ); + await page.screenshot({ path: test.info().outputPath('usage-dashboard.png') }); + await page + .getByRole('navigation', { name: '用量页面' }) + .getByRole('button', { name: '服务与用途' }) + .click(); + await expect(page.getByRole('heading', { name: '服务与用途' })).toBeVisible(); + await expect(page.getByRole('heading', { name: '每日记录' })).toHaveCount(0); + await page + .getByRole('navigation', { name: '用量页面' }) + .getByRole('button', { name: '记录' }) + .click(); + const clear = page.getByRole('button', { name: '清除已记录 Token', exact: true }); + await expect(clear).toBeVisible(); + const dimensions = await clear.evaluate((node) => ({ + scroll: node.scrollHeight, + client: node.clientHeight, + })); + expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.client + 1); + await page.screenshot({ path: test.info().outputPath('usage-history.png') }); + await clear.click(); await expect(page.getByText('该时段没有已保存的服务商 token 报告。')).toBeVisible(); await page.getByRole('button', { name: '近 7 天' }).click(); await expect(page.getByRole('button', { name: '近 7 天' })).toHaveAttribute( diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index 5b3f09dc4..4bd4d532f 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -4124,25 +4124,8 @@ impl CoreTextInserter for TauriTextInserter { #[cfg(target_os = "windows")] let ime_target = crate::windows_ime_target::capture_ime_submit_target(); #[cfg(target_os = "macos")] - let live_capture = if self - .backend - .lock() - .as_ref() - .and_then(|b| b.upgrade()) - .is_some_and(|b| b.get_preferences().macos_composition_input) - { - let target = insertion_target.clone(); - Some(Arc::new(Mutex::new(Some( - tauri::async_runtime::spawn_blocking(move || { - if !crate::selection::selection_insertion_target_is_current(&target) { - return Err("live input target changed before capture".into()); - } - crate::host_document::LiveComposition::capture() - }), - )))) - } else { - None - }; + // Legacy macOS composition preferences must not start AX range capture. + let live_capture = None; Some(CapturedTextTarget { inserter: Arc::new(Self { insertion_target: Some(insertion_target.clone()), @@ -5005,17 +4988,6 @@ impl TauriTextInsertionSession { terminal .join_or_spawn( move || async move { - let live = Arc::clone(&self.live_composition); - let live_final = final_text.clone(); - let live_result = tauri::async_runtime::spawn_blocking(move || { - let mut guard = live.lock(); - match live_final { - Some(text) => Some(replace_live_composition(&mut guard, &text)), - None => guard.take().map(|mut live| live.cancel()), - } - }) - .await - .map_err(|e| BackendError::new(BackendErrorCode::Platform, e.to_string()))?; let worker = self.streaming_worker.clone(); let session = &self; finish_mac_insertion_after_barrier( @@ -5029,36 +5001,7 @@ impl TauriTextInsertionSession { if session.cancel_requested.load(Ordering::Acquire) { return Ok(MacInsertionCompletion::Cancelled); } - if let Some(result) = live_result { - return match result { - Ok(()) => Ok(MacInsertionCompletion::Finished( - InsertOutcome::Inserted, - )), - Err(_) => match final_text { - Some(text) => session - .copy_fallback(text) - .await - .map(MacInsertionCompletion::Finished), - None => Ok(MacInsertionCompletion::Cancelled), - }, - }; - } - if session.context.insertion.composition_input { - return match final_text { - Some(text) => session.copy_fallback(text).await.map(MacInsertionCompletion::Finished), - None => Ok(MacInsertionCompletion::Cancelled), - }; - } - match final_text { - Some(text) if !text.is_empty() => session - .insert_final(text) - .await - .map(MacInsertionCompletion::Finished), - Some(_) => { - Ok(MacInsertionCompletion::Finished(InsertOutcome::Inserted)) - } - None => Ok(MacInsertionCompletion::Cancelled), - } + finish_mac_final_text(final_text, |text| session.insert_final(text)).await }, || session.restore_platform_state(), ) @@ -5072,6 +5015,23 @@ impl TauriTextInsertionSession { } } +/// Core passes only the undelivered suffix here; an empty suffix is already delivered. +/// Final delivery must not probe AX composition support or replace earlier streamed text. +#[cfg(target_os = "macos")] +async fn finish_mac_final_text( + text: Option, + insert: impl FnOnce(String) -> F, +) -> Result +where + F: std::future::Future>, +{ + match text { + Some(text) if !text.is_empty() => insert(text).await.map(MacInsertionCompletion::Finished), + Some(_) => Ok(MacInsertionCompletion::Finished(InsertOutcome::Inserted)), + None => Ok(MacInsertionCompletion::Cancelled), + } +} + #[cfg(target_os = "macos")] fn replace_live_composition( slot: &mut Option, @@ -5265,6 +5225,37 @@ mod mac_insertion_lifecycle_tests { tokio::spawn(task); } + #[tokio::test] + async fn final_text_uses_normal_delivery() { + let calls = Mutex::new(Vec::new()); + let result = finish_mac_final_text(Some("final text".to_string()), |text| async { + calls.lock().push(text); + Ok(InsertOutcome::Inserted) + }) + .await + .unwrap(); + assert_eq!(result, MacInsertionCompletion::Finished(InsertOutcome::Inserted)); + assert_eq!(*calls.lock(), ["final text"]); + } + + #[tokio::test] + async fn fully_streamed_text_finishes_without_an_ax_probe_or_second_insertion() { + let result = finish_mac_final_text(Some(String::new()), |_| async { + panic!("already delivered text must not invoke insertion or clipboard fallback") + }) + .await + .unwrap(); + assert_eq!(result, MacInsertionCompletion::Finished(InsertOutcome::Inserted)); + assert_eq!( + finish_mac_final_text(None, |_| async { + panic!("cancelled text must not invoke insertion") + }) + .await + .unwrap(), + MacInsertionCompletion::Cancelled + ); + } + #[tokio::test] async fn worker_start_failure_restores_the_previous_source_once() { let restored = Arc::new(Mutex::new(Vec::new())); diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index 82dff12f4..af87b16ad 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -152,7 +152,6 @@ function FloatingShellBody({ const [providerPromptOpen, setProviderPromptOpen] = useState(false); const [hotkeyModePromptOpen, setHotkeyModePromptOpen] = useState(false); const settingsMount = useExitMount(settingsOpen); - const [settingsPainted, setSettingsPainted] = useState(false); const providerPromptMount = useExitMount(providerPromptOpen); const hotkeyPromptMount = useExitMount(hotkeyModePromptOpen); const [moreOpen, setMoreOpen] = useState(false); @@ -310,7 +309,6 @@ function FloatingShellBody({
{t('shell.betaTag')} @@ -483,7 +481,9 @@ function FloatingShellBody({
-
+
+ ))} +
{[7, 30].map((day) => ( - )} - {usage && ( -

- {text( - '清除前或清除期间已开始的请求可能不会计入新账本。', - 'Requests started before or during clearing may not appear in the new ledger.', - )} -

+ )} {error ? (

{text('无法读取历史记录。', 'Could not load history.')}

@@ -242,157 +269,172 @@ export function Usage() {

{text('加载中…', 'Loading…')}

) : ( <> -
- {stats.map(([label, value]) => ( - -
{label}
-
{value}
+ {view === 'overview' && ( + <> +
+ {stats.map(([label, value]) => ( + +
{label}
+
{value}
+
+ ))} +
+ +

+ {text('每日记录', 'Daily records')} +

+
+ {daily.map(({ date, count }) => ( +
+ ))} +
- ))} -
- -

- {text('每日记录', 'Daily records')} -

-
- {daily.map(({ date, count }) => ( -
- ))} -
- - -

- {text('服务与用途', 'Services and purposes')} -

- {grouped.size === 0 ? ( -

{text('没有可归属服务的记录。', 'No attributable records.')}

- ) : ( - [...grouped.entries()] - .sort((a, b) => b[1] - a[1]) - .map(([name, count]) => ( + + )} + {view === 'services' && ( + +

+ {text('服务与用途', 'Services and purposes')} +

+ {grouped.size === 0 ? ( +

{text('没有可归属服务的记录。', 'No attributable records.')}

+ ) : ( + [...grouped.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([name, count]) => ( +
+ {name} + {count} +
+ )) + )} +
+ )} + {view === 'history' && ( + <> + +

+ {text('已报告的模型用量', 'Reported model usage')} +

+

+ {text( + '缓存和推理字段由各服务商报告,可能已包含在输入或输出中,不能直接相加。', + 'Provider-reported cache and reasoning fields may already be included in input or output; do not add them directly.', + )} +

+ {byPurpose.map(({ purpose, input, output }) => (
- {name} - {count} + {purposeName(purpose)} + + {text('已报告输入', 'Reported input')}: {input?.toLocaleString() ?? '—'} ·{' '} + {text('已报告输出', 'Reported output')}: {output?.toLocaleString() ?? '—'} +
- )) - )} -
- -

- {text('已报告的模型用量', 'Reported model usage')} -

-

- {text( - '缓存和推理字段由各服务商报告,可能已包含在输入或输出中,不能直接相加。', - 'Provider-reported cache and reasoning fields may already be included in input or output; do not add them directly.', - )} -

- {byPurpose.map(({ purpose, input, output }) => ( -
- {purposeName(purpose)} - - {text('已报告输入', 'Reported input')}: {input?.toLocaleString() ?? '—'} ·{' '} - {text('已报告输出', 'Reported output')}: {output?.toLocaleString() ?? '—'} - -
- ))} - {reported.length === 0 ? ( -

- {text( - '该时段没有已保存的服务商 token 报告。', - 'No provider token reports were saved for this period.', + ))} + {reported.length === 0 ? ( +

+ {text( + '该时段没有已保存的服务商 token 报告。', + 'No provider token reports were saved for this period.', + )} +

+ ) : ( + [...reported] + .sort((a, b) => b.occurredAtMs - a.occurredAtMs) + .slice(0, 30) + .map((record) => ( +
+ + {purposeName(record.purpose)} + + {[ + ...new Set( + [record.provider, record.channel, record.model].filter(Boolean), + ), + ].join(' · ') || '—'} + + + {text('输入', 'Input')}: {safeTokens(record.inputTokens)} ·{' '} + {text('输出', 'Output')}: {safeTokens(record.outputTokens)} +
+ {text('缓存读取', 'Cache read')}: {safeTokens(record.cachedInputTokens)} ·{' '} + {text('缓存写入', 'Cache write')}:{' '} + {safeTokens(record.cacheWriteInputTokens)} +
+ {text('推理', 'Reasoning')}: {safeTokens(record.reasoningTokens)} +
+
+ )) )} -

- ) : ( - [...reported] - .sort((a, b) => b.occurredAtMs - a.occurredAtMs) - .slice(0, 30) - .map((record) => ( +
+ +

+ {text('最近记录', 'Recent records')} +

+ {rows.slice(0, 30).map((row) => (
- - {purposeName(record.purpose)} - - {[ - ...new Set([record.provider, record.channel, record.model].filter(Boolean)), - ].join(' · ') || '—'} - + - {text('输入', 'Input')}: {safeTokens(record.inputTokens)} ·{' '} - {text('输出', 'Output')}: {safeTokens(record.outputTokens)} -
- {text('缓存读取', 'Cache read')}: {safeTokens(record.cachedInputTokens)} ·{' '} - {text('缓存写入', 'Cache write')}: {safeTokens(record.cacheWriteInputTokens)} -
- {text('推理', 'Reasoning')}: {safeTokens(record.reasoningTokens)} + {[row.asrProvider, row.llmProvider].filter(Boolean).join(' · ') || '—'}
- )) - )} -
- -

- {text('最近记录', 'Recent records')} -

- {rows.slice(0, 30).map((row) => ( -
- - {[row.asrProvider, row.llmProvider].filter(Boolean).join(' · ') || '—'} -
- ))} -
+ ))} +
+ + )} )} - +
); } diff --git a/openless-all/app/src/pages/settings/navigation.ts b/openless-all/app/src/pages/settings/navigation.ts index 0df150d6c..908df3305 100644 --- a/openless-all/app/src/pages/settings/navigation.ts +++ b/openless-all/app/src/pages/settings/navigation.ts @@ -46,7 +46,7 @@ export type AdvancedPageId = AdvancedPage['id']; export function visibleAdvancedPages(platform: PlatformKind | undefined, os: OS): AdvancedPage[] { return ADVANCED_PAGES.filter((page) => { - if (page.id === 'liveInput') return platform === 'desktop' && (os === 'mac' || os === 'win'); + if (page.id === 'liveInput') return platform === 'desktop' && os === 'win'; if (page.id === 'localModels') return platform === 'desktop' && os === 'mac'; if (page.id === 'debug') return platform === 'desktop' || platform === 'android' || platform === 'harmony'; diff --git a/openless-all/app/src/pages/settings/tabs.tsx b/openless-all/app/src/pages/settings/tabs.tsx index d4558b8aa..0cf2727a7 100644 --- a/openless-all/app/src/pages/settings/tabs.tsx +++ b/openless-all/app/src/pages/settings/tabs.tsx @@ -621,35 +621,21 @@ function CursorContextSection() { function LiveInputSection() { const { t } = useTranslation(); const { prefs, updatePrefs } = useHotkeySettings(); - const os = detectOS(); - if (!prefs || (os !== 'mac' && os !== 'win')) return null; - const mac = os === 'mac'; + if (!prefs || detectOS() !== 'win') return null; return ( {t('settings.advancedPages.liveInputTitle')} - void updatePrefs((current) => - mac - ? { ...current, macosCompositionInput: next } - : { ...current, windowsCompositionInput: next }, - ) + void updatePrefs((current) => ({ ...current, windowsCompositionInput: next })) } /> @@ -748,14 +734,17 @@ export function AdvancedTab({ {item.id === 'voiceEnhancement' && } {item.id === 'liveInput' && } {item.id === 'cursorContext' && ( - <> +
{showsCursorContextControl(platformCaps?.platform, detectOS()) && ( )} {platformCaps?.platform === 'desktop' && (detectOS() === 'mac' || detectOS() === 'win') && } - +
)} {item.id === 'startupDiagnostics' && } {item.id === 'customSyncServer' && page === item.id && } diff --git a/openless-all/app/src/styles/global.css b/openless-all/app/src/styles/global.css index e2251407d..d9993505d 100644 --- a/openless-all/app/src/styles/global.css +++ b/openless-all/app/src/styles/global.css @@ -1093,7 +1093,7 @@ html[data-ol-conservative-layout='true'] .ol-conservative-scope .ol-select-lite- flex-wrap: wrap; gap: 2px; max-width: 100%; - padding: 2.5px; + padding: 3px; border: 0; box-shadow: inset 0 0 0 0.5px var(--ol-line-strong); border-radius: var(--ol-control-radius); @@ -1104,7 +1104,7 @@ html[data-ol-conservative-layout='true'] .ol-conservative-scope .ol-select-lite- border: 0; box-shadow: inset 0 0 0 0.5px var(--ol-line-strong); } -.ol-service-pipeline-track > :is(.ol-segmented-indicator, .ol-segmented-option) { +.ol-segmented > :is(.ol-segmented-indicator, .ol-segmented-option) { --ol-corner-radius: calc(var(--ol-control-radius, 8px) - 3px); border-radius: var(--ol-corner-radius); } From f0ed5b82e95011a21f966d8b0f3efd2946f2f242 Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Mon, 9 Nov 2026 13:46:28 +0800 Subject: [PATCH 21/22] test(settings): align navigation with awareness and live-input availability --- .../app/src/pages/settings/navigation.test.ts | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/openless-all/app/src/pages/settings/navigation.test.ts b/openless-all/app/src/pages/settings/navigation.test.ts index 8e9ff19b6..344c56076 100644 --- a/openless-all/app/src/pages/settings/navigation.test.ts +++ b/openless-all/app/src/pages/settings/navigation.test.ts @@ -94,14 +94,7 @@ assert.equal( ); assert.deepEqual( visibleAdvancedPages('android', 'android').map((item) => item.id), - [ - 'voiceEnhancement', - 'cursorContext', - 'startupDiagnostics', - 'vocabularyLearning', - 'customSyncServer', - 'debug', - ], + ['voiceEnhancement', 'cursorContext', 'startupDiagnostics', 'customSyncServer', 'debug'], 'the pipeline switch lives in Services on every platform', ); console.log('settings navigation tests passed'); @@ -127,8 +120,29 @@ assert.equal( const advancedIds: readonly string[] = ADVANCED_PAGES.map((item) => item.id); assert.equal( advancedIds.includes('vocabularyLearning'), + false, + 'correction learning shares the awareness page rather than a duplicate menu entry', +); + +assert.equal( + advancedIds.includes('cursorContext'), + true, + 'awareness remains reachable from advanced features', +); +assert.equal( + visibleAdvancedPages('desktop', 'mac').some((item) => item.id === 'liveInput'), + false, + 'retired macOS in-field streaming is not offered', +); +assert.equal( + visibleAdvancedPages('desktop', 'win').some((item) => item.id === 'liveInput'), true, - 'manual correction learning remains reachable from advanced features', + 'Windows retains its advanced text insertion page', +); +assert.equal( + visibleAdvancedPages('android', 'android').some((item) => item.id === 'liveInput'), + false, + 'Windows text insertion is not offered to Android', ); const sectionIds = SETTINGS_SECTIONS.map((item) => item.id); From 4afcac7c54ee85beee33b128aa0001875afac270 Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Mon, 9 Nov 2026 13:54:00 +0800 Subject: [PATCH 22/22] refine(usage): bound record scrolling and compact reported metrics --- openless-all/app/e2e/usage.spec.ts | 52 +++ .../app/src/lib/serviceUsageMetrics.test.ts | 42 ++- .../app/src/lib/serviceUsageMetrics.ts | 35 ++ openless-all/app/src/pages/Usage.tsx | 341 ++++++++++-------- openless-all/app/src/pages/usage.css | 51 +++ 5 files changed, 377 insertions(+), 144 deletions(-) create mode 100644 openless-all/app/src/pages/usage.css diff --git a/openless-all/app/e2e/usage.spec.ts b/openless-all/app/e2e/usage.spec.ts index 0a76f47ef..30565f431 100644 --- a/openless-all/app/e2e/usage.spec.ts +++ b/openless-all/app/e2e/usage.spec.ts @@ -32,6 +32,47 @@ test('usage separates the dashboard, services and history', async ({ page }) => .click(); const clear = page.getByRole('button', { name: '清除已记录 Token', exact: true }); await expect(clear).toBeVisible(); + const period = page.getByRole('group', { name: '统计时段' }); + await expect(period).toBeVisible(); + const positions = await clear.evaluate((node) => { + const button = node.getBoundingClientRect(); + const toolbar = node.closest('.ol-usage-toolbar')!.getBoundingClientRect(); + const segment = node + .closest('.ol-usage-toolbar')! + .querySelector('.ol-segmented')! + .getBoundingClientRect(); + return { + right: toolbar.right - button.right, + center: Math.abs(button.top + button.bottom - (segment.top + segment.bottom)) / 2, + }; + }); + expect(positions.right).toBeLessThan(1); + expect(positions.center).toBeLessThan(1); + await page.setViewportSize({ width: 960, height: 600 }); + await page.locator('.ol-usage-page').evaluate((node) => { + node.style.zoom = '1.1'; + }); + const recent = page.getByRole('region', { name: '最近记录列表' }); + await expect(recent).toBeVisible(); + await recent.scrollIntoViewIfNeeded(); + const bounds = await recent.evaluate((node) => ({ + height: node.clientHeight, + content: node.scrollHeight, + overflow: getComputedStyle(node).overflowY, + })); + expect(bounds.overflow).toBe('auto'); + expect(bounds.height).toBeLessThan(400); + expect(bounds.content).toBeGreaterThan(bounds.height); + await recent.hover(); + await page.mouse.wheel(0, 160); + await expect.poll(() => recent.evaluate((node) => node.scrollTop)).toBeGreaterThan(0); + await page.emulateMedia({ colorScheme: 'light' }); + await page.screenshot({ path: test.info().outputPath('usage-history-light-short.png') }); + await page.setViewportSize({ width: 1300, height: 835 }); + await page.locator('.ol-usage-page').evaluate((node) => { + node.style.zoom = '1'; + }); + const dimensions = await clear.evaluate((node) => ({ scroll: node.scrollHeight, client: node.clientHeight, @@ -45,4 +86,15 @@ test('usage separates the dashboard, services and history', async ({ page }) => 'aria-pressed', 'true', ); + await page + .getByRole('navigation', { name: '用量页面' }) + .getByRole('button', { name: '概览', exact: true }) + .click(); + await expect(page.getByRole('heading', { name: '每日记录' })).toBeVisible(); + await expect + .poll(() => + page.locator('.ol-usage-chart-dates').evaluate((node) => node.getBoundingClientRect().bottom), + ) + .toBeLessThan(835); + await page.screenshot({ path: test.info().outputPath('usage-dashboard-light.png') }); }); diff --git a/openless-all/app/src/lib/serviceUsageMetrics.test.ts b/openless-all/app/src/lib/serviceUsageMetrics.test.ts index 5afba6be7..c17b96308 100644 --- a/openless-all/app/src/lib/serviceUsageMetrics.test.ts +++ b/openless-all/app/src/lib/serviceUsageMetrics.test.ts @@ -1,4 +1,8 @@ -import { summarizeReportedUsage } from './serviceUsageMetrics'; +import { + summarizeReportedUsage, + formatUsageNumber, + reportedCacheHitRate, +} from './serviceUsageMetrics'; import type { ServiceUsageRecord } from './ipc/service-usage'; function equal(actual: unknown, expected: unknown, label: string) { @@ -56,3 +60,39 @@ equal( 'unsafe sums are unknown', ); console.log('reported service usage metrics tests passed'); + +for (const [value, expected] of [ + [null, '—'], + [-1, '—'], + [999, '999'], + [1000, '1K'], + [12500, '12.5K'], + [999999, '1M'], + [1250000, '1.3M'], + [2300000000, '2.3B'], + [Number.MAX_SAFE_INTEGER + 1, '—'], +] as const) { + equal(formatUsageNumber(value), expected, `compact number ${value}`); +} +equal( + reportedCacheHitRate([ + make({ provider: 'deepseek', inputTokens: 100, cachedInputTokens: 80 }), + make({ provider: 'openai', inputTokens: 300, cachedInputTokens: 0 }), + ]), + 0.2, + 'cache rate weights compatible requests by reported input', +); +for (const record of [ + make({ provider: 'anthropic', inputTokens: 100, cachedInputTokens: 80 }), + make({ provider: 'openai', inputTokens: 100, cachedInputTokens: null }), + make({ provider: 'openai', inputTokens: 0, cachedInputTokens: 0 }), + make({ provider: 'openai', inputTokens: 100, cachedInputTokens: 101 }), + make({ provider: 'openai', inputTokens: 100, cachedInputTokens: 80, cacheWriteInputTokens: 0 }), +]) { + equal( + reportedCacheHitRate([record]), + null, + 'unknown, additive or invalid cache reports do not create a rate', + ); +} +console.log('compact usage numbers and reported cache rate tests passed'); diff --git a/openless-all/app/src/lib/serviceUsageMetrics.ts b/openless-all/app/src/lib/serviceUsageMetrics.ts index 0de49a345..409497ac9 100644 --- a/openless-all/app/src/lib/serviceUsageMetrics.ts +++ b/openless-all/app/src/lib/serviceUsageMetrics.ts @@ -41,3 +41,38 @@ export function summarizeReportedUsage( byPurpose: [...groups].map(([purpose, group]) => ({ purpose, ...summarize(group) })), }; } + +export function formatUsageNumber(value: number | null | undefined): string { + if (value == null || !Number.isSafeInteger(value) || value < 0) return '—'; + if (value < 1_000) return String(value); + const units = [ + [1_000_000_000, 'B'], + [1_000_000, 'M'], + [1_000, 'K'], + ] as const; + // Choose the unit after rounding so 999,999 becomes 1M, never 1000K. + for (let index = 0; index < units.length; index++) { + const [divisor, suffix] = units[index]; + if (value >= divisor || (index < units.length - 1 && value / divisor >= 0.99995)) { + return `${Number((value / divisor).toFixed(1))}${suffix}`; + } + } + return String(value); +} + +export function reportedCacheHitRate(records: ServiceUsageRecord[]): number | null { + const compatible = records.filter( + (record) => + ['openai', 'deepseek'].includes(record.provider?.toLowerCase() ?? '') && + record.cacheWriteInputTokens == null && + Number.isSafeInteger(record.inputTokens) && + record.inputTokens! > 0 && + Number.isSafeInteger(record.cachedInputTokens) && + record.cachedInputTokens! >= 0 && + record.cachedInputTokens! <= record.inputTokens!, + ); + if (!compatible.length) return null; + const input = sumKnown(compatible.map((record) => record.inputTokens)); + const cached = sumKnown(compatible.map((record) => record.cachedInputTokens)); + return input !== null && input > 0 && cached !== null ? cached / input : null; +} diff --git a/openless-all/app/src/pages/Usage.tsx b/openless-all/app/src/pages/Usage.tsx index c9026313e..aa2031557 100644 --- a/openless-all/app/src/pages/Usage.tsx +++ b/openless-all/app/src/pages/Usage.tsx @@ -7,7 +7,13 @@ import { type ServiceUsageSnapshot, } from '../lib/ipc/service-usage'; import type { DictationSession } from '../lib/types'; -import { summarizeReportedUsage } from '../lib/serviceUsageMetrics'; +import { + summarizeReportedUsage, + formatUsageNumber, + reportedCacheHitRate, +} from '../lib/serviceUsageMetrics'; +import { Segmented } from '../components/ui/Segmented'; +import './usage.css'; import { Btn, Card, PageHeader } from './_atoms'; export function Usage() { @@ -89,16 +95,43 @@ export function Usage() { const label = labels[purpose]; return label ? text(...label) : text('其他', 'Other'); }; - const safeTokens = (value: number | null) => - value !== null && Number.isSafeInteger(value) && value >= 0 ? value.toLocaleString() : '—'; + const safeTokens = (value: number | null) => ( + = 0 + ? value.toLocaleString() + : undefined + } + > + {formatUsageNumber(value)} + + ); + const cacheRate = reportedCacheHitRate(reported); const seconds = rows.reduce((total, row) => total + (row.durationMs ?? 0), 0) / 1000; - const stats = [ - [text('语音识别记录', 'ASR records'), asr.length], - [text('文字处理记录', 'Text processing records'), llm.length], - [text('多模态记录', 'Multimodal records'), omni.length], + const stats: Array<[string, string, string?]> = [ + [text('语音识别记录', 'ASR records'), formatUsageNumber(asr.length), String(asr.length)], + [ + text('文字处理记录', 'Text processing records'), + formatUsageNumber(llm.length), + String(llm.length), + ], + [text('多模态记录', 'Multimodal records'), formatUsageNumber(omni.length), String(omni.length)], [text('TTS 用量', 'TTS usage'), '—'], - [text('已报告输入 Token', 'Reported input tokens'), totals.input?.toLocaleString() ?? '—'], - [text('已报告输出 Token', 'Reported output tokens'), totals.output?.toLocaleString() ?? '—'], + [ + text('已报告输入 Token', 'Reported input tokens'), + formatUsageNumber(totals.input), + totals.input?.toLocaleString(), + ], + [ + text('已报告输出 Token', 'Reported output tokens'), + formatUsageNumber(totals.output), + totals.output?.toLocaleString(), + ], + [ + text('缓存命中率', 'Cache hit rate'), + cacheRate === null ? '—' : `${(cacheRate * 100).toFixed(1)}%`, + text('可比较记录的缓存命中率', 'Cache hit rate for comparable reported calls'), + ], [ text('录音时长', 'Recording duration'), rows.some((row) => row.durationMs == null) @@ -142,6 +175,34 @@ export function Usage() { }; }); const maxDaily = Math.max(1, ...daily.map((day) => day.count)); + const clearControl = view === 'history' && usage && ( + { + clearingRef.current = true; + request.current += 1; + setClearing(true); + setClearError(false); + try { + await clearServiceUsage(); + if (mounted.current) setUsage({ records: [], droppedOrFailed: usage.droppedOrFailed }); + } catch { + if (mounted.current) setClearError(true); + } finally { + clearingRef.current = false; + if (mounted.current) setClearing(false); + } + }} + > + {text('清除已记录 Token', 'Clear recorded tokens')} + + ); return (
))} -
- {[7, 30].map((day) => ( - - ))} +
+ setDays(Number(value))} + /> + {clearControl}
{usageError && (

@@ -233,36 +286,6 @@ export function Usage() { )}

)} - {view === 'history' && usage && ( - { - clearingRef.current = true; - request.current += 1; - setClearing(true); - setClearError(false); - try { - await clearServiceUsage(); - if (mounted.current) - setUsage({ records: [], droppedOrFailed: usage.droppedOrFailed }); - } catch { - if (mounted.current) setClearError(true); - } finally { - clearingRef.current = false; - if (mounted.current) setClearing(false); - } - }} - > - {text('清除已记录 Token', 'Clear recorded tokens')} - - )} {error ? (

{text('无法读取历史记录。', 'Could not load history.')}

) : history === null ? ( @@ -278,10 +301,12 @@ export function Usage() { gap: 12, }} > - {stats.map(([label, value]) => ( + {stats.map(([label, value, exact]) => (
{label}
-
{value}
+
+ {value} +
))}
@@ -289,7 +314,7 @@ export function Usage() {

{text('每日记录', 'Daily records')}

-
+
{daily.map(({ date, count }) => (
))}
+
+ + {daily[0].date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + })} + + + {daily[daily.length - 1].date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + })} + +
)} @@ -336,8 +375,8 @@ export function Usage() { )} {view === 'history' && ( - <> - +
+

{text('已报告的模型用量', 'Reported model usage')}

@@ -347,91 +386,107 @@ export function Usage() { 'Provider-reported cache and reasoning fields may already be included in input or output; do not add them directly.', )}

- {byPurpose.map(({ purpose, input, output }) => ( -
- {purposeName(purpose)} - - {text('已报告输入', 'Reported input')}: {input?.toLocaleString() ?? '—'} ·{' '} - {text('已报告输出', 'Reported output')}: {output?.toLocaleString() ?? '—'} - -
- ))} - {reported.length === 0 ? ( -

- {text( - '该时段没有已保存的服务商 token 报告。', - 'No provider token reports were saved for this period.', - )} -

- ) : ( - [...reported] - .sort((a, b) => b.occurredAtMs - a.occurredAtMs) - .slice(0, 30) - .map((record) => ( -
- - {purposeName(record.purpose)} - - {[ - ...new Set( - [record.provider, record.channel, record.model].filter(Boolean), - ), - ].join(' · ') || '—'} - - - {text('输入', 'Input')}: {safeTokens(record.inputTokens)} ·{' '} - {text('输出', 'Output')}: {safeTokens(record.outputTokens)} -
- {text('缓存读取', 'Cache read')}: {safeTokens(record.cachedInputTokens)} ·{' '} - {text('缓存写入', 'Cache write')}:{' '} - {safeTokens(record.cacheWriteInputTokens)} -
- {text('推理', 'Reasoning')}: {safeTokens(record.reasoningTokens)} -
-
- )) - )} +
+ {byPurpose.map(({ purpose, input, output }) => ( +
+ {purposeName(purpose)} + + {text('已报告输入', 'Reported input')}: {safeTokens(input)} ·{' '} + {text('已报告输出', 'Reported output')}: {safeTokens(output)} + +
+ ))} + {reported.length === 0 ? ( +

+ {text( + '该时段没有已保存的服务商 token 报告。', + 'No provider token reports were saved for this period.', + )} +

+ ) : ( + [...reported] + .sort((a, b) => b.occurredAtMs - a.occurredAtMs) + .slice(0, 30) + .map((record) => ( +
+ + {purposeName(record.purpose)} + + {[ + ...new Set( + [record.provider, record.channel, record.model].filter(Boolean), + ), + ].join(' · ') || '—'} + + + {text('输入', 'Input')}: {safeTokens(record.inputTokens)} ·{' '} + {text('输出', 'Output')}: {safeTokens(record.outputTokens)} +
+ {text('缓存读取', 'Cache read')}: {safeTokens(record.cachedInputTokens)}{' '} + · {text('缓存写入', 'Cache write')}:{' '} + {safeTokens(record.cacheWriteInputTokens)} +
+ {text('推理', 'Reasoning')}: {safeTokens(record.reasoningTokens)} +
+
+ )) + )} +
- +

{text('最近记录', 'Recent records')}

- {rows.slice(0, 30).map((row) => ( -
- - - {[row.asrProvider, row.llmProvider].filter(Boolean).join(' · ') || '—'} - -
- ))} +
+ {rows.map((row) => ( +
+ + + {[row.asrProvider, row.llmProvider].filter(Boolean).join(' · ') || '—'} + +
+ ))} +
- +
)} )} diff --git a/openless-all/app/src/pages/usage.css b/openless-all/app/src/pages/usage.css new file mode 100644 index 000000000..68ec3c387 --- /dev/null +++ b/openless-all/app/src/pages/usage.css @@ -0,0 +1,51 @@ +.ol-usage-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 8px 0 16px; +} +.ol-usage-chart { + display: flex; + align-items: flex-end; + gap: 4px; + height: clamp(120px, calc(100dvh - 650px), 240px); +} +.ol-usage-chart-dates { + display: flex; + justify-content: space-between; + margin-top: 10px; + color: var(--ol-ink-3); + font-size: 12px; +} +.ol-usage-history-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} +.ol-usage-record-card { + display: flex; + flex-direction: column; + min-width: 0; + height: clamp(260px, calc(100dvh - 320px), 650px); + overflow: hidden; +} +.ol-usage-record-card > h2, +.ol-usage-record-card > p { + flex-shrink: 0; +} +.ol-usage-record-scroll { + min-height: 0; + flex: 1; + overflow: auto; + overscroll-behavior: contain; + overflow-wrap: anywhere; +} +@media (max-width: 1000px) { + .ol-usage-history-grid { + grid-template-columns: minmax(0, 1fr); + } + .ol-usage-record-card { + height: clamp(240px, 55dvh, 480px); + } +}