refactor(app): 清理废弃代码并明确模块注释

This commit is contained in:
sim committed 2026-09-08 14:27:29 +08:00
1 parent 7ae366e2aa
commit d686406a70
60 files changed
+165 -998

No files matched your search

@@ -64,12 +64,6 @@ class OpenLessAccessibilityCommandReceiver : BroadcastReceiver() {
const val EXTRA_RESULT_REASON = "result_reason"
const val EXTRA_PASTE_TEXT = "paste_text"
const val EXTRA_SELECTED_TEXT = "selected_text"
/** @deprecated Use [AccessibilityPasteResult] codes */
const val EXTRA_PASTE_RESULT = "paste_result"
/** @deprecated Use [AccessibilityPasteResult.SUCCESS.code] */
const val RESULT_PASTE_SUCCESS = 1
/** @deprecated Use failure codes from [AccessibilityPasteResult] */
const val RESULT_PASTE_FAILED = 4
private const val TAG = "OpenLessA11yCommand"
}
}
@@ -463,12 +463,6 @@ class OpenLessAccessibilityService : AccessibilityService() {
return pingResult == AccessibilityPasteResult.SUCCESS
}
/** @deprecated Use [pingAccessibilityProcess] for UI; paste no longer gates on this. */
@JvmStatic
fun isOperational(context: Context): Boolean {
return pingAccessibilityProcess(context)
}
internal fun performPasteFromCommand(pasteText: String? = null): AccessibilityPasteResult {
return instance?.performPasteToFocusedFieldInternal(pasteText)
?: AccessibilityPasteResult.SERVICE_NOT_CONNECTED
@@ -1,9 +1,9 @@
#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))]
//! Volcengine SAUC bigmodel streaming ASR client.
//!
//! Direct port of the Swift `VolcengineStreamingASR`. Battle-tested protocol
//! quirks are preserved verbatim — see comments tagged with `[asr]` for the
//! original learnings (especially the "definite=true is NOT stream end" bug).
//! Sends PCM frames, combines recognition updates and waits for the protocol's
//! final response. A `definite=true` utterance commits that segment only; it does
//! not end the stream or prevent later audio from being sent.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
@@ -173,7 +173,14 @@ impl StylePackStore {
Ok(updated)
}
/// Store a rasterized user icon without accepting caller-supplied file paths.
/// Save a PNG icon in this pack's owned asset directory, or clear it with `None`.
///
/// Metadata is committed before the old file is removed. A failed metadata
/// write leaves the previous image and in-memory pack unchanged.
///
/// # Errors
/// Returns an error for an unknown/invalid pack ID, an invalid PNG, an image
/// over 64 KiB, an unavailable asset directory, or a failed filesystem write.
pub fn update_icon(&self, id: &str, png: Option<&[u8]>) -> Result<StylePack, BackendError> {
if id.is_empty()
|| id == "."
@@ -247,7 +254,13 @@ impl StylePackStore {
Ok(saved)
}
/// Return only bounded, validated image data from this repository's assets.
/// Read a pack icon as a PNG/JPEG/WebP data URL without exposing filesystem access.
///
/// Missing icons return `None`. Reads are limited to 64 KiB and must resolve
/// inside the repository's asset directory.
///
/// # Errors
/// Returns an error for an unknown pack, an invalid image or path, or a read failure.
pub fn icon_data_url(&self, id: &str) -> Result<Option<String>, BackendError> {
let pack = self.get(id)?;
let Some(path) = pack.icon_path else {
+7 -9
View File
@@ -1,9 +1,8 @@
//! Linux host seam for the egui frontend.
//! Linux platform adapters and runtime coordination for the egui frontend.
//!
//! The egui team owns the `eframe::App` and all visual/UI code. This crate is
//! intentionally a small host adapter: it re-exports the core contract and
//! provides a place for Linux window, tray, input and resource adapters to be
//! added without making the core depend on egui or Tauri.
//! `main.rs` owns the egui UI. This library constructs Core with Linux audio,
//! credentials, input, settings and resource adapters, then forwards commands
//! and semantic events between that backend and the UI.
mod audio;
mod backend;
@@ -48,11 +47,10 @@ pub use single_instance::{
pub use openless_core::contract::*;
/// Construction seam reserved for the Linux host implementation.
/// Coordinates Core operations with Linux settings and recording lifecycles.
///
/// Keeping this as a named type gives the egui package a stable home for
/// platform adapters while the UI is developed independently. No window or
/// egui object is stored here.
/// Capture state binds recorder callbacks to their owning Core session. Window
/// objects and egui widgets stay in the frontend.
pub struct LinuxHost {
backend: std::sync::Arc<OpenLessBackend>,
settings_runtime: std::sync::Arc<dyn SettingsRuntime>,
@@ -1,22 +0,0 @@
import * as esbuild from 'esbuild';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const tmp = await mkdtemp(join(tmpdir(), 'openless-hotkey-recorder-'));
const outfile = join(tmp, 'hotkey-recorder-test.mjs');
try {
await esbuild.build({
entryPoints: [fileURLToPath(new URL('../src/lib/hotkeyRecorder.test.ts', import.meta.url))],
outfile,
bundle: true,
platform: 'node',
format: 'esm',
logLevel: 'silent',
});
await import(pathToFileURL(outfile).href);
} finally {
await rm(tmp, { recursive: true, force: true });
}
@@ -13,7 +13,7 @@ for (const expected of [
'scripts/macos-speech-usage-description-contract.test.mjs',
'scripts/repository-owner-contract.test.mjs',
'scripts/windows-ui-config.test.mjs',
'src/lib/hotkeyRecorder.test.ts',
'src/lib/hotkeySideModifiers.test.ts',
'src/lib/windowHotkeyFallback.test.ts',
]) {
assert(discovered.includes(expected), `aggregate discovery omitted ${expected}`);
@@ -1,4 +1,6 @@
#![allow(dead_code, unused_variables)] // Task 6 接入 coordinator 后这些路径会变成运行时路径。
#![allow(dead_code, unused_variables)]
//! Foundry Local Whisper 的录音缓冲与分片转写适配。
//! Windows 路径执行原生推理;其他目标保留类型兼容分支。
#[cfg(target_os = "windows")]
use std::fs::{self, OpenOptions};
@@ -108,9 +108,7 @@ pub(crate) const FOUNDRY_FALLBACK_TERMINAL_USER_MESSAGE: &str =
"本地识别失败: GPU 识别异常,且 CPU 回退未能完成(详情见日志)";
#[cfg(target_os = "windows")]
// PR #945 review P2-1:transcribe_audio_file 死代码已删除;此 allow 仍需保留,
// 覆盖 ensure_loaded 等其他 pre-existing 死代码(删除它们涉及公共 API 形状变更,
// 留作后续跟进)。
// Windows 原生运行时模块同时暴露预加载与转写接口,调用入口按目标配置选择。
#[allow(dead_code)]
mod imp {
use super::{FoundryPrimaryRecoveryToken, FoundryRouteEpoch};
@@ -1,7 +1,7 @@
//! vendored Open-Less/qwen-asr 的安全 Rust 包装。
//!
//! 当前只暴露**最小可用面**:`load` / `transcribe_audio` / `transcribe_stream`
//! + token 回调。后续接 coordinator 时再扩 prompt/language 设置。
//! 管理模型 context、批处理/流式转写和 token 回调;同一 context 的原生调用
//! 由 run_lock 串行化,取消后的 worker 返回前也不能复用该 context。
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_void};
@@ -208,7 +208,7 @@ fn copy_recording_to_mobile_url(
/// 流程:读 `recordings/<id>.wav` → 取 PCM(跳过 44 字节 WAV 头)→ 现 provider 重转
/// → 成功则原地回写该条历史的 rawTranscript / finalText、清除 error_code,返回新文本。
///
/// 仅做 ASR,不自动二次润色(润色依赖 LLM 凭据且 issue 标为待定,留作后续)。失败时
/// 仅重新转写音频,不调用 LLM 润色。失败时
/// 不动历史、不删录音,把错误返回给前端提示,用户可重试。返回更新后的整条记录给前端
/// 局部刷新。
#[tauri::command]
@@ -271,8 +271,3 @@ pub async fn debug_read_cursor_context(
);
result
}
// ─────────────────────────── unused but exported (silences dead_code) ───────────────────────────
#[allow(dead_code)]
fn _ensure_snapshot_used(_: CredentialsSnapshot) {}
+5 -12
View File
@@ -1,12 +1,8 @@
#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))]
//! Tauri command surface — every IPC entry the React UI invokes lives here.
//! Tauri IPC 命令入口,按设置、凭据、历史等领域拆分子模块。
//!
//! issue: 历史上整个 IPC 表(127 个 `#[tauri::command]` + 跨域 helper)挤在单个
//! 4800 行的 `commands.rs` 里。按单一职责拆成 `commands/` 下的域模块(settings /
//! credentials / providers / history / …),每个文件聚焦一个领域。对外路径保持不变:
//! 本模块用 `pub use <domain>::*` glob 重导出每个子模块,`commands::<name>` 仍然解析,
//! `lib.rs` 的 `generate_handler!` 清单与类型引用零改动。`#[tauri::command]` 生成的
//! `__cmd__<name>` 伴生项也随 glob 一并重导出——这是 Tauri 拆分命令文件的标准做法。
//! 子模块的命令及宏生成的伴生项通过 glob 重导出,供 `lib.rs` 的
//! `generate_handler!` 以 `commands::<name>` 注册。平台条件须与注册清单保持一致。
use std::sync::Arc;
@@ -16,8 +12,7 @@ use parking_lot::Mutex;
use tauri::Manager;
use tauri::State;
// 跨域共享的 crate 级导入:以 `pub(crate) use` 重导出,子模块用 `use super::*;`
// 即可拿到,避免在 16 个文件里重复同一组 import。
// 子模块通过 use super::* 使用共享的 Host 状态、DTO 和依赖类型。
pub(crate) use serde::Serialize;
pub(crate) use serde_json::Value;
pub(crate) use tauri::{AppHandle, Emitter, Window};
@@ -100,9 +95,6 @@ pub use qa::*;
#[cfg(not(mobile))]
pub use remote_input::*;
pub use settings::*;
// sherpa_onnx_asr_* 命令整组 `#[cfg(target_os = "windows")]`(见 lib.rs 的
// generate_handler! 清单)。非 Windows 平台这组 glob 重导出无人引用,会触发
// unused_imports;这是平台 cfg 的正常结果,不是真正的死代码。
#[cfg(all(not(mobile), debug_assertions))]
pub use selection_polish::*;
#[cfg(not(mobile))]
@@ -110,6 +102,7 @@ pub use selection_polish_preview::*;
#[cfg(all(not(mobile), target_os = "windows"))]
pub use selection_voice::*;
#[cfg(not(mobile))]
// Sherpa 命令只在 Windows 注册;其他桌面目标保留空重导出。
#[allow(unused_imports)]
pub use sherpa_asr::*;
pub use style_packs::*;
@@ -184,7 +184,6 @@ pub(crate) fn persist_strict_settings(
pub async fn set_settings(
coord: CoordinatorState<'_>,
app: AppHandle,
tray_microphones: State<'_, TrayMicrophoneMenuState>,
mut prefs: UserPreferences,
) -> Result<(), String> {
// 捕获旧值用于远程输入服务的 diff(persist 后端口/开关变化时启停/重启)。
@@ -226,9 +225,6 @@ pub async fn set_settings(
);
}
});
// 抑制 unused 警告:tray_microphones 现在改在闭包里通过 app.state 取,
// 但函数签名保留 State 入参,以便 Tauri 在调用前注入。
let _ = tray_microphones;
// 远程输入:开关 / 端口变化时启停或重启服务(PIN 变化走 regenerate_remote_pin 命令)。
if remote_prev.remote_input_enabled != prefs.remote_input_enabled
|| remote_prev.remote_input_port != prefs.remote_input_port
+3 -2
View File
@@ -963,7 +963,7 @@ mod macos_ax {
buffer: *mut c_char,
buffer_size: isize,
encoding: u32,
) -> bool;
) -> u8;
fn CFStringGetLength(s: CFStringRef) -> isize;
fn CFStringGetMaximumSizeForEncoding(length: isize, encoding: u32) -> isize;
}
@@ -1053,7 +1053,8 @@ mod macos_ax {
max_bytes,
K_CF_STRING_ENCODING_UTF8,
);
if !ok {
// CoreFoundation's Boolean is an unsigned byte in the SDK ABI.
if ok == 0 {
return None;
}
let cstr = CStr::from_ptr(buf.as_ptr() as *const c_char);
+2 -5
View File
@@ -21,11 +21,8 @@ import {
} from './lib/windowHotkeyFallback';
import { HotkeySettingsProvider } from './state/HotkeySettingsContext';
// 各窗口/重页面懒加载,让每个 webview 只下载并解析自己用到的那部分代码。原本所有窗口
// (主设置 / 胶囊 / QA / Less Computer / glow)共用一个打包产物,导致 5 个常驻 WebKit
// 进程都把整套设置 UI(FloatingShell + Style/Marketplace/LocalAsr…)和聊天面板加载进来,
// 常驻内存离谱。拆开后胶囊/glow 这类轻窗口不再加载设置/聊天代码。胶囊保持 eager:
// 它是听写实时反馈、对首帧延迟敏感,且体积很小。
// 各 WebView 按窗口用途懒加载页面,减少常驻内存。
// 胶囊承担录音的即时反馈,体积较小且对首帧延迟敏感,因此保持直接导入。
const AutoUpdateGate = lazy(() =>
import('./components/AutoUpdateGate').then(m => ({ default: m.AutoUpdateGate })),
);
@@ -1,10 +1,7 @@
// FloatingShell.tsx — frosted outer frame + raised inner console.
// Sidebar lives INSIDE the console card.
// Settings opens as a centered modal sheet from the sidebar bottom entry.
//
// Ported verbatim from design_handoff_openless/variants.jsx::FloatingShell.
// 主窗口外壳:组织侧栏导航、页面切换、移动端面板和设置弹窗。
// 各页面共享偏好状态;业务读写通过 typed IPC 交给 Core。
import { useEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties } from 'react';
import { useEffect, useRef, useState, type ComponentType, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from './Icon';
import { Tooltip } from './Tooltip';
@@ -19,9 +16,6 @@ import { Marketplace } from '../pages/Marketplace';
import { Translation } from '../pages/Translation';
import { SelectionAsk } from '../pages/SelectionAsk';
import { Corrections } from '../pages/Corrections';
// 风格市场(Marketplace)现在是侧栏「风格」展开组下的独立页面(不再是 Style 页面内 modal)。
// LocalAsr 不再作为主 nav tab——本地 ASR 模型管理已合并到 Settings → Services 中
// 通过 <LocalAsr embedded /> 渲染。这里之前的 import 与 NAV_BASE 条目都已移除。
import { APP_VERSION_LABEL, IS_BETA_BUILD } from '../lib/appVersion';
import {
HOTKEY_MODE_MIGRATION_ACK_KEY,
@@ -65,10 +59,7 @@ const PAGE_CMP: Record<Exclude<AppTab, 'localAsr'>, ComponentType> = {
corrections: Corrections,
};
/** 侧栏导航树:扁平项 + 可展开分组(用户拍板的结构)。
* - 概览 / 历史 / 词汇:扁平项,位置不变。
* - 风格:展开组 → 润色模式(=style 页) + 风格市场(=marketplace 页,原为 Style 内 modal)。
* - 工具:展开组 → 划词追问 + 翻译 + 纠正规则(2.0 UI 走查:纠正规则从词典页迁入)。 */
/** 主导航分为直接入口与可展开分组;页面组件由 PAGE_CMP 统一解析。 */
type NavNode =
| { kind: 'item'; id: AppTab; icon: string }
| { kind: 'group'; key: string; icon: string; children: Array<{ id: AppTab }> };
@@ -105,7 +96,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSectionId | undefined>();
const [providerPromptOpen, setProviderPromptOpen] = useState(false);
const [hotkeyModePromptOpen, setHotkeyModePromptOpen] = useState(false);
// 退出动画门(2.0 UI 走查「从哪来回到哪去」):关闭时先反向播放入场动画再卸载。
// 退出动画门:关闭时先反向播放入场动画再卸载。
const settingsMount = useExitMount(settingsOpen, 220);
const providerPromptMount = useExitMount(providerPromptOpen);
const hotkeyPromptMount = useExitMount(hotkeyModePromptOpen);
@@ -235,9 +226,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
const styleTabActive = STYLE_TAB_IDS.includes(currentTab);
return (
// 不再为 macOS 红绿灯预留顶部 28px 空条(用户反馈「块上方多一条丑横条」):
// 侧栏与内容块都顶到窗口最上沿,原生红绿灯直接浮在侧栏左上角的块面上。
// 侧栏 aside 在 mac 上加 topClearance(红绿灯竖直居中的避让带)让导航避开红绿灯。
// 窗口内容从顶端铺开;macOS 仅在侧栏内部预留红绿灯区域。
<div style={{ flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', minHeight: 0, paddingTop: 0, background: 'var(--ol-app-shell-bg)' }}>
{mobile && (
@@ -278,7 +267,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
padding: os === 'mac' ? `${MAC_TRAFFIC_LIGHT_CLEARANCE}px 10px 12px` : '10px 10px 12px',
}}>
{/* 版本信息行(2.0 UI 走查):原「OpenLess」品牌位置直接改为显示版本信息,
{/* 版本信息行:原「OpenLess」品牌位置直接改为显示版本信息,
填掉导航上方的空档;BETA 徽章与版本号同基线。 */}
<div
style={{
@@ -388,7 +377,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
<div style={{ flex: 1 }} />
{/* 底部只剩设置按钮(2.0 UI 走查:版本信息已上移到侧栏顶部原品牌位)。 */}
{/* 底部只剩设置按钮。 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingTop: 10 }}>
<Tooltip content={t('shell.navHint.settings')} placement="right">
<button
@@ -450,7 +439,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
data-ol-page-fixed={displayTab === 'overview' && !mobile ? 'true' : undefined}
style={{
flex: 1, minHeight: 0,
// 概览页是单屏固定页(2.0 UI 走查):不滚动,所有仪表盘铺在一屏内,
// 概览页是单屏固定页:不滚动,所有仪表盘铺在一屏内,
// 由 Overview.tsx 内部 flex 自行分配高度;其余页保持细滚动条。
overflow: displayTab === 'overview' && !mobile ? 'hidden' : 'auto',
padding: mobile
@@ -803,7 +792,7 @@ const navBtnStyle: CSSProperties = {
width: '100%',
padding: '8px 10px',
borderRadius: 8, border: 0,
// 2.0 UI 走查(参考 Codex 侧栏):导航文字 15px、图标 16px,观感更接近参考稿。
// (参考 Codex 侧栏):导航文字 15px、图标 16px,观感更接近参考稿。
fontFamily: 'inherit', fontSize: 15,
cursor: 'default',
transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)',
@@ -6,7 +6,7 @@
// 重渲染不会拖累它,它也不会让页面跟随每个进度事件重渲染。
//
// 事件在 Core ModelStore 内已按 ≥150ms 节流,
// 进度条不会因高频 IPC 抽搐;这里只做展示 + 取消入口,不参与模型状态管理。
// 进度条不会因高频 IPC 抖动;这里只做展示 + 取消入口,不参与模型状态管理。
//
// portal 到 body 的原因与 DownloadDialog 相同:WindowChrome 根节点的常驻
// transform / will-change 会为 position:fixed 后代创建 containing block,不
@@ -18,7 +18,7 @@ interface SettingsModalProps {
os: OS;
onClose: () => void;
initialSettingsSection?: SettingsSectionId;
/** true 时反向播放入场动画(2.0 UI 走查「从哪来回到哪去」);由 FloatingShell
/** true 时反向播放入场动画;由 FloatingShell
* 的 useExitMount 门控,动画播完才真正卸载。 */
closing?: boolean;
}
@@ -69,8 +69,7 @@ export function SettingsModal({ os, onClose, initialSettingsSection, closing = f
const contentDescription = searching ? t('modal.searchCount', { count: results.length })
: activeAdvancedPage ? t(`modal.advancedPages.${activeAdvancedPage.id}`) : t(`modal.descriptions.${section}`);
// 2.0 UI 走查:桌面端设置侧栏的选中蓝框改为滑动指示块——切换分类时蓝框沿
// 列表平滑移动到目标项(spring 曲线),而不是生硬地瞬间换底色。
// 指示块按所选导航行的布局位置移动;搜索或移动布局时隐藏。
const railNavRef = useRef<HTMLElement>(null);
const railBtnRefs = useRef(new Map<SettingsSectionId, HTMLButtonElement>());
const [railThumb, setRailThumb] = useState<{ top: number; height: number } | null>(null);
@@ -164,7 +163,7 @@ export function SettingsModal({ os, onClose, initialSettingsSection, closing = f
};
// 搜索框:桌面端放在侧栏顶部(仿 macOS 系统设置的「搜索在导航栏上方」布局,
// 2.0 UI 走查);移动端仍留在标题栏下方整行。
// );移动端仍留在标题栏下方整行。
const searchBox = (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 10px', borderRadius: 8, border: '0.5px solid var(--ol-line-strong)', background: 'var(--ol-surface)', minWidth: 0 }}>
<Icon name="search" size={15} />
@@ -177,7 +176,7 @@ export function SettingsModal({ os, onClose, initialSettingsSection, closing = f
<div
onClick={mobile ? undefined : closeSettings}
// 打开动画:遮罩淡入 + 面板弹入(global.css ol-modal-* keyframes,纯
// opacity/transform,合成器友好)。2.0 UI 走查:此前设置面板是瞬间出现的。
// opacity/transform,合成器友好)。此前设置面板是瞬间出现的。
style={{ position: mobile ? 'fixed' : 'absolute', inset: 0, background: mobile ? 'var(--ol-surface)' : 'var(--ol-overlay-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: mobile ? 0 : 20, zIndex: mobile ? 70 : 50, animation: mobile ? undefined : closing ? 'ol-modal-backdrop-in 0.18s var(--ol-motion-soft) reverse both' : 'ol-modal-backdrop-in 0.2s var(--ol-motion-soft) both' }}
>
<div
@@ -194,7 +193,7 @@ export function SettingsModal({ os, onClose, initialSettingsSection, closing = f
? closing ? 'ol-mobile-sheet-up 0.22s var(--ol-motion-soft) reverse both' : 'ol-mobile-sheet-up 0.26s var(--ol-motion-spring) both'
: closing ? 'ol-modal-card-in 0.2s var(--ol-motion-soft) reverse both' : 'ol-modal-card-in 0.28s var(--ol-motion-spring) both' }}
>
{/* 桌面端不再有横跨两栏的标题栏(2.0 UI 走查:去掉「第三条横栏」);
{/* 桌面端不再有横跨两栏的标题栏;
左侧栏与右侧内容各自通到顶,标题/自动保存/关闭并入右栏顶部。
移动端保留整宽 header(标题 + 关闭 + 整行搜索)。 */}
{mobile && (
@@ -276,7 +275,7 @@ export function SettingsModal({ os, onClose, initialSettingsSection, closing = f
{results.length === 0 && <div style={{ padding: '24px 16px', border: '0.5px solid var(--ol-line)', borderRadius: 10, textAlign: 'center' }}><p style={{ fontSize: 13, color: 'var(--ol-ink-3)' }}>{t('modal.noResults')}</p><button type="button" onClick={() => { setQuery(''); searchRef.current?.focus(); }} style={{ ...navButtonStyle, margin: '12px auto 0', color: 'var(--ol-blue)' }}>{t('modal.clearSearch')}</button></div>}
</div>}
{/* key={section} 重挂载 → 每次切换分类播放轻微淡入(ol-tab-fade),
与 tab 切换动画语言一致(2.0 UI 走查:补全动效)。 */}
与 tab 切换动画语言一致。 */}
<div key={section} style={{ display: searching ? 'none' : 'flex', flexDirection: 'column', gap: 16, animation: 'ol-tab-fade 0.22s var(--ol-motion-soft) both' }}>
{section === 'general' && <GeneralTab />}
{section === 'shortcuts' && <ShortcutsTab />}
@@ -11,7 +11,7 @@ import type { ShortcutBinding } from '../lib/types';
const SLIDE_DISTANCE = 48;
/** 下拉菜单展开后的固定高度(px):菜单按钮行高恒定,用固定值动画避免每次测量。 */
const MENU_HEIGHT = 34;
/** 滑动切换用 spring(与 Style.tsx 编辑抽屉同款)。只动 transform/opacity,不驱动布局,避免抽搐。 */
/** 滑动切换用 spring(与 Style.tsx 编辑抽屉同款)。只动 transform/opacity,不驱动布局,避免抖动。 */
const slideSpring = { type: 'spring' as const, damping: 26, stiffness: 280 };
/** 下拉菜单展开/收起缓动,与 --ol-motion-soft 一致。 */
const menuEase = [0.22, 0.8, 0.22, 1] as const;
@@ -231,7 +231,7 @@ export function ShortcutRecorder({
flexDirection: 'column',
gap: 6,
width: '100%',
// 2.0 UI 走查:设置行里的快捷键录制控件不再拉满整行(此前「Right ⌃」值贴左、
// 设置行里的快捷键录制控件不再拉满整行(此前「Right ⌃」值贴左、
// 下拉箭头甩到最右缘),与输入框同宽上限,紧凑地跟在标签列之后。
maxWidth: 360,
};
@@ -295,7 +295,7 @@ export function ShortcutRecorder({
return (
<div style={rootStyle} ref={rootRef}>
{/* mode="wait":主行与「正在录入」面板不重叠渲染;切换只做 transform/opacity 动画,
不驱动布局,面板运动过程不抽搐。所有滑入/滑出统一向右。 */}
不驱动布局,面板运动过程不抖动。所有滑入/滑出统一向右。 */}
<AnimatePresence mode="wait" initial={false}>
{recording ? (
<motion.div
+1 -1
View File
@@ -16,7 +16,7 @@ interface ModalProps {
width?: string;
/** 需要固定标题和底栏的弹窗可由内部内容区负责滚动。 */
style?: CSSProperties;
/** true 时反向播放入场动画(2.0 UI 走查「从哪来回到哪去」);调用方用
/** true 时反向播放入场动画;调用方用
* useExitMount 门控卸载时机,动画播完再 unmount。 */
closing?: boolean;
}
@@ -1,5 +1,4 @@
// SelectLite — 三平台统一的自定义下拉,替代 native <select>,避开 Win32 ComboBox
// 直角丑框(issue #418)以及 WKWebView 上原生 NSPopUpButton 的视觉割裂。
// SelectLite 在各平台提供一致的下拉交互、键盘操作和浮层定位。
//
// 设计:
// - 触发器是 button(chevron + 当前值),样式可被 `style` 覆盖
-29
View File
@@ -728,23 +728,9 @@ export const en: typeof zhCN = {
},
},
selectionAsk: {
kicker: 'SELECTION ASK',
title: 'Selection Ask',
desc: 'Select text and ask questions by voice, with multi-turn follow-ups.',
shortcutSettings: 'Shortcut settings',
intro: {
eyebrow: 'From reading to understanding',
title: 'Select a passage.\nAsk it out loud.',
desc: 'Explore a concept, find the key points, or go deeper. Answers appear in a floating panel.',
},
example: {
title: 'Example conversation',
selection: 'Selected text',
term: 'The Feynman technique',
context: ' reveals gaps in your understanding by explaining a concept to someone else.',
question: 'Explain that in simpler terms.',
answer: 'Think of teaching a friend: the clearer your explanation, the better you understand it.',
},
guide: {
openTitle: 'Open the panel',
openDesc: 'Press <key>{{hotkey}}</key> to start a conversation.',
@@ -755,17 +741,10 @@ export const en: typeof zhCN = {
followup: 'Use the recording shortcut again to ask a follow-up.',
dismiss: 'Close the panel and end this conversation',
},
statusEnabled: 'Enabled',
statusDisabled: 'Disabled',
hotkey: {
title: 'Hotkey to open the panel',
desc: 'Opens/closes the panel. Recording uses {{recordHotkey}}.',
optionDisabled: 'Disabled',
chordWarning: '',
},
save: {
hotkeyRegisterFailed: 'Failed to register the Selection Ask shortcut. The preference was not saved.',
hotkeySaveFailed: 'Failed to save the Selection Ask shortcut. Please try again.',
historySaveFailed: 'Failed to save the Q&A history setting. Please try again.',
},
history: {
@@ -774,15 +753,7 @@ export const en: typeof zhCN = {
},
howto: {
title: 'How to use',
step1: 'Press {{hotkey}} to open the panel.',
step2: 'Select text in any app.',
step3: 'Press {{recordHotkey}} to record, press again to submit.',
step4: 'Continue pressing {{recordHotkey}} for follow-up questions.',
step5: 'Press Esc to close and clear history.',
windowTitle: 'Position, drag, and pin',
windowDesc: 'The panel is draggable and remembers its position. Pin to keep it open.',
privacyTitle: 'Privacy contract',
privacyDesc: 'Selected text lives only in memory until the panel closes. Text over 4000 chars is truncated.',
},
},
settings: {
-29
View File
@@ -730,23 +730,9 @@ export const ja: typeof zhCN = {
},
},
selectionAsk: {
kicker: 'SELECTION ASK',
title: '選択追問',
desc: 'テキストを選択して音声で質問。複数ターンの追問対応。',
shortcutSettings: 'ショートカット設定',
intro: {
eyebrow: '読むことから、理解することへ',
title: '気になる文章を選んで、\n声で質問。',
desc: '概念の説明や要点の整理、さらに深い質問も。回答はフロートパネルに表示されます。',
},
example: {
title: '会話の例',
selection: '選択したテキスト',
term: 'ファインマン・テクニック',
context: 'は、他の人に説明することで理解の抜けを見つける学習法です。',
question: 'もっと簡単な言葉で説明して。',
answer: '友人に教えるようなものです。分かりやすく説明できるほど、理解が深まっています。',
},
guide: {
openTitle: 'パネルを開く',
openDesc: '<key>{{hotkey}}</key> で会話を始めます。',
@@ -757,17 +743,10 @@ export const ja: typeof zhCN = {
followup: '録音キーでもう一度、続けて質問できます。',
dismiss: 'パネルを閉じて、この会話を終了',
},
statusEnabled: '有効',
statusDisabled: '無効',
hotkey: {
title: 'フロートウィンドウのショートカット',
desc: 'パネルの開閉を制御。パネル内の録音は {{recordHotkey}}。',
optionDisabled: '無効',
chordWarning: '',
},
save: {
hotkeyRegisterFailed: '選択追問ショートカットの登録に失敗しました。設定は保存されていません。',
hotkeySaveFailed: '選択追問ショートカットの保存に失敗しました。もう一度お試しください。',
historySaveFailed: 'Q&A 履歴設定の保存に失敗しました。もう一度お試しください。',
},
history: {
@@ -776,15 +755,7 @@ export const ja: typeof zhCN = {
},
howto: {
title: '使い方',
step1: '{{hotkey}} でパネルを開く。',
step2: '任意のアプリでテキストを選択。',
step3: '{{recordHotkey}} で録音、再度押して送信。',
step4: '{{recordHotkey}} で続けて追問可能。',
step5: 'Esc でパネルを閉じ、履歴をクリア。',
windowTitle: 'ウィンドウの位置 + ドラッグ + ピン留め',
windowDesc: 'パネルはドラッグ可能で位置を記憶。ピン留めで開いたままに。',
privacyTitle: 'プライバシー契約',
privacyDesc: '選択テキストはメモリ上のみ。パネルを閉じると破棄。4000 字超は自動切り詰め。',
},
},
settings: {
-29
View File
@@ -730,23 +730,9 @@ export const ko: typeof zhCN = {
},
},
selectionAsk: {
kicker: 'SELECTION ASK',
title: '선택 질문',
desc: '텍스트 선택 후 음성으로 질문. 다중 라운드 후속 질문 지원.',
shortcutSettings: '단축키 설정',
intro: {
eyebrow: '읽기에서 이해로',
title: '궁금한 글을 선택하고,\n말로 질문하세요.',
desc: '개념을 이해하고, 핵심을 정리하거나 더 깊이 알아보세요. 답변은 플로팅 패널에 표시됩니다.',
},
example: {
title: '대화 예시',
selection: '선택한 텍스트',
term: '파인만 학습법',
context: '은 다른 사람에게 설명하면서 이해가 부족한 부분을 찾는 방법입니다.',
question: '더 쉬운 말로 설명해 줘.',
answer: '친구에게 가르친다고 생각해 보세요. 명확하게 설명할수록 더 깊이 이해한 것입니다.',
},
guide: {
openTitle: '질문 패널 열기',
openDesc: '<key>{{hotkey}}</key>로 대화를 시작하세요.',
@@ -757,17 +743,10 @@ export const ko: typeof zhCN = {
followup: '녹음 단축키를 다시 눌러 후속 질문을 할 수 있어요.',
dismiss: '패널을 닫고 이번 대화 종료',
},
statusEnabled: '활성화됨',
statusDisabled: '비활성화됨',
hotkey: {
title: '플로팅 창 단축키',
desc: '패널 열기/닫기 제어. 패널 내 녹음은 {{recordHotkey}} 사용.',
optionDisabled: '비활성화',
chordWarning: '',
},
save: {
hotkeyRegisterFailed: '선택 질문 단축키 등록에 실패했습니다. 설정은 저장되지 않았습니다.',
hotkeySaveFailed: '선택 질문 단축키 저장에 실패했습니다. 다시 시도하세요.',
historySaveFailed: 'Q&A 기록 설정 저장에 실패했습니다. 다시 시도하세요.',
},
history: {
@@ -776,15 +755,7 @@ export const ko: typeof zhCN = {
},
howto: {
title: '사용 방법',
step1: '{{hotkey}} 로 패널 열기.',
step2: '아무 앱에서 텍스트 선택.',
step3: '{{recordHotkey}} 로 녹음, 다시 눌러 제출.',
step4: '{{recordHotkey}} 로 계속 후속 질문 가능.',
step5: 'Esc 로 패널 닫기 및 기록 삭제.',
windowTitle: '창 위치 + 드래그 + 고정',
windowDesc: '패널은 드래그 가능하며 위치를 기억합니다. 고정하면 열린 상태 유지.',
privacyTitle: '프라이버시 계약',
privacyDesc: '선택 텍스트는 메모리에만 존재하며 패널 닫으면 삭제. 4000 자 초과 시 자동 축소.',
},
},
settings: {
+1 -30
View File
@@ -1,5 +1,5 @@
// 简体中文资源 — 与产品当前文案保持一致。
// 添加新 key 时,必须同步更新 en.ts,否则首次切换到 English 会回落到中文残留。
// 五个语言文件共享同一结构;新增或删除 key 时同步更新全部语言。
export const zhCN = {
app: {
@@ -726,23 +726,9 @@ export const zhCN = {
},
},
selectionAsk: {
kicker: 'SELECTION ASK',
title: '划词追问',
desc: '选中文字后语音提问,支持多轮追问。',
shortcutSettings: '快捷键设置',
intro: {
eyebrow: '在文字与理解之间',
title: '选中一段文字,\n把问题说出来。',
desc: '解释概念、提炼要点,或继续深入。答案会出现在独立浮窗里。',
},
example: {
title: '对话示例',
selection: '选中的文字',
term: '费曼学习法',
context: ':通过向他人讲解,发现自己理解中的空白。',
question: '用更简单的话解释一下。',
answer: '就像给朋友讲课:讲得越明白,你就理解得越透彻。',
},
guide: {
openTitle: '打开追问浮窗',
openDesc: '按 <key>{{hotkey}}</key>,开始一轮对话。',
@@ -753,17 +739,10 @@ export const zhCN = {
followup: '继续使用录音快捷键,即可多轮追问。',
dismiss: '关闭浮窗,结束本次对话',
},
statusEnabled: '已启用',
statusDisabled: '未启用',
hotkey: {
title: '弹出浮窗的快捷键',
desc: '控制浮窗开关。浮窗内录音用 {{recordHotkey}}。',
optionDisabled: '不启用',
chordWarning: '',
},
save: {
hotkeyRegisterFailed: '划词追问快捷键注册失败,未继续保存。',
hotkeySaveFailed: '划词追问快捷键保存失败,请重试。',
historySaveFailed: 'Q&A 历史保存设置保存失败,请重试。',
},
history: {
@@ -772,15 +751,7 @@ export const zhCN = {
},
howto: {
title: '使用方法',
step1: '按 {{hotkey}} 打开浮窗。',
step2: '在任意 app 选中文字。',
step3: '按 {{recordHotkey}} 录音,再按一次提交。',
step4: '可继续按 {{recordHotkey}} 多轮追问。',
step5: '按 Esc 关闭浮窗并清空历史。',
windowTitle: '浮窗操作',
windowDesc: '浮窗可拖动,位置自动记忆。钉住可保持窗口不关。',
privacyTitle: '隐私',
privacyDesc: '选中文本仅存于内存,关闭浮窗即销毁。超 4000 字符自动截断。',
},
},
settings: {
-29
View File
@@ -728,23 +728,9 @@ export const zhTW: typeof zhCN = {
},
},
selectionAsk: {
kicker: 'SELECTION ASK',
title: '劃詞追問',
desc: '選中文字後語音提問,支援多輪追問。',
shortcutSettings: '快捷鍵設定',
intro: {
eyebrow: '在文字與理解之間',
title: '選取一段文字,\n把問題說出來。',
desc: '解釋概念、整理重點,或繼續深入。答案會出現在獨立浮窗裡。',
},
example: {
title: '對話範例',
selection: '選取的文字',
term: '費曼學習法',
context: ':透過向他人講解,發現自己理解中的空白。',
question: '用更簡單的話解釋一下。',
answer: '就像給朋友講課:講得越明白,你就理解得越透徹。',
},
guide: {
openTitle: '開啟追問浮窗',
openDesc: '按 <key>{{hotkey}}</key>,開始一輪對話。',
@@ -755,17 +741,10 @@ export const zhTW: typeof zhCN = {
followup: '繼續使用錄音快捷鍵,即可多輪追問。',
dismiss: '關閉浮窗,結束本次對話',
},
statusEnabled: '已啓用',
statusDisabled: '未啓用',
hotkey: {
title: '彈出浮窗的快捷鍵',
desc: '控制浮窗開關。浮窗內錄音用 {{recordHotkey}}。',
optionDisabled: '不啓用',
chordWarning: '',
},
save: {
hotkeyRegisterFailed: '劃詞追問快捷鍵註冊失敗,未繼續保存。',
hotkeySaveFailed: '劃詞追問快捷鍵保存失敗,請重試。',
historySaveFailed: 'Q&A 歷史保存設置保存失敗,請重試。',
},
history: {
@@ -774,15 +753,7 @@ export const zhTW: typeof zhCN = {
},
howto: {
title: '使用方法',
step1: '按 {{hotkey}} 開啟浮窗。',
step2: '在任意 app 選中文字。',
step3: '按 {{recordHotkey}} 錄音,再按一次提交。',
step4: '可繼續按 {{recordHotkey}} 多輪追問。',
step5: '按 Esc 關閉浮窗並清空歷史。',
windowTitle: '浮窗位置 + 拖動 + 釘住',
windowDesc: '浮窗可拖動,位置自動記憶。釘住可保持視窗不關。',
privacyTitle: '隱私契約',
privacyDesc: '選中文本僅存於記憶體,關閉浮窗即銷毀。超 4000 字元自動截斷。',
},
},
settings: {
+1 -1
View File
@@ -137,7 +137,7 @@ function resolveAudioContextCtor(): AudioContextCtor | null {
function getContext(): AudioContext | null {
const Ctor = resolveAudioContextCtor();
if (!Ctor) return null;
// 已 closed 的 ctx 无法复活,丢弃后重建——否则后续所有提示音永久静默(本 bug 根因)。
// 已 closed 的 ctx 无法复活,丢弃后重建——否则后续所有提示音永久静默。
// 旧 ctx 上挂着的 activeVoices 也一并清空,避免去叠音时操作已失效节点。
if (sharedCtx && audioContextActionForState(sharedCtx.state) === 'recreate') {
sharedCtx = null;
+1 -8
View File
@@ -111,10 +111,7 @@ export function getHotkeyBindingLabel(binding: HotkeyBinding | null | undefined)
export function getHotkeyCodeLabel(code: string): string {
const zh = i18n.language.toLowerCase().startsWith('zh');
const isMac = currentPlatform().isMac;
// Alt 在 macOS 是 Option(⌥),Meta 在 macOS 是 Command(⌘),Linux 上 Meta 是 Super。
// 之前对所有平台一律返回 "Alt" / "Win",QA 浮窗里 macOS 用户的"右 Option" 被显示成
// "右 Alt","左 Cmd" 被显示成 "左 Win"——平台错配。下面按平台分流。
// 用 ⌥/⌘ 与 formatPrimary 的输出("Right ⌥" 等)保持一致。
// macOS 使用 Option/Command 符号,并与组合键标签 formatPrimary 保持一致。
const labels: Record<string, string> = {
ControlLeft: zh ? '左Ctrl' : 'Left Ctrl',
ControlRight: zh ? '右Ctrl' : 'Right Ctrl',
@@ -308,10 +305,6 @@ export function modifiersFromPressedCodes(
: genericModifiersFromPressedCodes(codes);
}
function modifierDisplayName(tag: string, platform: { isMac: boolean; isWindows: boolean }): string {
return sideModifierDisplayName(tag, platform);
}
function sideModifierDisplayName(tag: string, platform: { isMac: boolean; isWindows: boolean }): string {
const sideLabels: Record<string, string> = platform.isMac
? {
@@ -1,85 +0,0 @@
import {
createHotkeyRecorderState,
orderHotkeyCodes,
updateHotkeyRecorderState,
} from './hotkeyRecorder';
function assertEqual<T>(actual: T, expected: T, name: string) {
if (actual !== expected) {
throw new Error(`${name}: expected ${expected}, got ${actual}`);
}
}
function assertDeepEqual(actual: unknown, expected: unknown, name: string) {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) {
throw new Error(`${name}: expected ${expectedJson}, got ${actualJson}`);
}
}
function apply(
state = createHotkeyRecorderState(),
code: string,
pressed: boolean,
) {
const next = updateHotkeyRecorderState(state, code, pressed);
return next;
}
{
let result = apply(undefined, 'ControlLeft', true);
assertDeepEqual(result.state.draftCodes, ['ControlLeft'], 'tracks first pressed key');
assertEqual(result.commitCodes, null, 'does not commit until release');
result = apply(result.state, 'ControlLeft', false);
assertDeepEqual(result.commitCodes, ['ControlLeft'], 'commits single key on release');
result = apply(createHotkeyRecorderState(), 'KeyK', true);
assertDeepEqual(result.state.draftCodes, ['KeyK'], 'starts a new recording state cleanly');
assertEqual(result.commitCodes, null, 'new keydown does not include old released keys');
}
{
let result = apply(undefined, 'ControlLeft', true);
result = apply(result.state, 'KeyK', true);
assertDeepEqual(result.state.draftCodes, ['ControlLeft', 'KeyK'], 'records keyboard combo draft');
result = apply(result.state, 'ControlLeft', false);
assertEqual(result.commitCodes, null, 'keyboard combo waits for final release');
assertDeepEqual(result.state.draftCodes, ['ControlLeft', 'KeyK'], 'released combo member stays in draft only');
result = apply(result.state, 'KeyK', false);
assertDeepEqual(result.commitCodes, ['ControlLeft', 'KeyK'], 'keyboard combo commits after all keys release');
assertDeepEqual(result.state, createHotkeyRecorderState(), 'state resets after commit');
}
{
let result = apply(undefined, 'Mouse4', true);
assertDeepEqual(result.state.draftCodes, ['Mouse4'], 'tracks mouse button as draft');
assertEqual(result.commitCodes, null, 'mouse button does not commit on mousedown');
result = apply(result.state, 'ControlLeft', true);
assertDeepEqual(result.state.draftCodes, ['ControlLeft', 'Mouse4'], 'records keyboard plus mouse combo');
assertEqual(result.commitCodes, null, 'combo does not commit while inputs remain pressed');
result = apply(result.state, 'Mouse4', false);
assertEqual(result.commitCodes, null, 'releasing one combo member does not commit early');
result = apply(result.state, 'ControlLeft', false);
assertDeepEqual(result.commitCodes, ['ControlLeft', 'Mouse4'], 'commits combo after final release');
}
{
let result = apply(undefined, 'ControlLeft', true);
result = apply(result.state, 'Mouse5', true);
assertDeepEqual(result.state.draftCodes, ['ControlLeft', 'Mouse5'], 'records mouse button pressed after keyboard');
result = apply(result.state, 'ControlLeft', false);
assertEqual(result.commitCodes, null, 'mouse combo keeps waiting while mouse remains pressed');
result = apply(result.state, 'Mouse5', false);
assertDeepEqual(result.commitCodes, ['ControlLeft', 'Mouse5'], 'commits mouse-last combo after mouse release');
}
assertDeepEqual(orderHotkeyCodes(['Mouse4', 'ControlLeft']), ['ControlLeft', 'Mouse4'], 'orders mouse after modifiers');
@@ -1,70 +0,0 @@
export interface HotkeyRecorderState {
pressedCodes: string[];
draftCodes: string[];
}
export interface HotkeyRecorderUpdate {
state: HotkeyRecorderState;
commitCodes: string[] | null;
}
export function createHotkeyRecorderState(): HotkeyRecorderState {
return {
pressedCodes: [],
draftCodes: [],
};
}
export function updateHotkeyRecorderState(
state: HotkeyRecorderState,
code: string,
pressed: boolean,
): HotkeyRecorderUpdate {
const active = new Set(state.pressedCodes);
if (pressed) {
active.add(code);
} else {
active.delete(code);
}
const pressedCodes = orderHotkeyCodes([...active]);
const draftCodes = pressed ? pressedCodes : state.draftCodes;
const shouldCommit = !pressed && pressedCodes.length === 0 && draftCodes.length > 0;
return {
state: shouldCommit ? createHotkeyRecorderState() : { pressedCodes, draftCodes },
commitCodes: shouldCommit ? draftCodes : null,
};
}
export function orderHotkeyCodes(codes: string[]): string[] {
const seen = new Set<string>();
return codes
.filter(code => {
if (!code || seen.has(code)) return false;
seen.add(code);
return true;
})
.sort((a, b) => hotkeyCodeRank(a) - hotkeyCodeRank(b));
}
function hotkeyCodeRank(code: string): number {
const index = HOTKEY_CODE_ORDER.indexOf(code);
if (index >= 0) return index;
if (/^Key[A-Z]$/.test(code)) return 100 + code.charCodeAt(3);
if (/^Digit[0-9]$/.test(code)) return 200 + Number(code.slice(5));
if (/^F([1-9]|1[0-9]|2[0-4])$/.test(code)) return 300 + Number(code.slice(1));
if (/^Numpad[0-9]$/.test(code)) return 400 + Number(code.slice(6));
return 1000;
}
const HOTKEY_CODE_ORDER = [
'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', 'ShiftLeft', 'ShiftRight',
'MetaLeft', 'MetaRight', 'Fn', 'FnLock', 'CapsLock', 'ScrollLock', 'Pause',
'PrintScreen', 'Backspace', 'Tab', 'Enter', 'Space', 'Insert', 'Delete', 'Home',
'End', 'PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
'ContextMenu', 'Backquote', 'Minus', 'Equal', 'BracketLeft', 'BracketRight',
'Backslash', 'Semicolon', 'Quote', 'Comma', 'Period', 'Slash', 'NumpadAdd',
'NumpadSubtract', 'NumpadMultiply', 'NumpadDivide', 'NumpadDecimal', 'NumpadEnter',
'Mouse4', 'Mouse5',
];
+2 -7
View File
@@ -1,10 +1,5 @@
// 渠道卡片的 IPC 封装。
//
// 一张卡片 = 一份可命名、可排序、可开关的供应商配置;同一家厂商可以有多张卡片
// (多把 key)。列表里**第一个启用的**就是当前生效的渠道 —— 后端不另存"当前选中",
// 排序即优先级(见 docs/provider-channels-plan.md)。
//
// 凭据不走这里:按渠道 id 调 readCredential/setCredential(account, value, id)。
// 渠道配置的 typed IPC:排序、启停、名称和手动验证结果。
// Core 选择排序后的第一个启用渠道;凭据通过 readCredential/setCredential 按渠道 ID 读写。
import { invokeOrMock } from "./shared"
@@ -2,7 +2,6 @@ import type { PolishMode, StylePack, StylePackRuntimeDiagnostics } from "../type
import { invokeOrMock } from "./shared"
import {
cloneMockStylePacks,
cloneStylePack,
composeMockStylePackRuntimeDiagnostics,
mockSetDefaultPolishMode,
mockSetStyleEnabled,
-1
View File
@@ -1,7 +1,6 @@
import type {
CorrectionRule,
DictionaryEntry,
PendingCorrection,
VocabPresetStore,
} from "../types"
import { invokeOrMock } from "./shared"
+4 -1
View File
@@ -4,6 +4,7 @@ const MAX_SVG_BYTES = 256 * 1024;
const MAX_PNG_BYTES = 64 * 1024;
const ICON_SIZE = 128;
/** 清理 SVG 的可执行内容和外部引用,保留图形、渐变与内部遮罩。 */
export function sanitizeStyleSvg(source: string): string {
if (/<!DOCTYPE|<!ENTITY/i.test(source)) throw new Error('invalidSvg');
const document = new DOMParser().parseFromString(source, 'image/svg+xml');
@@ -16,7 +17,7 @@ export function sanitizeStyleSvg(source: string): string {
});
const svg = new DOMParser().parseFromString(clean, 'image/svg+xml').documentElement;
if (svg.localName !== 'svg') throw new Error('invalidSvg');
// Icons are self-contained. Keep fragment references for gradients and masks.
// 只允许引用同一 SVG 中的元素,避免解码图标时请求网络或本地文件。
for (const element of [svg, ...Array.from(svg.querySelectorAll('*'))]) {
for (const attribute of Array.from(element.attributes)) {
if (attribute.localName === 'href' && !attribute.value.trim().startsWith('#')) {
@@ -46,6 +47,7 @@ function hasExternalCssReference(value: string): boolean {
.some(match => !match[2].trim().startsWith('#'));
}
/** 转成风格包已有的 PNG 资源格式;128px 输出仍须满足 Core 的 64 KiB 上限。 */
export async function rasterizeStyleSvg(file: File): Promise<number[]> {
if (!file.name.toLowerCase().endsWith('.svg') || (file.type && file.type !== 'image/svg+xml') || file.size > MAX_SVG_BYTES) {
throw new Error('invalidSvg');
@@ -70,6 +72,7 @@ export async function rasterizeStyleSvg(file: File): Promise<number[]> {
}
}
/** 图片仅通过 img 展示;拒绝可执行类型和超过资源上限的返回值。 */
export function isStyleIconDataUrl(value: string | null): value is string {
return value !== null && value.length < 90_000 && /^data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$/.test(value);
}
-240
View File
@@ -1,240 +0,0 @@
import {
applyStylePreferencesNotification,
isStyleMasterEnabled,
rollbackDefaultAndEnabledChange,
persistStylePreferenceChange,
rollbackStyleEnabledChange,
rollbackWholeStylePreferences,
styleDefaultModePreferences,
styleMasterFallbackModes,
styleMasterOffPreferences,
} from './stylePrefs';
import type { UserPreferences } from './types';
function assert(condition: boolean, message: string) {
if (!condition) throw new Error(message);
}
const previousPrefs: UserPreferences = {
hotkey: { trigger: 'rightOption', mode: 'toggle' },
dictationHotkey: { primary: 'RightOption', modifiers: [] },
pipelineMode: 'traditional',
multimodalPipelineEnabled: false,
activeOmniProvider: 'custom',
selectionPolishHotkey: { primary: 'RightControl', modifiers: [] },
selectionPolishStylePackId: 'builtin.light',
selectionPolishOutputMode: 'directReplace',
selectionVoiceEnabled: false,
selectionVoiceIntentMode: 'auto',
selectionVoiceManualIntent: 'question',
selectionVoiceEditKeywords: ['翻译', '替换'],
cursorContextEnabled: false,
showOverviewActivityHeatmap: true,
stackedRowLayout: false,
conservativeLayout: false,
defaultMode: 'light',
enabledModes: ['raw', 'light', 'structured'],
activeStylePackId: '',
styleSystemPrompts: {
raw: 'raw system prompt',
light: 'light system prompt',
structured: 'structured system prompt',
formal: 'formal system prompt',
},
customStylePrompts: { raw: '', light: '', structured: '', formal: '' },
launchAtLogin: false,
showCapsule: true,
capsuleStyle: 'siri',
muteDuringRecording: false,
audioCueOnRecord: true,
silenceAutoStopEnabled: false,
silenceAutoStopSeconds: 3,
microphoneDeviceName: '',
activeAsrProvider: 'volcengine',
activeLlmProvider: 'ark',
llmThinkingEnabled: false,
useSystemProxy: true,
restoreClipboardAfterPaste: true,
pasteShortcut: 'ctrlV',
allowNonTsfInsertionFallback: true,
windowsInsertionMode: 'tsf',
windowsSendInputNewlineMode: 'enter',
macosNewlineMode: 'shiftReturn',
windowsSendInputInsertionOnly: false,
windowsShowOpenlessInKeyboardList: true,
workingLanguages: ['简体中文'],
translationTargetLanguage: '',
chineseScriptPreference: 'auto',
outputLanguagePreference: 'auto',
qaHotkey: null,
qaSaveHistory: false,
customComboHotkey: null,
translationHotkey: { primary: 'Shift', modifiers: [] },
switchStyleHotkey: { primary: 'S', modifiers: ['alt'] },
openAppHotkey: { primary: 'O', modifiers: ['alt'] },
stylePackHotkeys: [],
codingAgentEnabled: false,
codingAgentProvider: 'claude-code-cli',
codingAgentModel: null,
codingAgentPermissionMode: 'acceptEdits',
codingAgentWorkdir: null,
codingAgentExe: null,
codingAgentVoiceHotkey: { primary: 'LeftControl', modifiers: [] },
codingAgentPanelHotkey: { primary: 'Enter', modifiers: ['cmd', 'shift'] },
codingAgentQuickHotkey: null,
localAsrActiveModel: '',
localWhisperActiveModel: 'whisper-large-v3-turbo',
localAsrMirror: 'huggingface',
localAsrKeepLoadedSecs: 300,
foundryLocalAsrModel: '',
foundryLocalRuntimeSource: 'auto',
foundryLocalAsrLanguageHint: '',
foundryLocalAsrKeepLoadedSecs: 300,
sherpaOnnxModel: 'sense-voice-small-zh',
sherpaOnnxLanguageHint: '',
sherpaOnnxKeepLoadedSecs: 300,
historyRetentionDays: 7,
polishContextWindowMinutes: 5,
startMinimized: false,
themeMode: 'system',
updateChannel: 'stable',
streamingInsert: true,
streamingInsertDefaultMigrated: true,
streamingInsertSaveClipboard: true,
autoUpdateCheck: true,
historyMaxEntries: null,
recordAudioForDebug: false,
audioRecordingMaxEntries: null,
marketplaceBaseUrl: '',
marketplaceDevLogin: '',
remoteInputEnabled: false,
remoteInputPort: 8443,
remoteInputPin: '000000',
remoteInputDefaultMode: 'toggle',
androidInsertStrategy: 'accessibility',
androidOverlayTrigger: 'background',
androidOverlayActivationMode: 'tap',
androidOverlayLeftSwipeAction: 'translation',
androidOverlayCancelSwipeDirection: 'up',
androidOverlaySizeDp: 72,
};
const nextPrefs: UserPreferences = {
...previousPrefs,
enabledModes: [],
};
const states: UserPreferences[] = [];
const errors: string[] = [];
let firstCurrentPrefs: UserPreferences | null = previousPrefs;
const saved = await persistStylePreferenceChange(
nextPrefs,
async () => {
throw 'disk full';
},
update => {
firstCurrentPrefs = typeof update === 'function' ? update(firstCurrentPrefs) : update;
if (firstCurrentPrefs) states.push(firstCurrentPrefs);
},
message => errors.push(message),
rollbackWholeStylePreferences(previousPrefs, nextPrefs),
);
assert(saved === false, 'setSettings reject should report save failure');
assert(states.length === 2, `expected optimistic state then rollback, got ${states.length} updates`);
assert(states[0] === nextPrefs, 'first state update should be the optimistic next prefs');
assert(
states[1].enabledModes.join(',') === previousPrefs.enabledModes.join(','),
'second state update should roll back enabled modes to previous prefs',
);
assert(errors[0] === 'disk full', `expected backend error message, got ${errors[0]}`);
let currentPrefs: UserPreferences | null = previousPrefs;
const disableLightPrefs: UserPreferences = {
...previousPrefs,
enabledModes: ['raw', 'structured'],
};
const disableStructuredAfterLightPrefs: UserPreferences = {
...previousPrefs,
enabledModes: ['raw'],
};
const overlapSaved = await persistStylePreferenceChange(
disableLightPrefs,
async () => {
currentPrefs = disableStructuredAfterLightPrefs;
throw 'slow failure';
},
update => {
currentPrefs = typeof update === 'function' ? update(currentPrefs) : update;
},
() => undefined,
rollbackStyleEnabledChange('light', previousPrefs, disableLightPrefs),
);
assert(overlapSaved === false, 'overlapped style save should still report failure');
assert(
currentPrefs?.enabledModes.includes('light') === true,
'failed light toggle should roll back only the light mode',
);
assert(
currentPrefs?.enabledModes.includes('structured') === false,
'failed light toggle should preserve newer structured edit',
);
const notifiedPrefs: UserPreferences = {
...previousPrefs,
defaultMode: 'formal',
enabledModes: ['raw', 'formal'],
};
const syncedPrefs = applyStylePreferencesNotification(previousPrefs, notifiedPrefs);
assert(syncedPrefs === notifiedPrefs, 'prefs notification should replace stale style page prefs');
const masterOffPrefs = styleMasterOffPreferences(previousPrefs);
assert(
masterOffPrefs.enabledModes.join(',') === 'raw,light',
`master toggle off should persist raw and current default, got ${masterOffPrefs.enabledModes.join(',')}`,
);
const masterFallback = styleMasterFallbackModes('light');
assert(
masterFallback.join(',') === 'raw,light',
`master toggle off should preserve raw and current default, got ${masterFallback.join(',')}`,
);
assert(
isStyleMasterEnabled({ ...previousPrefs, enabledModes: masterFallback }) === false,
'master toggle should render off when only raw and default remain enabled',
);
assert(
isStyleMasterEnabled(previousPrefs) === true,
'master toggle should render on when extra styles remain enabled',
);
const rawFallback = styleMasterFallbackModes('raw');
assert(
rawFallback.join(',') === 'raw',
`raw default fallback should not duplicate raw, got ${rawFallback.join(',')}`,
);
const defaultAfterMasterOff = styleDefaultModePreferences(
{ ...previousPrefs, enabledModes: ['raw', 'light'] },
'formal',
);
assert(
defaultAfterMasterOff.defaultMode === 'formal' && defaultAfterMasterOff.enabledModes.join(',') === 'raw,formal',
`default change while master is off should refresh fallback modes, got ${defaultAfterMasterOff.defaultMode}/${defaultAfterMasterOff.enabledModes.join(',')}`,
);
assert(
isStyleMasterEnabled(defaultAfterMasterOff) === false,
'master toggle should stay off after changing default while off',
);
let rolledBackDefaultAndEnabled: UserPreferences | null = defaultAfterMasterOff;
const rollbackDefaultAndEnabled = rollbackDefaultAndEnabledChange(
{ ...previousPrefs, enabledModes: ['raw', 'light'] },
defaultAfterMasterOff,
);
rolledBackDefaultAndEnabled = rollbackDefaultAndEnabled(rolledBackDefaultAndEnabled);
assert(
rolledBackDefaultAndEnabled?.defaultMode === 'light' && rolledBackDefaultAndEnabled.enabledModes.join(',') === 'raw,light',
'failed off-state default save should roll back both default mode and enabled modes',
);
-120
View File
@@ -1,120 +0,0 @@
import type { PolishMode, UserPreferences } from './types';
type StylePrefsState = UserPreferences | null;
type StylePrefsSetter = (prefs: StylePrefsState | ((current: StylePrefsState) => StylePrefsState)) => void;
type StylePrefsRollback = (current: StylePrefsState) => StylePrefsState;
export function styleSaveErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
return String(error);
}
export async function persistStylePreferenceChange(
nextPrefs: UserPreferences,
persist: () => Promise<void>,
setPrefs: StylePrefsSetter,
onFailure: (message: string) => void,
rollback: StylePrefsRollback,
): Promise<boolean> {
setPrefs(nextPrefs);
try {
await persist();
return true;
} catch (error) {
setPrefs(current => rollback(current));
onFailure(styleSaveErrorMessage(error));
return false;
}
}
export function applyStylePreferencesNotification(
_current: UserPreferences | null,
incoming: UserPreferences,
): UserPreferences {
return incoming;
}
export function styleMasterFallbackModes(defaultMode: PolishMode): PolishMode[] {
return defaultMode === 'raw' ? ['raw'] : ['raw', defaultMode];
}
export function isStyleMasterEnabled(prefs: UserPreferences): boolean {
return !sameModeSet(prefs.enabledModes, styleMasterFallbackModes(prefs.defaultMode));
}
export function styleMasterOffPreferences(prefs: UserPreferences): UserPreferences {
return { ...prefs, enabledModes: styleMasterFallbackModes(prefs.defaultMode) };
}
export function styleDefaultModePreferences(prefs: UserPreferences, mode: PolishMode): UserPreferences {
const nextPrefs = { ...prefs, defaultMode: mode };
return isStyleMasterEnabled(prefs) ? nextPrefs : styleMasterOffPreferences(nextPrefs);
}
export function rollbackDefaultModeChange(
previousPrefs: UserPreferences,
nextPrefs: UserPreferences,
): StylePrefsRollback {
return current => {
if (!current || current.defaultMode !== nextPrefs.defaultMode) return current;
return { ...current, defaultMode: previousPrefs.defaultMode };
};
}
export function rollbackStyleEnabledChange(
mode: PolishMode,
previousPrefs: UserPreferences,
nextPrefs: UserPreferences,
): StylePrefsRollback {
const previousEnabled = previousPrefs.enabledModes.includes(mode);
const nextEnabled = nextPrefs.enabledModes.includes(mode);
return current => {
if (!current || current.enabledModes.includes(mode) !== nextEnabled) return current;
const enabledModes = previousEnabled
? Array.from(new Set([...current.enabledModes, mode]))
: current.enabledModes.filter(m => m !== mode);
return { ...current, enabledModes };
};
}
export function rollbackWholeStylePreferences(
previousPrefs: UserPreferences,
nextPrefs: UserPreferences,
): StylePrefsRollback {
return current => {
if (!current || !sameModes(current.enabledModes, nextPrefs.enabledModes)) return current;
return { ...current, enabledModes: previousPrefs.enabledModes };
};
}
export function rollbackDefaultAndEnabledChange(
previousPrefs: UserPreferences,
nextPrefs: UserPreferences,
): StylePrefsRollback {
return current => {
if (
!current ||
current.defaultMode !== nextPrefs.defaultMode ||
!sameModes(current.enabledModes, nextPrefs.enabledModes)
) {
return current;
}
return {
...current,
defaultMode: previousPrefs.defaultMode,
enabledModes: previousPrefs.enabledModes,
};
};
}
function sameModeSet(left: PolishMode[], right: PolishMode[]): boolean {
if (left.length !== right.length) return false;
const rightSet = new Set(right);
return left.every(mode => rightSet.has(mode));
}
function sameModes(left: PolishMode[], right: PolishMode[]): boolean {
if (left.length !== right.length) return false;
return left.every((mode, index) => mode === right[index]);
}
+2 -9
View File
@@ -1,12 +1,5 @@
// useExitMount — 条件渲染弹窗/浮层的退出动画门(2.0 UI 走查「从哪来回到哪去」)。
//
// 问题:`{open && <Modal/>}` 在 open 变 false 的瞬间直接卸载,入场动画没有对应的
// 退场可播。本钩子把「渲染中」与「打开」解耦:open 变 false 时保持挂载并返回
// closing=true,让组件反向播放入场动画,exitMs 后才真正卸载。
//
// 用法:
// const gate = useExitMount(open);
// {gate.mounted && <Overlay closing={gate.closing} ... />}
// 关闭浮层时保持挂载至退出动画结束;再次打开会取消待卸载计时器。
// 调用方用 mounted 控制渲染、closing 控制退场样式,exitMs 与 CSS 动画时长保持一致。
import { useEffect, useState } from 'react';
+1 -1
View File
@@ -1,4 +1,4 @@
// Corrections.tsx — 「纠正规则」独立页(2.0 UI 走查:从词典页迁出,归入侧栏「工具」组)。
// Corrections.tsx — 「纠正规则」独立页。
// 修正常见 ASR 误识别:pattern → replacement,支持 {num} 一个数字通配。
// 自动收集(source === 'learned')的规则可单独筛出并一键清空 —— 与词典页的
// 「自动添加」筛选同一套信任前提:用户随时能看清、能整块撤销。
+6 -6
View File
@@ -322,7 +322,7 @@ export function History() {
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : '300px 1fr', gap: 14, flex: 1, minHeight: 0 }}>
{( !mobile || !mobileDetailOpen) && (
<Card padding={0} style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* 2.0 UI 走查:左列整体是一个滚动容器,搜索框吸顶且自带不透明底 ——
{/* 左列整体是一个滚动容器,搜索框吸顶且自带不透明底 ——
列表内容直接从搜索框下方滚过去;样式筛选 chips 与「共 N 条」计数行
删掉,只保留搜索。 */}
<div className="ol-thinscroll" style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
@@ -376,7 +376,7 @@ export function History() {
setSelectedId(s.id);
if (mobile) setMobileDetailOpen(true);
}}
// 2.0 UI 走查:选中项不再用蓝色左条 + 淡蓝底 —— 与渠道行同一套
// 选中项不再用蓝色左条 + 淡蓝底 —— 与渠道行同一套
// 中性语言:圆角 + 灰底 + 细描边。
style={{
width: '100%', padding: '10px 12px', textAlign: 'left',
@@ -386,7 +386,7 @@ export function History() {
background: selectedId === s.id ? 'var(--ol-surface-2)' : 'transparent',
cursor: 'default', fontFamily: 'inherit', marginBottom: 4,
transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)',
// 搜索过滤/新记录插入时,进入结果的行淡入轻降(2.0 UI 走查)。
// 搜索过滤/新记录插入时,进入结果的行淡入轻降。
animation: 'ol-item-in 0.22s var(--ol-motion-spring) both',
}}
>
@@ -532,7 +532,7 @@ export function History() {
{item.rawTranscript || t('history.rawEmpty')}
</p>
</div>
{/* 润色结果框同样去蓝:中性 surface-2 底 + 细线描边(2.0 UI 走查)。 */}
{/* 润色结果框同样去蓝:中性 surface-2 底 + 细线描边。 */}
<div style={{ minWidth: 0, padding: 14, border: '0.5px solid var(--ol-line)', borderRadius: 10, background: 'var(--ol-surface-2)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10 }}>
<span style={{ display: 'flex', minWidth: 0 }} title={styleLabel(item)}>
@@ -680,7 +680,7 @@ function RepolishPanel({ session, mobile, allPacks, packsError }: {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap', marginBottom: 12 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-2)' }}>
{t('history.repolish.title')}
{/* 2.0 UI 走查:常驻说明文字收进「?」徽章,悬停/聚焦才展开全文(与设置页同款)。 */}
{/* 常驻说明文字收进「?」徽章,悬停/聚焦才展开全文(与设置页同款)。 */}
<Tooltip content={t('history.repolish.hint')} wrap placement="bottom" focusable>
<span style={{
width: 16, height: 16, borderRadius: 999,
@@ -718,7 +718,7 @@ function RepolishPanel({ session, mobile, allPacks, packsError }: {
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>{t('history.repolish.noPacks')}</span>
) : (
<>
{/* 2.0 UI 走查:统一用官方 SelectLite,不再混用原生 <select>。 */}
{/* 统一用官方 SelectLite,不再混用原生 <select>。 */}
<SelectLite
value={selectedPackId}
onChange={setSelectedPackId}
@@ -12,7 +12,7 @@ import {
type LocalAsrModelStatus,
type LocalAsrTestResult,
} from "../../lib/localAsr"
import { Btn, Card, Collapsible, Pill } from "../_atoms"
import { Btn, Card, Pill } from "../_atoms"
import { Icon } from "../../components/Icon"
import { formatBytes } from "./helpers"
import type { RemoteSize } from "./types"
+6 -26
View File
@@ -39,7 +39,6 @@ import {
getFoundryLocalAsrCatalog,
getFoundryLocalAsrStatus,
getLocalAsrEngineStatus,
getLocalAsrModelDir,
getLocalAsrSettings,
getSherpaOnnxAsrCatalog,
getSherpaOnnxAsrModelDir,
@@ -113,7 +112,6 @@ import type { RemoteSize } from "./types"
// Qwen3 模型管理 UI 仍按桌面端守严,具体后端由平台能力与渠道选择决定。
const OS = detectOS()
const IS_WINDOWS = OS === "win"
const IS_MAC = OS === "mac"
const IS_QWEN_PLATFORM = OS === "mac" || OS === "linux"
interface LocalAsrProps {
@@ -181,7 +179,6 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
const [selectedModelId, setSelectedModelId] = useState<string | null>(null)
// 下载弹框开关:点侧栏「下载新模型」/ 看板「下载」打开。
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false)
const [modelDirs, setModelDirs] = useState<Record<string, string>>({})
const [progress, setProgress] = useState<
Record<string, LocalAsrDownloadProgress>
>({})
@@ -511,21 +508,6 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
)
setSettings(s)
setModels(supportedModels)
void Promise.all(
supportedModels.map(async (m) => {
try {
const dir = await getLocalAsrModelDir(m.id)
if (!isCurrent()) return
setModelDirs((current) =>
current[m.id] === dir
? current
: { ...current, [m.id]: dir },
)
} catch (err) {
console.warn("[localAsr] Qwen3 model dir query failed", err)
}
}),
)
void refreshEngineStatus()
if (IS_WINDOWS) {
void refreshFoundryStatus()
@@ -1487,8 +1469,6 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
try {
setError(null)
await revealLocalAsrModelDir(modelId)
const dir = await getLocalAsrModelDir(modelId)
setModelDirs((current) => ({ ...current, [modelId]: dir }))
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
@@ -2174,7 +2154,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
{t("localAsr.mirrorDesc")}
</div>
</div>
{/* 2.0 UI 走查:统一官方 SelectLite。 */}
<SelectLite
value={settings?.mirror ?? "huggingface"}
onChange={(v) => void handleMirrorChange(v)}
@@ -2292,7 +2272,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
{t("localAsr.keepLoadedDesc")}
</div>
</div>
{/* 2.0 UI 走查:统一官方 SelectLite。 */}
<SelectLite
value={String(
engineStatus?.keepLoadedSecs ?? 300,
@@ -2572,7 +2552,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
}}
>
{t("localAsr.foundrySelectedModel")}
{/* 2.0 UI 走查:统一官方 SelectLite;滚动保持
{/* 统一官方 SelectLite;滚动保持
改从行容器 ref 向上找滚动祖先。 */}
<SelectLite
value={selectedFoundryAlias}
@@ -2629,7 +2609,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
}}
>
{t("localAsr.foundryRuntimeSourceLabel")}
{/* 2.0 UI 走查:统一官方 SelectLite。 */}
<SelectLite
value={selectedFoundryRuntimeSource}
onChange={(v) => {
@@ -2683,7 +2663,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
}}
>
{t("localAsr.foundryLanguageLabel")}
{/* 2.0 UI 走查:统一官方 SelectLite。 */}
<SelectLite
value={selectedFoundryLanguageHint}
onChange={(v) => {
@@ -3074,7 +3054,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
}}
>
{t("localAsr.mirrorLabel")}
{/* 2.0 UI 走查:统一官方 SelectLite。 */}
<SelectLite
value={selectedSherpaMirrorValue}
onChange={(v) =>
+5 -5
View File
@@ -229,14 +229,14 @@ export function Overview({ onOpenHistory, onOpenSettings }: OverviewProps) {
hasShortcut: Boolean(prefs?.dictationHotkey.primary.trim()),
});
const openSettings = (section: OverviewSettingsSection) => onOpenSettings?.(section);
// 2.0 UI 走查:已配置完成的服务商卡不再常驻(没有信息价值),只展示仍待配置的
// 已配置完成的服务商卡不再常驻(没有信息价值),只展示仍待配置的
// 卡作为提醒;全部配置完成后整组「当前语音服务」隐藏。凭据加载中/拉取失败
// (providers 为空)时保留占位卡,避免页面闪空。
const pendingProviders = setup.providers.filter(p => !p.configured);
const showProvidersSection = setup.providers.length === 0 || pendingProviders.length > 0;
return (
// 单屏固定页(2.0 UI 走查):不滚动,撑满外壳给定的高度,所有仪表盘在一屏内
// 单屏固定页:不滚动,撑满外壳给定的高度,所有仪表盘在一屏内
// 弹性分配;窗口压到很矮时由底部行内部收缩(最近识别列表内滚),页面本身不出滚动条。
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, minWidth: 0, gap: 14 }}>
<PageHeader
@@ -383,7 +383,7 @@ function ProviderCard({ kind, name, status, onConfigure }: ProviderCardProps) {
function ActivityHeatmapCard({ activity }: { activity: ActivityDay[] }) {
const { t, i18n } = useTranslation();
const { endDate, startDate, data, labels } = useMemo(() => {
// 2.0 UI 走查:年历按日历年铺满——1 月 1 日起、12 月 31 日止,从最左排到最右;
// 年历按日历年铺满——1 月 1 日起、12 月 31 日止,从最左排到最右;
// 此前的滚动 365 天窗口会让月份标号从年中开始、右侧留空,观感像「缺数据」。
const now = new Date();
const year = now.getFullYear();
@@ -414,10 +414,10 @@ function ActivityHeatmapCard({ activity }: { activity: ActivityDay[] }) {
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--ol-ink-2)', flexShrink: 0 }}>
{t('overview.activityTitle')}
</span>
{/* 格子不再封顶:53 列随卡片宽度铺满到右缘(2.0 UI 走查:长度要排到最右侧)。
{/* 格子不再封顶:53 列随卡片宽度铺满到右缘。
卡片高度收紧为内容高度(flex 默认 0 1 auto:不拉伸、窗口压矮时才收缩),
四边 padding 一致;腾出的高度全部让给上方周期/最近识别行,热力图整体沉底
(2.0 UI 走查:消除卡片底部大片留白、上方两卡拉高)。 */}
。 */}
<Heatmap
data={data}
startDate={startDate}
+1 -1
View File
@@ -12,7 +12,7 @@
// 语音状态**只在输入区**表达(用户拍板:不进面板上部):
// 录音 = 输入组绕圈红光;转译思考(问题还没落到对话里)= 绕圈黑光;
// 问题落定发出后光停,此时才在对话里出现助手思考行 —— 头像永远不先于
// 用户的话出现(修「AI 头像先出来、动画抽搐」)。
// 用户的话出现(修「AI 头像先出来、动画抖动」)。
//
// 触发链路:
// 1) 用户按 Cmd+Shift+;(默认)→ 后端 toggle 浮窗可见性;显示时发
+1 -1
View File
@@ -205,7 +205,7 @@ export function Translation() {
<SavedToast saveState={saveState} message={saveMessage} />
{/* 2.0 UI 走查:宽屏下「工作语言 / 目标语言」并排两栏(窄屏自动叠成一栏),
{/* 宽屏下「工作语言 / 目标语言」并排两栏(窄屏自动叠成一栏),
语言 chips 用对齐的均匀网格代替自由换行,避免参差的标签云。 */}
<div className="ol-translation-grid">
{/* 1. 工作语言 */}
+4 -4
View File
@@ -1,4 +1,4 @@
// Vocab.tsx — 「词典」页(2.0 UI 走查:由词汇表改版)。
// Vocab.tsx — 「词典」页。
// 结构:
// - 顶部:标题 + 右上「新词」入口(弹窗:直接输入 或 从预设模板批量导入)
// - 工具行:所有 / 自动添加 / 手动添加 分段筛选 + 右侧圆形搜索(点击向左展开)
@@ -61,7 +61,7 @@ export function Vocab() {
const editMount = useExitMount(editingEntry !== null);
const newWordMount = useExitMount(newWordOpen);
// 词条网格 FLIP(2.0 UI 走查):增删/筛选让行位移时,从旧位置滑到新位置;
// 词条网格 FLIP:增删/筛选让行位移时,从旧位置滑到新位置;
// 新卡片入场走 .ol-word-card 的 CSS 动画,删除走 onRemove 里的退场动画。
const cardRefs = useRef(new Map<string, HTMLDivElement>());
const prevCardTops = useRef(new Map<string, number>());
@@ -410,7 +410,7 @@ export function Vocab() {
))}
</div>
<div style={{ flex: 1 }} />
{/* 2.0 UI 走查:圆形控件原地展开成搜索框 —— 放大镜固定在右缘不动,
{/* 圆形控件原地展开成搜索框 —— 放大镜固定在右缘不动,
占位文字「搜索」在框内;收起走同一条 width 过渡(从哪来回到哪去)。 */}
<div className={searchOpen ? 'ol-search ol-search-open' : 'ol-search'}>
<input
@@ -483,7 +483,7 @@ export function Vocab() {
</div>
)}
{/* 独立滚动区(2.0 UI 走查):词条多时只有这一格在滚,底部面板固定在视口
{/* 独立滚动区:词条多时只有这一格在滚,底部面板固定在视口
底缘、白底天然遮挡滚过去的内容;之前网格作为页根 flex item 被压扁、内容
溢出到下方区块背后的穿帮也从根上消除。 */}
<div
+1 -1
View File
@@ -12,7 +12,7 @@ interface PageHeaderProps {
desc?: string;
right?: ReactNode;
titleRight?: ReactNode;
/** 概览等单屏页用:收紧标题与下方内容的间距(2.0 UI 走查:上方留白过多)。 */
/** 概览等单屏页用:收紧标题与下方内容的间距。 */
compact?: boolean;
}
@@ -1,7 +1,11 @@
import { createContext } from 'react';
/** 设置右侧的渠道编辑容器;引导页不提供此容器,继续使用独立弹窗。 */
export const ChannelEditorHostContext = createContext<{
/** 子页挂载位置,覆盖右侧内容但保留左侧导航。 */
container: HTMLDivElement | null;
/** 被子页覆盖的内容;编辑期间设为 inert,避免键盘焦点落入背后。 */
background: HTMLDivElement | null;
/** 切换设置分类或关闭设置时,先完成编辑器的草稿清理。 */
registerClose: (close: (() => void) | null) => void;
} | null>(null);
@@ -1,11 +1,5 @@
// 渠道卡片列表 —— LLM 润色与 ASR 语音转写共用同一套交互。
//
// 心智只有一条:**排序即优先级,列表里第一个启用的就是当前生效的渠道**。
// 开关关掉的渠道自动沉到列表末尾;后端不另存"当前选中",避免"列表第一张是 A、
// 实际请求打的是 B"这种两处真相。详见 docs/provider-channels-plan.md。
//
// 卡片解决的两件事:同一家厂商可以存多把 key;key 之间切换只是拖一下顺序,
// 而不是把旧 key 覆盖掉。
// LLM 与 ASR 共用的渠道列表和编辑器。
// Core 按排序选择第一个启用渠道;每个渠道独立保存凭据,支持同一供应商的多个账号。
import { useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
@@ -330,9 +324,8 @@ export function ChannelList({
channelsRef.current = channels;
}, [channels]);
// 2.0 UI 走查:添加/删除/拖序都会让行位置突变,此前没有任何过渡。
// FLIP:每次 channels 变化后对比各行上边缘,位移的行从旧位置滑到新位置,
// 新出现的行淡入下沉进场;被拖的行保留抬升 scale,避免动画盖掉拖拽态。
// FLIP 动画比较更新前后的布局位置,平滑呈现增删与排序变化。
// 正在拖动的行保留自己的 transform,避免滑位动画覆盖拖动态。
const prevRowTops = useRef(new Map<string, number>());
useLayoutEffect(() => {
// 同样只量布局位置(offsetTop):rect.top 会被上一帧仍在飞行的 FLIP
@@ -368,11 +361,8 @@ export function ChannelList({
const dragCleanupRef = useRef<(() => void) | null>(null);
/** 指针移到哪张卡片上,就把被拖的那张插到那个位置 —— 卡片实时跟手。
* 命中测试必须用布局坐标(offsetTop)而不是 getBoundingClientRect:拖动时
* FLIP 滑位动画正在飞行,rect 里掺着动画 transform,会把动画反馈进命中
* 判定 —— 同一指针位置交替命中两张卡、顺序来回翻转,整栏疯狂抽搐
* (2.0 UI 走查修复)。offsetTop 只反映布局位置,不受 transform/动画影响。 */
/** 按指针命中的行实时排序。使用 offsetTop 读取布局坐标,避免 FLIP 的
* transform 改变命中结果,导致同一指针位置反复触发互换。 */
const moveDragTo = (pointerY: number) => {
const dragId = dragIdRef.current;
if (!dragId) return;
@@ -481,7 +471,7 @@ export function ChannelList({
channels.find(c => c.id === (draftId ?? editingId)) ?? null;
const isDraft = draftId != null;
// 弹窗退场门控(2.0 UI 走查「从哪来回到哪去」):closing 动画期间保留最后一次
// 弹窗退场门控:closing 动画期间保留最后一次
// 打开的 channel/isDraft,避免动画播一半内容先消失、标题从「添加」闪回「编辑」。
const dialogMount = useExitMount(editingChannel !== null);
const lastDialogRef = useRef<{ channel: Channel; isDraft: boolean } | null>(null);
@@ -531,7 +521,7 @@ export function ChannelList({
</div>
)}
{/* 2.0 UI 走查:生效渠道不再用蓝底 + 左侧竖条(「当前使用」徽章已经说明问题,
{/* 生效渠道不再用蓝底 + 左侧竖条(「当前使用」徽章已经说明问题,
整行染色太花哨);行改为圆角卡片,选中态只用中性灰底 + 细描边。 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, paddingTop: channels.length ? 12 : 0 }}>
{channels.map(channel => {
@@ -551,7 +541,7 @@ export function ChannelList({
display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '14px 20px',
padding: '14px 12px', borderRadius: 12,
// 拖动态:轻微抬升(scale + 大阴影 + 强描边 + 提高层级),
// 让「哪张在被拖、拖到哪了」一目了然(2.0 UI 走查)。
// 让「哪张在被拖、拖到哪了」一目了然。
border: '0.5px solid',
borderColor: draggingId === channel.id ? 'var(--ol-line-strong)' : isActive ? 'var(--ol-line)' : 'transparent',
background: isActive ? 'var(--ol-surface-2)' : 'transparent',
@@ -661,10 +651,7 @@ function ChannelTestResult({ channel, testing, t }: {
}
/**
* 「服务 → AI 提供商」面板:LLM 与 ASR 两张渠道列表。
*
* 保留 `ProvidersSection` 这个名字与 `kind` 签名,让设置页 tabs 与新手引导的调用点
* 不用改。渠道化之后它只是两个 <ChannelList> 的容器。
* 为设置与新手引导提供 LLM/ASR 渠道列表;多模态管线启用时展示 Omni 配置。
*/
export function ProvidersSection({
kind = 'all',
@@ -698,10 +685,8 @@ export function ProvidersSection({
}
/**
* 添加与编辑共用的同一个弹窗 —— 供应商、名字、凭据、测试连通都在这一屏里。
*
* 刻意不做「先创建、再填凭据」的两步:那只是实现上需要先有渠道 id 才能写凭据,
* 不该变成用户多点一次。
* 添加与编辑复用同一表单。设置中挂载为右侧子页,新手引导中使用独立弹窗。
* 新建渠道先取得凭据所属的 ID,关闭时仅回收从未发生用户操作的空白草稿。
*/
function ChannelModal({
kind,
@@ -203,7 +203,7 @@ export function ClaudeConsoleSection() {
</SettingRow>
<SettingRow label={t('settings.codingConsole.permissionMode')}>
{/* 2.0 UI 走查:统一官方 SelectLite。 */}
<SelectLite
value={permMode}
onChange={v => setPermMode(v as CodingAgentPermissionMode)}
@@ -1,5 +1,4 @@
// 隐私 → 数据存储:本地保留的历史会话与对话上下文窗口。
// 自 Settings.tsx 的 RecordingSection「历史与上下文」折叠组拆出,逻辑零改动。
// 历史与上下文数据的保留、清理和存储设置。
import { useTranslation } from 'react-i18next';
import { detectOS } from '../../components/WindowChrome';
@@ -1,9 +1,5 @@
// 服务 → 本地模型:本地 ASR 模型的管理入口。含 Qwen3(macOS)/ Foundry Local +
// sherpa-onnx(Windows)三条本地引擎。
//
// 2026-08 重构:本地 ASR 不再有「启用开关」——本地 ASR 始终可用,是否激活由
// 「服务 → AI 提供商」的 ASR 语音转写供应商决定:选到本地模型供应商即使用本地
// 引擎(与 Apple 语音同理)。这里只保留模型下载 / 管理看板(<LocalAsr embedded />)。
// 本地 ASR 模型管理入口。下载与运行状态由 LocalAsr 展示,
// 是否使用该引擎由 AI 服务与模型中的语音识别渠道决定。
import { useEffect, useState } from 'react';
import type { PlatformCapabilities } from '../../lib/types';
@@ -1,10 +1,9 @@
// 服务 → AI 提供商:LLM 润色模型 + ASR 语音转写两张卡片。
// 自 Settings.tsx 整体迁出,逻辑零改动;i18n key 全部保持 `settings.providers.*`。
// 渠道凭据、模型列表和连接验证表单。供应商能力与默认值由 Core 提供,
// 界面负责本地化、字段保存反馈,以及用户主动触发的模型拉取和验证。
import { useEffect, useId, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from '../../components/Icon';
import { detectOS } from '../../components/WindowChrome';
import {
listProviderModels,
listProviderDescriptors,
@@ -32,27 +31,6 @@ import {
serializeAdvancedAsrConfig,
type AdvancedAsrConfig,
} from '../../lib/advancedAsrConfig';
import {
getFoundryLocalAsrCatalog,
getSherpaOnnxAsrCatalog,
listLocalAsrModels,
setFoundryLocalAsrModel,
setLocalAsrActiveModel,
setSherpaOnnxAsrModel,
} from '../../lib/localAsr';
// 本地模型供应商:在主下拉里标注「本地」后缀,与云端供应商区分开。
const LOCAL_ASR_PRESET_IDS: ReadonlySet<string> = new Set([
'local-qwen3-mlx',
'local-qwen3-c',
'local-whisper',
'foundry-local-whisper',
'sherpa-onnx-local',
]);
function isLocalAsrPreset(id: string): boolean {
return LOCAL_ASR_PRESET_IDS.has(id);
}
/** Channel-local form layout; do not change the shared settings row contract. */
export function ChannelFormRow({ label, htmlFor, children }: { label: string; htmlFor?: string; children: ReactNode }) {
const stack = useLayoutStack();
@@ -116,12 +94,7 @@ export const LLM_LABELS = [
['custom', 'custom'],
].map(([id, nameKey]) => ({ id, nameKey })) as readonly { id: string; nameKey: string }[];
// 多模态(Omni)模型预设(issue #902):一个模型同时接收「提示词 + 音频」一步输出
// 最终文本。凭据走独立 `omni.*` 命名空间,与上方 LLM/ASR 两套配置完全隔离。
// - openai : OpenAI 官方(gpt-4o-audio-preview 等,input_audio part)
// - gemini : Gemini 原生 generateContent(inlineData audio/wav)
// - dashscope-omni: 阿里云百炼 OpenAI 兼容通道(qwen3-omni-flash 等)
// - custom : 任意 OpenAI 兼容多模态网关
// 火山语音转写在资源 ID 为空时显示的默认值。
const ASR_DEFAULT_RESOURCE_ID = 'volc.seedasr.sauc.duration';
/** 模型预设下拉里的「自定义模型…」哨兵值:选中即切回输入框手输。 */
@@ -153,9 +126,6 @@ export function ChannelCredentialFields({
}) {
const { t } = useTranslation();
const { prefs, updatePrefs } = useHotkeySettings();
const baseLayoutStack = useLayoutStack();
const conservative = useConservativeLayout();
const layoutStack = conservative || baseLayoutStack;
const [llmModelRevision, setLlmModelRevision] = useState(0);
const [asrModelRevision, setAsrModelRevision] = useState(0);
const unifiedBailian = providerType === 'bailian';
@@ -601,9 +571,6 @@ type ProviderToolStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error';
function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested, onUserMutation, showFetchModels = true }: { kind: 'llm' | 'asr' | 'omni'; modelAccount: string; provider?: string; onModelSelected: () => void; onTested?: () => void; onUserMutation?: () => void; showFetchModels?: boolean }) {
const { t } = useTranslation();
const baseLayoutStack = useLayoutStack();
const conservative = useConservativeLayout();
const layoutStack = conservative || baseLayoutStack;
const [models, setModels] = useState<string[]>([]);
const [selectedModel, setSelectedModel] = useState('');
const [status, setStatus] = useState<ProviderToolStatus>('idle');
@@ -786,9 +753,6 @@ interface CredentialFieldProps {
function CredentialField({ label, account, provider, placeholder, mono, mask, defaultValue, trailing, onValueChange, onUserMutation, options }: CredentialFieldProps) {
const fieldId = useId();
const { t } = useTranslation();
const baseLayoutStack = useLayoutStack();
const conservative = useConservativeLayout();
const layoutStack = conservative || baseLayoutStack;
const [value, setValue] = useState('');
const [revealed, setRevealed] = useState(false);
const [loaded, setLoaded] = useState(false);
@@ -1046,11 +1010,9 @@ const iconBtnStyle: CSSProperties = {
};
/**
* 多模态(Omni)配置卡片:仅在「高级 → 实验性」里打开多模态管线后出现。
*
* 它不参与渠道排序——Omni 是独立命名空间,渠道化范围只覆盖 ASR/LLM
* (见 docs/provider-channels-plan.md 的分期);管道模式切换沿用既有语义:
* 多模态模式下隐藏传统 llm/asr 渠道列表,凭据两套并存但停用,切回即恢复。
* 在「实验与扩展」启用多模态管线后,展示独立的 Omni 配置。
* Omni 使用自己的凭据命名空间,不参与 ASR/LLM 渠道排序;传统渠道配置仍保留,
* 切回传统管线后继续使用。
*/
export function OmniChannelSection() {
const { t } = useTranslation();
@@ -1,6 +1,4 @@
// 通用 → 录音与输入:录音快捷键 / 方式 / 麦克风 / 胶囊 / 静音,
// 外加「插入与剪贴板」「启动」两个折叠组。流式输入也并到这里(属于插入行为)。
// 自 Settings.tsx 的 RecordingSection 拆出,录音相关逻辑零改动。
// 录音与输入设置:录音方式、提示音、输入行为及平台专属选项。
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -344,7 +342,7 @@ export function RecordingInputSection() {
label={t('settings.recording.silenceAutoStopLabel')}
desc={t('settings.recording.silenceAutoStopDesc')}
>
{/* 开关行只放 Toggle:秒数下拉移出本行,避免开关时行内内容变宽导致抽搐。 */}
{/* 开关行只放 Toggle:秒数下拉移出本行,避免开关时行内内容变宽导致抖动。 */}
<Toggle
on={prefs.silenceAutoStopEnabled}
onToggle={next => savePrefs({ ...prefs, silenceAutoStopEnabled: next })}
@@ -102,7 +102,7 @@ export function RemoteInputSection() {
const mode = prefs.remoteInputDefaultMode ?? 'toggle';
const viewState = getRemoteInputViewState(enabled, status, startError);
// 提交端口草稿:非法(非有限数/越界离谱)则丢弃还原显示,合法则取整并 clamp 到 [1024, 65535]。
// 无效或非正数草稿还原显示;有效数值取整并限制在 [1024, 65535]。
const commitPort = () => {
if (portDraft == null) return;
const n = Math.round(Number(portDraft));
@@ -88,7 +88,7 @@ export function SettingRow({
}}
>
<div style={{ minWidth: 0, alignSelf: "center" }}>
{/* 2.0 UI 走查:行内长说明不再常驻占位,统一收进标题旁的「?」,
{/* 行内长说明不再常驻占位,统一收进标题旁的「?」,
悬停 / 点击弹出(与「录音与输入」标题的提示同一交互语言)。 */}
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<span style={{ ...labelStyle, minWidth: 0 }}>{label}</span>
@@ -146,7 +146,7 @@ export function Toggle({
onClick={() => onToggle?.(!on)}
style={{
position: "relative",
// 2.0 UI 走查:此前写死 flex: 0 0 36px,在列方向的设置行包装里
// 此前写死 flex: 0 0 36px,在列方向的设置行包装里
// flex-basis 作用在高度上,开关被拉成 36×36 的圆球(开机自启行)。
// 宽高由 width/height 显式锁定,flex 只负责不伸不缩。
flex: "0 0 auto",
+2 -4
View File
@@ -64,9 +64,7 @@ export function AppearanceTab() {
return <><ThemeSection /><LayoutSection /><LanguageSection /></>;
}
// 服务:AI 提供商 · 本地模型 · 扩展市场。
// 本地模型是「语音识别由谁提供」的一种答案,和云端提供商属同一决策,
// 不再藏进「高级」。
// AI 服务与模型:按当前管线和平台能力组织渠道、本地模型及网络设置。
export function ServicesTab() {
const { t } = useTranslation();
const { prefs } = useHotkeySettings();
@@ -79,7 +77,7 @@ export function ServicesTab() {
const contentRef = useRef<HTMLDivElement>(null);
// 语言模型 / 语音识别是必配项:tab 上挂状态点 —— 未配置红、已配置黄
// (2.0 UI 走查)。任何渠道增删改/启停后 ChannelList 会广播
// 。任何渠道增删改/启停后 ChannelList 会广播
// ol-channels-changed,这里即时重算。
const [requiredConfigured, setRequiredConfigured] = useState<{ llm: boolean; asr: boolean }>({ llm: false, asr: false });
useEffect(() => {
+9 -9
View File
@@ -27,7 +27,7 @@ body {
background: transparent;
user-select: none;
-webkit-user-select: none;
/* 2.0 UI 走查:全局基准字号 14px(此前未设 → 浏览器默认 16 但组件几乎全部
/* 全局基准字号 14px(此前未设 → 浏览器默认 16 但组件几乎全部
显式写 11–13px 小字,视觉偏小)。控件/正文的显式字号已同步上调到 13.5–14。 */
font-size: 14px;
}
@@ -350,7 +350,7 @@ html[data-ol-conservative-layout="true"] .ol-conservative-scope .ol-select-lite-
.ol-advanced-settings-entry { transition: none; }
}
/* 桌面端设置侧栏导航(2.0 UI 走查):选中底色由滑动 thumb 提供,按钮自身透明;
/* 桌面端设置侧栏导航:选中底色由滑动 thumb 提供,按钮自身透明;
hover 给弱底色反馈;选中项文字蓝色加粗走 [aria-current]。 */
.ol-settings-rail-btn {
color: var(--ol-ink-2);
@@ -367,7 +367,7 @@ html[data-ol-conservative-layout="true"] .ol-conservative-scope .ol-select-lite-
font-weight: 600;
}
/* 翻译页顶部两栏(2.0 UI 走查):左栏略宽让语言 chips 每行 4 个,
/* 翻译页顶部两栏:左栏略宽让语言 chips 每行 4 个,
与右栏卡片高度更接近;窄屏叠成单栏。 */
.ol-translation-grid {
display: grid;
@@ -381,7 +381,7 @@ html[data-ol-conservative-layout="true"] .ol-conservative-scope .ol-select-lite-
}
}
/* 语言/标签选择 chip(2.0 UI 走查 · 翻译页):底色走 token 以兼容深色模式,
/* 语言/标签选择 chip:底色走 token 以兼容深色模式,
选中态走 [aria-pressed],悬浮给描边+加深反馈。 */
.ol-chip {
min-width: 0;
@@ -416,7 +416,7 @@ html[data-ol-conservative-layout="true"] .ol-conservative-scope .ol-select-lite-
border-color: var(--ol-blue);
}
/* 列表项通用入场(2.0 UI 走查「从哪来回到哪去」):新条目淡入轻降。
/* 列表项通用入场:新条目淡入轻降。
key 稳定时只有真正新增/过滤变化的项会重播,存量项不动。 */
@keyframes ol-item-in {
from { opacity: 0; transform: translateY(-4px); }
@@ -451,7 +451,7 @@ html[data-ol-conservative-layout="true"] .ol-conservative-scope .ol-select-lite-
to { opacity: 0; }
}
/* ── 词典页(2.0 UI 走查)────────────────────────────────────────────── */
/* ── 词典页────────────────────────────────────────────── */
/* 来源分段筛选:灰底胶囊容器 + 白色浮起选中片(参考 Typeless 词典稿)。 */
.ol-seg {
@@ -489,7 +489,7 @@ html[data-ol-conservative-layout="true"] .ol-conservative-scope .ol-select-lite-
box-shadow: var(--ol-shadow-sm);
}
/* 圆形搜索(2.0 UI 走查重做):同一个胶囊容器原地变形 —— 收起 36px 圆钮,
/* 圆形搜索:同一个胶囊容器原地变形 —— 收起 36px 圆钮,
展开 220px 搜索框;放大镜图标始终贴右缘不动,输入文字在左侧淡入。
展开/收起共用同一条 width 过渡,两个方向都有动画(从哪来回到哪去)。 */
.ol-search {
@@ -524,7 +524,7 @@ html[data-ol-conservative-layout="true"] .ol-conservative-scope .ol-select-lite-
font-size: 13px;
color: var(--ol-ink);
/* 收起态 padding 必须为 0:否则输入框虽然宽度塌成 0,左右 padding 仍占位,
会把 36px 的放大镜按钮顶出圆形容器、被 overflow 裁掉(2.0 UI 走查修复)。 */
会把 36px 的放大镜按钮顶出圆形容器、被 overflow 裁掉。 */
padding: 0;
opacity: 0;
transition: opacity 0.16s var(--ol-motion-quick), padding 0.28s var(--ol-motion-spring);
@@ -670,7 +670,7 @@ html[data-ol-conservative-layout="true"] .ol-conservative-scope .ol-select-lite-
cursor: pointer;
}
/* 必配服务(语言模型/语音识别)tab 上的状态点:未配置红、已配置黄(2.0 UI 走查)。 */
/* 必配服务(语言模型/语音识别)tab 上的状态点:未配置红、已配置黄。 */
.ol-service-status-dot {
width: 7px;
height: 7px;
+2 -38
View File
@@ -111,7 +111,7 @@
light/dark 取值不同需保持主题感知。 */
--ol-panel-glass-bg: rgba(255, 255, 255, 0.94);
/* 2.0 UI 走查(参考 Codex 侧栏):侧栏底色与右侧纯白内容区拉开明确层次,
/* (参考 Codex 侧栏):侧栏底色与右侧纯白内容区拉开明确层次,
此前 #fafafa 对白底几乎无区分。 */
--ol-sidebar-bg: #f0f0f1;
--ol-sidebar-border: #e4e4e7;
@@ -165,34 +165,16 @@
--ol-toggle-knob: #ffffff;
--ol-overlay-bg: rgba(15,17,22,0.32);
--ol-style-shell-bg: linear-gradient(180deg, rgba(255,255,255,0.84), rgba(249,250,252,0.72));
--ol-style-shell-border: rgba(255,255,255,0.72);
--ol-style-shell-shadow: 0 32px 80px -40px rgba(15, 23, 42, 0.26);
--ol-style-card-bg: #fafafa;
--ol-style-card-bg-active: #e4e4e7;
--ol-style-card-border: #e4e4e7;
--ol-style-card-border-active: #bfc0c5;
--ol-style-card-shadow: 0 24px 48px -34px rgba(15, 23, 42, 0.18), inset 0 1px 0 0 rgba(255,255,255,0.65);
--ol-style-card-shadow-active: none;
--ol-style-card-ink: #09090b;
--ol-style-card-ink-2: rgba(9, 9, 11, 0.82);
--ol-style-card-ink-3: rgba(9, 9, 11, 0.56);
--ol-style-card-icon-bg: rgba(228,228,231,0.62);
--ol-style-card-icon-bg-active: #d4d4d8;
--ol-style-delete-bg: rgba(255,255,255,0.56);
--ol-style-delete-border: rgba(248,113,113,0.36);
--ol-style-add-tile-bg: rgba(255,255,255,0.12);
--ol-style-add-tile-icon-bg: rgba(255,255,255,0.48);
--ol-style-add-tile-border: rgba(255,255,255,0.32);
--ol-style-input-bg: rgba(255,255,255,0.86);
--ol-style-input-border: rgba(255,255,255,0.74);
--ol-style-input-shadow: inset 0 1px 0 0 rgba(255,255,255,0.84), 0 10px 24px -22px rgba(15,23,42,0.16);
--ol-style-subtle-bg: rgba(255,255,255,0.74);
--ol-style-subtle-border: rgba(255,255,255,0.62);
--ol-style-subtle-shadow: inset 0 1px 0 0 rgba(255,255,255,0.74);
--ol-style-editor-bg: linear-gradient(180deg, rgba(255,255,255,0.92), rgba(247,249,252,0.82));
--ol-style-editor-border: rgba(255,255,255,0.74);
--ol-style-editor-shadow: 0 32px 96px -32px rgba(15,23,42,0.34);
/* Desktop capsule: follows app theme (light frosted pill / dark solid pill). */
--ol-capsule-pill-bg: rgba(255, 255, 255, 0.90);
@@ -332,34 +314,16 @@
--ol-toggle-knob: #fafafa;
--ol-overlay-bg: rgba(5, 8, 13, 0.64);
--ol-style-shell-bg: linear-gradient(180deg, rgba(24, 24, 27, 0.96), rgba(20, 20, 23, 0.94));
--ol-style-shell-border: rgba(226,232,240,0.09);
--ol-style-shell-shadow: 0 26px 70px -42px rgba(0,0,0,0.66);
--ol-style-card-bg: #252528;
--ol-style-card-bg-active: #37373c;
--ol-style-card-border: rgba(226,232,240,0.09);
--ol-style-card-border-active: #62626a;
--ol-style-card-shadow: inset 0 1px 0 rgba(255,255,255,0.04);
--ol-style-card-shadow-active: none;
--ol-style-card-ink: #fafafa;
--ol-style-card-ink-2: rgba(250,250,250,0.86);
--ol-style-card-ink-3: rgba(250,250,250,0.64);
--ol-style-card-icon-bg: rgba(226,232,240,0.07);
--ol-style-card-icon-bg-active: #45454d;
--ol-style-delete-bg: rgba(226,232,240,0.06);
--ol-style-delete-border: rgba(248,113,113,0.34);
--ol-style-add-tile-bg: rgba(226,232,240,0.035);
--ol-style-add-tile-icon-bg: rgba(226,232,240,0.10);
--ol-style-add-tile-border: rgba(226,232,240,0.12);
--ol-style-input-bg: rgba(9, 9, 11, 0.38);
--ol-style-input-border: rgba(226,232,240,0.10);
--ol-style-input-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
--ol-style-subtle-bg: rgba(226,232,240,0.05);
--ol-style-subtle-border: rgba(226,232,240,0.08);
--ol-style-subtle-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
--ol-style-editor-bg: linear-gradient(180deg, rgba(24, 24, 27, 0.98), rgba(20, 20, 23, 0.96));
--ol-style-editor-border: rgba(226,232,240,0.10);
--ol-style-editor-shadow: 0 30px 90px -42px rgba(0,0,0,0.70);
--ol-capsule-pill-bg: #1b222c;
--ol-capsule-pill-border: rgba(226, 232, 240, 0.12);
@@ -392,7 +356,7 @@ body {
.ol-noscrollbar { scrollbar-width: none; }
/* Thin scrollbar */
/* 2.0 UI 走查:内容变长出现 6px 细滚动条的瞬间会把内容宽度挤窄一格,整页「抽搐」。
/* 内容变长出现 6px 细滚动条的瞬间会把内容宽度挤窄一格,整页「抖动」。
scrollbar-gutter: stable 让滚动条槽位常驻预留,滚动条出现/消失不再触发布局回流。 */
.ol-thinscroll { scrollbar-gutter: stable; }
/* 概览是 overflow:hidden 的单屏固定页,永远不会有滚动条 —— 不占槽位。 */
+19 -6
View File
@@ -2,7 +2,11 @@
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
@@ -12,14 +16,23 @@
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@android/*": ["android/frontend/*"]
"@android/*": [
"android/frontend/*"
]
}
},
"include": ["src", "android/frontend"],
"references": [{ "path": "./tsconfig.node.json" }]
"include": [
"src",
"android/frontend"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}