refactor(app): 清理废弃代码并明确模块注释
This commit is contained in:
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 {
|
||||
|
||||
@@ -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) {}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` 覆盖
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
@@ -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,7 +1,6 @@
|
||||
import type {
|
||||
CorrectionRule,
|
||||
DictionaryEntry,
|
||||
PendingCorrection,
|
||||
VocabPresetStore,
|
||||
} from "../types"
|
||||
import { invokeOrMock } from "./shared"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,4 +1,4 @@
|
||||
// Corrections.tsx — 「纠正规则」独立页(2.0 UI 走查:从词典页迁出,归入侧栏「工具」组)。
|
||||
// Corrections.tsx — 「纠正规则」独立页。
|
||||
// 修正常见 ASR 误识别:pattern → replacement,支持 {num} 一个数字通配。
|
||||
// 自动收集(source === 'learned')的规则可单独筛出并一键清空 —— 与词典页的
|
||||
// 「自动添加」筛选同一套信任前提:用户随时能看清、能整块撤销。
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// 语音状态**只在输入区**表达(用户拍板:不进面板上部):
|
||||
// 录音 = 输入组绕圈红光;转译思考(问题还没落到对话里)= 绕圈黑光;
|
||||
// 问题落定发出后光停,此时才在对话里出现助手思考行 —— 头像永远不先于
|
||||
// 用户的话出现(修「AI 头像先出来、动画抽搐」)。
|
||||
// 用户的话出现(修「AI 头像先出来、动画抖动」)。
|
||||
//
|
||||
// 触发链路:
|
||||
// 1) 用户按 Cmd+Shift+;(默认)→ 后端 toggle 浮窗可见性;显示时发
|
||||
|
||||
@@ -205,7 +205,7 @@ export function Translation() {
|
||||
|
||||
<SavedToast saveState={saveState} message={saveMessage} />
|
||||
|
||||
{/* 2.0 UI 走查:宽屏下「工作语言 / 目标语言」并排两栏(窄屏自动叠成一栏),
|
||||
{/* 宽屏下「工作语言 / 目标语言」并排两栏(窄屏自动叠成一栏),
|
||||
语言 chips 用对齐的均匀网格代替自由换行,避免参差的标签云。 */}
|
||||
<div className="ol-translation-grid">
|
||||
{/* 1. 工作语言 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 的单屏固定页,永远不会有滚动条 —— 不占槽位。 */
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user