feat(ui): Core 2.0 入口、设置与布局整理,Linux 引导与视觉改进

- 主页前置下一步与真实服务状态,服务卡直达对应设置;概览与热力图排布调整
- 设置按任务分类并支持搜索、焦点返回与 Escape 语义;旧 section ID 保持稳定
- AI 分区拆分语言模型/语音识别/本地模型/连接;渠道列表与表单重构,设置实底
- 本地模型管理与下载目录重排,补空态、主操作与窄屏布局
- Core 生成 provider descriptor 公开目录供浏览器预览懒加载,原生 IPC 合同不变
- Linux egui 按开始/听写/AI 服务/模型/工具/手机输入/历史/设置组织页面并加环境引导
- 五语言文案与文档(体验梳理、架构、计划收口)同步
This commit is contained in:
sim committed 2026-09-07 12:51:24 +08:00
1 parent fc38edd1a3
commit 30e580fc37
44 files changed
+3955 -1845

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
# Core 2.0 体验梳理与页面改进
状态:draft;阶段:implementation-backed;更新:2026-09-07。
## 基线与结论
实时抓取 `Open-Less/openless` 后确认,#1019 将 Core 2.0 合并到默认主线 `beta`,提交 `fc38edd1a3da3a4f2fa56f433be84278c3456ed1`。稳定 `main` 是 1.3.18 的 `08d70601`。本次按用户授权删除应用仓库旧的未提交修改,用前者替换工作区,再建立 `feat/core-2-ux-linux`。
项目名为 OpenLess,迁移名为 **Core 2.0**;应用显示版本 `2.0.0-Beta.1` 与后端公开合同 `2.0.0` 是不同概念。范围仍以 [2.0 需求](2.0-requirements.md)为准。
2.0 的重要收益是业务状态、渠道、迁移与会话取消进入共享 Core。主要体验问题是新架构已就位,界面仍让用户自己推断操作顺序:主页以统计为主,设置混放大量任务,Linux 原有领域界面顺序堆叠。本次优先整理现有功能的入口与显示,不增加另一套业务规则。
## 迁移领域与页面影响
本次按 #1019 的变更清单、公开合同、领域出口、平台交接与实际页面梳理;下表是迁移面索引和体验判断,不是逐行安全审计或全部设备验收。
| 领域 / 桌面验收项 | 2.0 相关实现 | 体验判断与本次处理 |
| --- | --- | --- |
| 启动、生命周期 D01 | `App.tsx`、IPC `shared.ts`、Core `api.rs` | 保留所有窗口的合同检查;补可读启动状态、重查按钮与折叠错误详情 |
| 听写、音频、风格/语言 D02/D03/D07 | Core `dictation_engine.rs`、`audio.rs`、`silence_auto_stop.rs`、`prompt_compose.rs` | 主页解释下一步;录音、外观语言拆分类;保留现有首帧、热键和停止语义 |
| 云服务、渠道与凭据 D04/D05 | Core `provider_rules.rs`、`provider_service.rs`、`credentials.rs`;`ChannelList` | 主页按有效 traditional/Omni 状态展示服务,直接进入 AI 设置;渠道规则继续用 descriptor |
| 本地模型 D06 | Core `model_store.rs`、`local_asr_service.rs`;React LocalAsr / Linux models | 仍放 AI 服务范围,保留原子激活与下载取消;Linux 管理深度缺口留在 L08 |
| 快捷键与插入 D08/D09 | Core `hotkey_interpreter.rs`、`streaming_insert.rs`;各 Host 原生 Adapter | 单独的快捷键与选区分类;权限诊断可直达;不更改原生目标和回退策略 |
| QA、选区、Agent D10–D12 | Core `qa_service.rs`、`selection*_service.rs`、`less_computer.rs` | 页头解释划词追问用途;Linux 分页仍保留全局待办、审批和取消 |
| 历史、词典与纠错 D13/D14 | Core `history.rs`、`activity.rs`、`vocabulary.rs`、`correction.rs` | 统计置于主页次级;历史在窄窗口使用列表/详情;词汇页面沿用列表与编辑布局 |
| 风格包与市场 D15/D16 | Core `style_pack_store.rs`、`style_packs.rs`、`marketplace.rs` | 保留主导航的风格分组;共同页头操作区允许换行,降低多语言窄窗拥挤 |
| 手机输入 D17 | Core `remote_input_service.rs`、各 Host TLS/H5 | React 保留录音与输入内入口;Linux 独立手机页、连接状态与陈旧地址处理 |
| 设置与维护 D18 | Core `settings.rs`、`preferences.rs`、React settings / Linux settings | 分类、搜索、自动保存提示、直接可见说明、焦点与滚动恢复 |
| 旧数据 D19 | Core credentials/model/preferences 等迁移;Host vault/data roots | 本次不改变迁移;真实升级与回滚继续按桌面/Linux 清单验收 |
| 合同与其他平台 | `backend-2.0.json`、事件 reducer、JNI 合同、构建入口 | 保留 Core/Host 边界;Android 不展示空的桌面快捷键分类;不改发布工作流 |
## 已落实的界面设计
- **主页**:下一步与状态刷新前置;未读取/读取失败/未配置分别处理;本地 ASR、云 ASR 和 Omni 依据已有真实状态呈现;服务卡有配置/管理入口。活动热力图置于近期结果之后,底部卡片自然延展而不被短窗口压扁。
- **设置**:录音与输入、快捷键与选区、AI 服务与模型、外观与语言、权限与数据、实验与扩展、关于与更新。稳定的旧 section ID 保留;分类搜索支持当前语言的用途和关键词,不搜索或暴露凭据值。
- **AI 分区**:语言模型、语音识别、本地模型、连接与扩展各自呈现,页签随滚动保留;Omni 模式使用对应入口,避免传统模型页变空白。设置实底,不透出背后的页面文字。
- **操作细节**:搜索不卸载当前分类草稿;选分类回到页首;Escape 先清空查询、再关闭;打开时移动焦点、关闭时返回入口;已有下拉和快捷键录制优先处理自己的按键。
- **页面排布**:设置关键解释直接可读;共享页头不再被内容压缩,操作按钮可换行;历史窗口小于1000像素时用列表/详情切换,缩窗保留正在查看的重润色结果;翻译和划词追问补上已有用途说明。
- **入口修复**:空本地模型目录在布局阶段反复写入相同选择值,浏览器可复现最大更新深度错误。增加变化判断后,同一服务入口恢复正常。下拉选项补可访问关联,快捷键小菜单优先消费 Escape。
- **渠道与表单**:列表改用统一分隔行,显示名称、模型、当前使用/停用、验证结果与时间;添加放在分区头部,验证/启停/编辑操作有标签。表单按服务连接、模型设置、连接验证组织,名称作为可选信息,删除仍需确认;顶部关闭、Tab循环、子菜单Escape与焦点返回已补齐。
- **模型与下载**:管理页减少嵌套卡片,已下载/下载中模型与所选详情各有清楚位置;空态提供下载与重新读取。下载目录有模型大小、引擎、状态、仓库和简介;仅多引擎时显示筛选,窄屏按目录/详情切换,底部操作独立滚动。保留Windows原引擎操作与取消/激活合同。
- **Linux**:实际界面行为统一记录在 [04 页面与领域操作](linux-egui-handoff/04-ui-domains.md),剩余事项统一记录在 [02 缺口登记](linux-egui-handoff/02-gap-register.md)。
## 下一轮建议与优先级
| 优先级 | 建议 | 依据与验收 |
| --- | --- | --- |
| P1 | 将单项配置检查连接成首次听写向导 | 渠道列表现已区分当前使用与上次验证;下一步串联麦克风、服务和目标输入检查,让新用户完成一条真实听写 |
| P1 | 优先跑首装与升级后的第一条听写闭环 | 2.0 改动大量 Host/Core 边界。分别验证权限拒绝、设备断开、快捷键冲突、取消与最终落字,保留清楚的恢复入口 |
| P1 | Linux 先完成输入环境和后台工作可达,再补领域功能 | L01–L03 涉及热键、上下文、录音,直接影响后续历史/纠错。真机覆盖 X11/Wayland 和常用目标应用;不以页面数替代功能闭环 |
| P2 | 将主快捷键的编辑统一到一个明确目的地 | 当前录音页和快捷键页都有入口,属于既有行为;下一轮可保留录音页摘要并跳到快捷键编辑,需同步首次引导与迁移提示 |
| P2 | 本地/云选择围绕用户需求表达 | 用离线、速度、语言和设备需求组织模型选择;模型实际可用、下载中、加载中、失败分别反馈,不默认“已下载”等于可识别 |
| P2 | Linux 跟进模型、历史、词汇/风格/市场完整操作 | 具体范围已在 L04–L09;已有 Core API 可复用,优先交付有完整成功/失败/取消路径的一个领域 |
| P2 | 页面能力来自 Host,并给不支持项明确原因 | Linux 能力快照中的 overlay/AppImage flag 不代表 UI/更新器已接好;后续能力合同可携带不可用原因,避免两端界面各自猜测 |
| P3 | 按领域拆解持续增长的门面和原生 UI 文件 | `Core api.rs` 与 Linux `main.rs` 较大,增加定位和回归成本;在领域功能实际扩展时拆分,避免无业务目的的全面重写 |
## 浏览器预览目录
普通浏览器此前的 `listProviderDescriptors` 返回空数组,2.0 渠道编辑只能停留在加载态。现在浏览器分支懒加载 Core 生成的公开目录;原生分支仍执行同一个受启动合同保护的 IPC。该文件只有公开服务元数据,没有用户凭据或认证状态,Core 继续是规则的唯一来源。
生成文件:`openless-all/app/src/lib/ipc/provider-descriptors.generated.json`(generated,不手工维护)。在应用目录执行以下命令重新生成:
```sh
cargo run --locked -p openless-core --example export_provider_descriptors
```
生成器只调用 `openless_core::provider_rules::provider_descriptors`,没有启动应用、打开密钥环或请求外部服务。对应浏览器入口测试覆盖 ASR、LLM、Omni 目录可加载及协议字段完整性。
## 验证结果(2026-09-07 收口)
| 验证 | 本次结果与范围 |
| --- | --- |
| `npm test` | 构建与72个前端/合同测试模块通过,包含实际Core快捷键回归;最后运行退出码0 |
| 既有渠道/提供商/本地模型/轮询/布局合同 | 通过;渠道重排后恢复保守布局的纵向操作区,没有通过改测试掩盖回归 |
| 五语言 | 导航与搜索路径、服务分区、渠道和本地模型文案验证通过;中英切换经浏览器检查 |
| Core目录导出 | 直接编译运行生成器通过;生成文件供预览按需加载;浏览器目录测试通过 |
| 浏览器桌面 | 主页直达、分类搜索与空结果、服务页签、完整LLM与ASR表单、字段排布、菜单Escape、Tab循环与返回焦点通过 |
| 浏览器窄屏 | 390×844下完整LLM表单和模型目录/详情;选择1.7B、返回目录保留选择、关闭返回下载入口通过。900×620历史无横向溢出;1280缩至990时重润色结果保留 |
| Linux Host | 50个单元测试与4个Host合同测试通过;本次实际运行在macOS |
| egui界面 | 临时macOS验证副本9项通过;追加Omni有效模式2项定向测试覆盖16种配置组合通过,先复现失败再修复 |
| 差异与格式 | 最终差异空白检查通过;新增Core导出示例和Linux改动的定向格式检查通过 |
Linux目标交叉检查缺少对应`core/std`,Docker服务未运行,因此没有Linux原生编译通过证据。真机输入、音频、密钥环、窗口、真实模型下载/取消/推理与安装升级按[Linux验收](linux-egui-handoff/07-acceptance.md)及[桌面验收](2.0-desktop-acceptance.md)继续完成。浏览器使用预览数据,UI通过不代表真实服务、设备或分发验收通过。
本次确定的页面回归(历史缩窗状态、空目录布局更新、菜单与弹窗焦点、Omni状态)已修复;没有留下已知未处理的本轮问题。完整原生验收属于上述后续清单。代码保留在 `feat/core-2-ux-linux` 工作区,未提交或推送。
+49
View File
@@ -0,0 +1,49 @@
# Core 2.0 架构
状态:canonical;阶段:implementation-backed;更新:2026-09-07;源码基线:`fc38edd1`,应用版本 `2.0.0-Beta.1`,公开合同 `2.0.0`。
## 分层与入口
```mermaid
flowchart LR
UI[React 桌面界面] --> IPC[typed IPC / 启动合同]
IPC --> Tauri[Tauri Host]
Egui[Linux egui 界面] --> Linux[Linux Host facade]
Tauri --> Core[openless-core 共享业务]
Linux --> Core
Core --> Ports[Host interfaces]
Ports --> Native[录音 / 系统凭据 / 热键 / 目标输入 / 窗口]
Core --> Events[带 sequence 与 session 的事件]
Events --> Tauri
Events --> Linux
```
应用工作目录为 `openless-all/app`。
| 层 | 实现位置 | 职责 |
| --- | --- | --- |
| React | `src/App.tsx`、`src/components/FloatingShell.tsx`、`src/pages/` | 启动显示、页面、草稿、操作与结果反馈 |
| IPC | `src/lib/ipc/shared.ts`、`src/lib/ipc/` | `2.0.0` 启动检查,typed invoke,业务调用前等待 Core 可用 |
| 共享 Core | `crates/openless-core/src/lib.rs`、`api.rs`、`domains.rs` | 公开门面、领域服务、状态机、配置、迁移与会话规则 |
| 原生接口 | `crates/openless-core/src/ports.rs` | 录音、插入、资源、目标上下文、窗口等 Host effect 合同 |
| Tauri Host | `src-tauri/src/commands/`、`core_adapters.rs`、`tauri_coordinator_host.rs`、`tauri_events.rs` | 连接现有 Windows/macOS 原生实现并桥接 React |
| Linux Host/UI | `linux-egui/src/{backend,lib,main}.rs` | 共享 Core 接入、现有 Linux Adapter、egui 页面与事件显示 |
| 合同 | `contract/backend-2.0.json`、`crates/openless-core/tests/`、前端 IPC 测试 | 版本、DTO、事件和领域行为回归 |
## 听写与状态
UI/热键发起 → Core 获取会话及真实目标 → Host 录音与识别 → Core 转写/润色 → Host 向原目标输入 → 真实结果、历史与事件反馈。停止录音、取消会话和正常结束分别调用已有门面,页面导航不接管录音资源。
主窗口与胶囊、问答、选区预览、Agent 等 WebView 共用启动合同检查。布局改进只改变启动说明,不跳过检查。`recordingReady` 由首帧 PCM 证据驱动,不能用定时器伪造就绪。
事件以 `sequence` 去重,以 `sessionId` 归属;Unicode delta 的 offset 是 scalar 数量。QA 全量 messages 校准与 Agent 多轮重放仍遵循各自规则。具体语义的规范来源是 [Linux 事件与会话合同](linux-egui-handoff/06-events-and-sessions.md)及对应代码。
凭据状态、provider descriptor 与有效 pipeline 由 Core 提供。UI 不在渠道 ID 上猜认证规则,不回显已有密钥,不把“已配置”当成设备或网络测试成功。普通写入、已发送粘贴、已复制回退和未知结果分别反馈。
## 平台边界
当前完整支持范围、Linux 交接要求与发布验收统一指向 [2.0 当前范围](2.0-requirements.md)。Linux 有运行与界面起点,但 [缺口登记](linux-egui-handoff/02-gap-register.md)仍存在。React 的 Linux 外观预览不能替代 egui 产品验收。
## 验证入口
`npm test` 会先构建 React,再跑前端与源码合同;其中 `check-hotkey-injection.mjs` 还会运行 Core 的实际快捷键测试。Core 全量验证使用 `cargo test -p openless-core --locked`;Linux 使用 `cargo test -p openless-linux-egui --locked`,原生目标验证遵循 [Linux 验收](linux-egui-handoff/07-acceptance.md)。本次实际执行结果在 [体验梳理](2.0-ux-review.md),不要将列出的命令视作已经执行。
+13
View File
@@ -0,0 +1,13 @@
# OpenLess 文档入口
状态:canonical;阶段:implementation-backed;更新:2026-09-07。
- [Core 2.0 当前范围](2.0-requirements.md):Windows/macOS 交付边界与 Linux 移交责任,范围的规范来源。
- [2.0 架构](architecture.md):共享 Core、平台 Host、界面与事件如何连接。
- [2.0 体验梳理与本次改进](2.0-ux-review.md):全部迁移领域的索引、页面变化、建议及验证记录。
- [本次执行计划](plans/active/2026-09-06-core-2-ux.md):基线、范围、进度与回滚。
- [桌面验收清单](2.0-desktop-acceptance.md):逐项功能保留和原生验收要求。
- [Linux 接入交接](linux-egui-handoff/README.md):Linux 已有能力与剩余工作;从对应小文档继续阅读。
- [Linux 引导的官方资料](knowledge/2026-09-06-linux-setup.md):来源、项目版本、有效期。
历史追溯按需阅读:[平台边界审计](core-platform-boundary-audit.md)、[#1019 复核](pr1019-2.0-final-review.md)、[桌面复审记录](pr1019-desktop-review-2026-09-06.md)。这些是带日期的实现与测试记录,不替代当前范围或新版本的设备验收。
+11
View File
@@ -0,0 +1,11 @@
# Linux 环境引导:官方资料
状态:canonical;阶段:implementation-backed;来源类型:外部知识。
获取日期:2026-09-06;新鲜度:normal;复核期限:2026-09-16;置信度:高(官方文档/源码),具体桌面可用性仍依设备验证。项目使用egui/eframe 0.31.1;以下Fcitx 5与Secret Service资料是当日上游页面,不声明为本机安装版本。
- [Fcitx 5设置](https://fcitx-im.org/wiki/Setup_Fcitx_5):桌面启动、输入法框架与会话配置。
- [Fcitx 5 on Wayland](https://fcitx-im.org/wiki/Using_Fcitx_5_on_Wayland):配置依桌面与GTK/Qt等工具包而异。
- [fcitx5-remote官方源码](https://github.com/fcitx/fcitx5/blob/master/src/tools/remote.cpp):`-r`调用`ReloadConfig`,不是插件安装/进程重启。
- [Secret Service官方规范](https://specifications.freedesktop.org/secret-service/latest/):会话、集合锁定及解锁语义;不能从已有渠道配置推断当前vault可访问。
+10 -2
View File
@@ -1,6 +1,8 @@
# 02:Linux 缺口登记与实施顺序
基线:`2b315572`;责任依据:[当前范围](../2.0-requirements.md)。以下是源码盘点,不是通过声明。
状态:draft;阶段:partial-implementation;更新:2026-09-07。
初始盘点基线:`2b315572`;本次UI整理基线:`fc38edd1`,更新反映当前工作区。责任依据:[当前范围](../2.0-requirements.md)。以下是源码盘点,不是通过声明。
## 1. 状态定义
@@ -21,7 +23,7 @@
| L06 | 界面:Marketplace service 已接入,市场页面缺失 | 浏览、安装、上传/下载、点赞/作品管理、设备 OAuth 登录/取消/退出;复用 Core 协议。[领域](./04-ui-domains.md) |
| L07 | 界面+L03依赖:历史仅只读最近20条,缺完整历史/统计/录音操作 | 浏览和原有历史操作、重润色/重转写、录音播放/导出/清理与统计;真实归因不由 UI 拼装。[领域](./04-ui-domains.md) |
| L08 | 界面:本地 Qwen 有下载/激活/取消,缺完整模型管理和运行时控制 | 路径/镜像、详情/状态、删除、预载/释放和准备/测试取消;真实推理另附证据。[领域](./04-ui-domains.md) |
| L09 | 界面:多数 Linux 适用设置、Agent 检测/模型/路径/权限配置缺失 | 设置有实际消费者、revision/错误处理;保留现有 Less Computer 输出/审批/取消流程。[领域](./04-ui-domains.md) |
| L09 | 界面部分改善:已增加领域导航、开始/环境引导、独立现有设置和手机输入页、跨页审批/取消;多数 Linux 适用设置、Agent 检测/模型/路径/权限配置仍缺失 | 设置有实际消费者、revision/错误处理;保留现有 Less Computer 输出/审批/取消流程。本次未关闭L09。[领域](./04-ui-domains.md) |
| L10 | 接线+界面:无托盘/自启;通知仅状态栏,重启仅提示,AppImage能力判定不等于更新器 | 窗口/后台运行、通知、自启、检查/下载/安装更新与重启有真实 Host 效果。[原生](./05-native-host-and-data.md) |
| L11 | 已实现待实测:fcitx5 输入/选区、CPAL、Secret Service、Qwen、CLI 进程、Remote TLS/H5 | 通过真实桌面/设备矩阵;未通过的逐项记录,不把整模块称为缺失。[验收](./07-acceptance.md) |
| L12 | 已有包构建,待 Linux 产品验收与正式分发 | 完成上述应用缺口及 Linux 安装/升级/回滚、签名和更新证据;不阻塞 Windows/macOS 首批交付。[验收](./07-acceptance.md) |
@@ -43,3 +45,9 @@
任务记录格式:`ID / owner / commit / 已完成效果 / 自动证据 / 设备证据 / 剩余限制`。
“待实测”不是“未实现”,也不是“已完成”;新发现必须按层归属,不能仅因 Linux 故障就判定 Core 缺失。
## 5. 本轮 UI 进展(2026-09-06)
`L04/L07/L08/L09/L11 / Linux UI / 共享工作区未提交`:现有领域从单页拆为导航分页;开始页与环境准备解释fcitx/plugin、桌面会话、麦克风与Secret Service的实际可知状态。原生未连接时保留诊断步骤,未展示成功。Agent审批固定在页面外,QA/Selection/Agent后台事件保留操作或导航提示,未改变Core接口或扩充原生适配。
本轮只改善入口与显示,不关闭Selection Voice、完整历史/模型管理、Agent高级配置、托盘/更新器及Linux设备/分发缺口。实现、官方来源、定向命令和实际自动证据统一记录在[04本轮记录](./04-ui-domains.md#5-2026-09-06-导航与-linux-准备引导),不在此重复维护。
+66 -2
View File
@@ -1,5 +1,7 @@
# 04:页面与领域操作
状态:draft;阶段:partial-implementation;更新:2026-09-07。页面实现与自动证据不等于 Linux 原生验收通过。
对应 L04–L09。现有[main.rs](../../openless-all/app/linux-egui/src/main.rs)是可复用的真实 UI 起点,但并非功能齐全的产品。
接口查[Core api.rs](../../openless-all/app/crates/openless-core/src/api.rs)与[domains.rs](../../openless-all/app/crates/openless-core/src/domains.rs)。
@@ -17,8 +19,8 @@
| 词典/纠错 | 无页面 | 接词条增删/启停、预设、规则;pending corrections接受/拒绝/清空。自动建议来源依赖L02 |
| Marketplace | service和下载归档Host已有,无页面 | 接 `MarketplaceApi` 的列表/详情/安装/下载/上传、点赞/作品、设备OAuth启动/轮询/取消/退出 |
| 历史/统计 | 仅最近20条只读和插入状态 | 完整浏览及历史操作、删除/清空、重润色/重转写、复制/导出、录音播放/定位/清理、活动统计;音频先补L03 |
| 设置 | 仅streaming_insert、Agent启用、Remote开关/端口 | 补Linux适用的麦克风/静音、语言/翻译、模式/热键、QA历史、选区、隐私/日志和外观;每项必须有真实消费者 |
| Remote Input | 开关、地址、PIN与重置已有,TLS/H5已接 | 补连接状态、陈旧地址/错误、二维码及LAN/证书信任说明;复用服务并进行真手机验收 |
| 设置 | 环境准备与现有streaming_insert、Agent启用;Remote开关/端口独立到手机输入页 | 补Linux适用的麦克风/静音、语言/翻译、模式/热键、QA历史、选区、隐私/日志和外观;每项必须有真实消费者 |
| Remote Input | 独立页面保留开关、地址、PIN与重置,增加服务状态/连接数/陈旧地址提示及LAN/证书说明,TLS/H5已接 | 继续补二维码、完整连接错误恢复与证书信任体验;复用服务并进行真手机验收 |
不要给 Linux 暴露无实现的 Windows TSF 或 macOS AX 权限按钮。当前 Linux 本地运行时为 Generic Qwen;Foundry/Apple MLX 不是这一轮要求跨平台移植的功能。
@@ -46,3 +48,65 @@ QA 内的预览必须传当前 turn token,使用 `QaApi::begin_edit_preview_ap
每个领域交付“用户入口 → Core调用 → Host效果 → 事件/持久化”的实际流程;验证成功、失败、取消、重新打开和重启。
领域UI完成不自动关闭相关原生缺口;L02/L03/L01分别验收上下文、音频归档和全局热键。
## 5. 2026-09-06 导航与 Linux 准备引导
页面复用现有 Core 与 Linux Host 接口。
| 入口 | 当前行为 |
| --- | --- |
| 开始 | 环境准备 → AI 服务或本地模型 → 听写;按 Core 有效模式分别显示 ASR/LLM 或 Omni 配置,已配置与校验通过分开;连接后可直达问答、选区润色、Agent、手机输入、历史 |
| 工作页面 | 听写、问答、选区润色、Less Computer 分页;每页独立滚动,草稿与会话保存在同一个 App 状态中 |
| 准备与管理 | AI 服务保留原渠道操作;本地模型保留列表/下载/激活/取消;历史保留最近20条只读与插入结果分类 |
| 环境与设置 | 只提供已有消费者的流式插入与Agent启用,保留revision保存;手机输入配置共用原设置草稿,保存/重新读取明确说明影响 |
| 手机输入 | 配置、保存、状态刷新、连接数、配对码重置;停止、地址陈旧时隐藏PIN和URL,状态读取失败清除旧显示 |
宽窗口使用左侧导航;小于760逻辑像素时改用可换行的顶部导航。主循环仍先无条件`poll`再渲染当前页面,保留50ms重绘、sequence去重、session归属、lag重放、退出shutdown与Esc语音取消。
- QA/Selection/Agent与听写/模型/Remote后台事件给对应导航增加提醒,访问该页面只清除该页未读状态,不取消后台工作。
- `ShowQa`、`ShowSelectionPreview`、`ShowLessComputer`打开所属页面;普通`ShowMain`保留当前页面。隐藏窗口/面板的既有Host语义保留。
- Agent审批固定在页面滚动区外:显示原command与原token对应的允许/拒绝;所有页面可直接取消Agent。取消请求与审批结果仍走原Core facade。
- QA有打开、原文本/语音操作、关闭及显式取消;全局任务栏保留问答取消、选区预览取消和当前语音取消。选区确认/撤回仍核验原session。
- Agent `VoiceState`仅增加导航提示,不把录音session当聊天session,不伪造首帧或电平。完整typed语音反馈与投影恢复仍按[06](./06-events-and-sessions.md)继续接入。
### 环境状态的证据边界
启动失败仍能查看开始页与环境引导。现有插件安装检查的Ready/Updated/Missing/错误结果被保留;Updated/Missing仍阻止启动原生运行时。未连接Core的领域页面展示准备入口,不开放假可用操作。
- 桌面类型来自`LinuxCapabilitySnapshot`的环境判断,不是端到端桌面可用性测试。
- fcitx探测是现有D-Bus Peer Ping,只能说明探测有响应;插件文件存在不能证明加载,加载不能证明任意目标应用落字。
- “重新检测会话与D-Bus”在后台执行,只更新环境探测;不会重新安装插件或启动Core。本次启动的插件检查结果保留,修复后需重启OpenLess。
- 麦克风显示未验证录音/当前环境不支持,不将Unknown当授权成功。Secret Service没有独立连接/解锁探测,UI明确说明未知,并引导用户解锁桌面密钥环后通过原渠道保存/验证获得真实结果。
- 不因X11 overlay或AppImage auto-update能力flag显示假浮层、托盘或更新器,也不新增Windows/macOS专属设置。
可复制的`fcitx5-diagnose`用于诊断;`fcitx5-remote -r`仅重载配置,不能称为重新安装或重启插件。新插件仍未加载时,指引重新登录桌面并重启OpenLess。Wayland指向桌面/工具包对应官方指导,不给所有桌面统一写环境变量。
### 定向验证与剩余实测
导航状态位于[ui_state.rs](../../openless-all/app/linux-egui/src/ui_state.rs),可在非Linux平台测试。主文件定向测试覆盖后台审批跨页可见、迟到终态隔离、QA/Selection草稿与取消提示、启动失败引导、陈旧Remote地址/PIN隐藏,并保留原两轮QA/Agent回归。
在`openless-all/app`执行:
```sh
rustfmt --edition 2021 --check --config skip_children=true linux-egui/src/main.rs linux-egui/src/ui_state.rs
cargo test -p openless-linux-egui --locked
```
完整主文件的`linux_app::tests`由Linux cfg控制;macOS直接运行上面的Cargo测试不会编译egui主界面。Linux机器还须执行:
```sh
cargo check -p openless-linux-egui --all-targets --locked
cargo test -p openless-linux-egui --bin openless-linux-egui --locked
```
原生依赖沿用[07验收](./07-acceptance.md)与现有CI。真机仍须覆盖420×400短窄窗口与宽窗口、中文字体、后台QA/Selection/Agent触发、跨页审批/取消、插件首次安装及重载、X11/Wayland目标应用、麦克风、密钥环、真实Qwen/CLI与手机TLS。自动布局绘制不是实际窗口/设备验收。
本次实际自动证据(macOS,Homebrew rustc 1.97.1):
- `cargo test -p openless-linux-egui --locked`:50个Host单元测试、4个Host合同测试及首轮2个导航测试通过;设备测试在此平台为0个,不能作设备证据。
- 为检查Linux cfg内的主界面,在独立临时crate复制本次`main.rs`/`ui_state.rs`,仅临时启用其UI编译,使用仓库已有eframe 0.31.1和同一Core/Linux Host路径依赖;`cargo test --offline`共9个测试通过(含最新3个导航测试、原2个事件回归和新增4个场景绘制测试)。未运行生产启动函数,未改变仓库Cargo清单或锁文件。该证据仅覆盖macOS上的UI类型、状态与无窗口绘制,Host仍使用macOS cfg。
- 尝试`cargo check -p openless-linux-egui --all-targets --target x86_64-unknown-linux-gnu --locked`,因目标`core/std`缺失(E0463)失败;本机Docker服务未运行。**没有Linux目标编译通过证据**,须在配置好原生依赖的Linux环境执行上方两条命令。
### 官方资料
来源、版本、获取日期和有效期统一记录在[Linux 环境引导知识记录](../knowledge/2026-09-06-linux-setup.md)。
+67
View File
@@ -0,0 +1,67 @@
# Core 2.0 入口、设置与 Linux 界面整理
状态:historical;阶段:implementation-backed;更新:2026-09-07;任务状态:complete。
## 意图基线
用户要求用云端最新已合并主线替换本地内容,删除未提交修改;梳理 2.0 并提出改进建议,落实更直觉的入口、设置与页面布局,为 Linux 增加引导和设置准备。
- 应用仓库:`1-app`,远端 `Open-Less/openless`。官网、市场后台、训练与审计项目独立,不在本次修改范围。
- 2026-09-06 实时 fetch:默认主线 `beta`,Core 2.0 在 #1019 合并,基线 `fc38edd1a3da3a4f2fa56f433be84278c3456ed1`;稳定 `main` 是 `08d70601`(1.3.18)。代码版本 `2.0.0-Beta.1`。
- 已按授权 reset/clean 应用工作区,并切到 `feat/core-2-ux-linux`。
## 目标、非目标与约束
主页提供可操作的开始入口;设置按用户任务拆分,保留保存、平台能力和原有功能;Linux 在现有 egui 页面中增加导航和环境准备引导。梳理整个 2.0 迁移面,以界面改动为实施重点。
不重写共享业务、不扩展公开 IPC、不变更发布版本、不实现完整 Linux 原生适配、不操作其他项目或生产配置。使用现有 React/TypeScript/i18next、Tauri 2、Rust Core 与 egui/eframe 0.31;不增加依赖。Linux 已有功能、待接线功能、设备待验收分别表达。
## Read Set
- 本仓库唯一 `AGENTS.md`(本地入口)及 `docs/2.0-requirements.md`、`2.0-desktop-acceptance.md`、`core-platform-boundary-audit.md`。
- `docs/linux-egui-handoff/README.md`、`02-gap-register.md`、`04-ui-domains.md`;其他交接小文档按需要读取。
- `openless-all/app/src/components/{FloatingShell,SettingsModal,Onboarding}.tsx`、`src/pages/Overview.tsx`、`src/pages/settings/`、平台与 IPC 门面。
- `openless-all/app/crates/openless-core/src/{lib,api,ports,events}.rs` 的公开结构;合同与测试;不逐行递归阅读全部旧迁移记录。
- `openless-all/app/linux-egui/src/{main,capabilities,settings}.rs`、已有 Host 接线;包清单与测试入口。
## 方案与影响范围
1. React 主页将下一步和真实配置状态前置,服务卡片可直接进入设置;短窗口保留自然滚动。
2. 设置保留稳定 ID general/services/privacy/advanced/about,增加 shortcuts、appearance,将长通用页按任务拆分。添加说明、可搜索的分类入口、键盘关闭和焦点返回;正确挂载/卸载原有区块。
3. Linux 复用已有功能,按开始/听写/AI 服务/模型/工具/手机输入/历史/设置组织页面;环境指导使用现有能力信息,提供实际可执行的准备步骤,保留取消、审批和后台事件消费。
4. 补文档入口与 2.0 架构/体验梳理,记录建议、落实项、Linux 剩余工作和验证范围。
## 里程碑
- [x] 实时核实并同步云端基线,清理本地改动。
- [x] 核对 2.0 范围、平台边界、现有页面与验证方式。
- [x] 桌面主页入口和概览布局改进。
- [x] 设置分类、查找、布局及可访问性改进。
- [x] Linux 原生导航与环境引导。
- [x] 补齐中英及现有语言文案,文档与代码同步。
- [x] 类型/构建、前端测试、可行 Rust 验证、浏览器桌面/窄屏检查、最终 diff 审阅。
## 用户追加的视觉改进(同一任务)
用户在预览后指出 LLM 设置元素不好看、排布不清楚,模型设置和下载也有同样问题。这是本次前端目标的继续细化。
- [x] 设置改为完全实底,消除透底文字干扰;AI 服务按语言模型/语音识别/本地模型/连接分类,Omni 模式保留真实配置入口。
- [x] 渠道列表明确名称、模型、使用状态和验证结果,整理添加/编辑/启停操作;编辑表单分组。
- [x] 本地模型管理与下载目录减少嵌套卡片,完善空态、主操作和窄屏排布。
- [x] 浏览器检查 LLM、编辑页、模型管理与下载页,并对最终代码重新构建/测试。
第一轮已通过前端构建及71个测试模块;990像素进入服务页时的空目录布局更新循环已复现并修复;历史重润色结果从宽窗缩至990像素已验证保留。Linux Host50+合同4、macOS临时UI9及追加Omni2项测试通过;原生Linux目标编译仍缺环境,不作通过声明。最终结果以追加视觉改进后的收口记录为准。
## 验收与回滚
主页服务入口能打开对应设置;录音/快捷键/外观/服务/隐私/高级/关于均可达;移动端没有丢失录音或语言设置;查找与空结果可恢复;设置支持关闭和焦点返回。Linux 每个旧操作保留真实调用,导航不停止事件消费或丢弃会话。
使用 `npm test`(含构建)、必要的定向行为测试;Linux 源文件格式检查、Host/Core 测试及能执行的目标检查;浏览器检查常规桌面、短窗口与窄屏。当前主机是 macOS,Linux 原生桌面效果须明确留给 Linux 设备验收,不能以 macOS 上 cfg 排除的构建冒充。
改动均保持在此分支,按文件撤销可回到 `fc38edd1`;已删除的用户未提交修改不作可恢复承诺。风险集中在设置入口迁移、短窗口溢出、平台 gating 和 Linux 隐藏页面的会话状态。
## 最终收口
追加视觉改进已实现并完成浏览器检查。最终 `npm test` 构建及72个测试模块通过;Core公开目录生成器编译运行通过。渠道编辑焦点、供应商下拉可访问性、快捷键菜单Escape、保守布局、历史缩窗结果保留与空模型目录白屏均已关闭。预览目录由Core生成,原生IPC合同不变。
验证详情、设备边界与下一轮建议统一见[体验梳理](../../2.0-ux-review.md#验证结果2026-09-07-收口)。原生Linux编译/真机与真实服务验收仍在已有交接清单内;本任务没有宣称完成这些产品发布门。
@@ -0,0 +1,24 @@
//! Regenerate the public provider metadata used by the browser-only UI preview.
//! Run from the app directory: cargo run --locked -p openless-core --example export_provider_descriptors
use openless_core::{domains::ProviderKind, provider_rules::provider_descriptors};
use std::{fs, path::Path};
fn main() -> anyhow::Result<()> {
let catalog = serde_json::json!({
"_generatedFrom": "openless-core::provider_rules::provider_descriptors",
"asr": provider_descriptors(ProviderKind::Asr),
"llm": provider_descriptors(ProviderKind::Llm),
"omni": provider_descriptors(ProviderKind::Omni),
});
let destination = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../src/lib/ipc/provider-descriptors.generated.json");
let mut bytes = serde_json::to_vec_pretty(&catalog)?;
bytes.push(b'\n');
fs::write(&destination, bytes)?;
println!(
"Provider preview catalog exported to {}",
destination.display()
);
Ok(())
}
File diff suppressed because it is too large. Load diff
+119
View File
@@ -0,0 +1,119 @@
//! Presentation state only: changing pages never owns or cancels a Core session.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub(super) enum Page {
#[default]
Start,
Dictation,
Qa,
Selection,
Agent,
Services,
Models,
Remote,
History,
Settings,
}
impl Page {
pub const ALL: [Self; 10] = [
Self::Start,
Self::Dictation,
Self::Qa,
Self::Selection,
Self::Agent,
Self::Services,
Self::Models,
Self::Remote,
Self::History,
Self::Settings,
];
pub fn label(self) -> &'static str {
match self {
Self::Start => "开始",
Self::Dictation => "听写",
Self::Qa => "问答",
Self::Selection => "选区润色",
Self::Agent => "Less Computer",
Self::Services => "AI 服务",
Self::Models => "本地模型",
Self::Remote => "手机输入",
Self::History => "历史",
Self::Settings => "环境与设置",
}
}
}
#[derive(Default)]
pub(super) struct Navigation {
pub page: Page,
unread: [bool; Page::ALL.len()],
}
impl Navigation {
pub fn open(&mut self, page: Page) {
self.page = page;
self.unread[page as usize] = false;
}
pub fn notify(&mut self, page: Page) {
if self.page != page {
self.unread[page as usize] = true;
}
}
pub fn has_update(&self, page: Page) -> bool {
self.unread[page as usize]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn background_work_keeps_its_notice_until_its_own_page_is_opened() {
let mut navigation = Navigation::default();
navigation.notify(Page::Qa);
navigation.notify(Page::Selection);
navigation.notify(Page::Agent);
navigation.open(Page::Settings);
for page in [Page::Qa, Page::Selection, Page::Agent] {
assert!(navigation.has_update(page));
}
navigation.open(Page::Qa);
assert!(!navigation.has_update(Page::Qa));
assert!(navigation.has_update(Page::Selection));
assert!(navigation.has_update(Page::Agent));
}
#[test]
fn reading_a_page_does_not_create_an_unread_notice() {
let mut navigation = Navigation::default();
navigation.open(Page::Agent);
navigation.notify(Page::Agent);
assert!(!navigation.has_update(Page::Agent));
navigation.open(Page::Start);
navigation.notify(Page::Agent);
assert!(navigation.has_update(Page::Agent));
assert_eq!(navigation.page, Page::Start);
}
#[test]
fn every_destination_can_be_opened_without_clearing_other_destinations() {
let mut navigation = Navigation::default();
for page in Page::ALL {
assert!(!page.label().is_empty());
navigation.notify(page);
}
for (index, page) in Page::ALL.into_iter().enumerate() {
navigation.open(page);
assert_eq!(navigation.page, page);
assert!(!navigation.has_update(page));
for remaining in &Page::ALL[index + 1..] {
assert!(navigation.has_update(*remaining));
}
}
}
}
+5 -8
View File
@@ -1,5 +1,6 @@
import { lazy, Suspense, useEffect, useState } from 'react';
import { Capsule } from './components/Capsule';
import { CoreStartupScreen } from './components/CoreStartupScreen';
import { GlobalDownloadProgress } from './components/GlobalDownloadProgress';
import { detectOS, type OS } from './components/WindowChrome';
import {
@@ -86,10 +87,10 @@ export function App(props: AppProps) {
}, []);
if (error) {
return <div role="alert">OpenLess Core 无法启动或版本不兼容。 {error}</div>;
return <CoreStartupScreen error={error} compact={props.isCapsule || props.isLessComputerGlow} />;
}
if (!ready) {
return <div role="status">正在检查 OpenLess Core 兼容性…</div>;
return <CoreStartupScreen compact={props.isCapsule || props.isLessComputerGlow} />;
}
return <ReadyApp {...props} />;
}
@@ -342,14 +343,10 @@ function ReadyApp({ isCapsule, isQa, isSelectionPolishPreview, isSelectionVoiceI
}, [os]);
if (gate === 'checking') {
return <div role="status">正在检查 OpenLess Core 兼容性…</div>;
return <CoreStartupScreen />;
}
if (gate === 'incompatible') {
return (
<div role="alert">
OpenLess Core 无法启动或版本不兼容。{startupError ? ` ${startupError}` : ''}
</div>
);
return <CoreStartupScreen error={startupError ?? 'Core startup failed'} />;
}
return (
@@ -0,0 +1,25 @@
import { useTranslation } from 'react-i18next';
import { Icon } from './Icon';
/** All windows keep the same contract gate; only its presentation varies by available space. */
export function CoreStartupScreen({ error, compact = false }: { error?: string | null; compact?: boolean }) {
const { t } = useTranslation();
return <div style={{ minHeight: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: compact ? 8 : 24, background: 'var(--ol-surface)', color: 'var(--ol-ink)' }}>
<div style={{ width: '100%', maxWidth: 420 }}>
<div role={error ? 'alert' : 'status'} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: compact ? 12 : 18, fontWeight: 600 }}>
{!compact && <Icon name={error ? 'info' : 'refresh'} size={22} />}
{t(error ? 'startup.failed' : 'startup.loading')}
</div>
{!compact && <>
<p style={{ fontSize: 13, lineHeight: 1.65, color: 'var(--ol-ink-3)', margin: '12px 0 18px' }}>{t(error ? 'startup.recovery' : 'startup.loadingDesc')}</p>
{error && <>
<button type="button" onClick={() => window.location.reload()} style={{ background: 'var(--ol-primary-solid-bg)', color: 'var(--ol-primary-solid-ink)', border: 0, borderRadius: 8, padding: '10px 16px', font: 'inherit', fontSize: 13, cursor: 'pointer' }}>{t('startup.retry')}</button>
<details style={{ marginTop: 20, fontSize: 12, color: 'var(--ol-ink-3)' }}>
<summary style={{ cursor: 'pointer' }}>{t('startup.details')}</summary>
<pre style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', fontSize: 11, lineHeight: 1.6, marginTop: 10 }}>{error}</pre>
</details>
</>}
</>}
</div>
</div>;
}
@@ -4,7 +4,7 @@
//
// Ported verbatim from design_handoff_openless/variants.jsx::FloatingShell.
import { useEffect, useMemo, useState, type ComponentType, type CSSProperties } from 'react';
import { useEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from './Icon';
import { Tooltip } from './Tooltip';
@@ -19,7 +19,7 @@ import { Marketplace } from '../pages/Marketplace';
import { Translation } from '../pages/Translation';
import { SelectionAsk } from '../pages/SelectionAsk';
// 风格市场(Marketplace)现在是侧栏「风格」展开组下的独立页面(不再是 Style 页面内 modal)。
// LocalAsr 不再作为主 nav tab——本地 ASR 模型管理已合并到 Settings → Advanced 中
// LocalAsr 不再作为主 nav tab——本地 ASR 模型管理已合并到 Settings → Services 中
// 通过 <LocalAsr embedded /> 渲染。这里之前的 import 与 NAV_BASE 条目都已移除。
import { APP_VERSION_LABEL, IS_BETA_BUILD } from '../lib/appVersion';
import {
@@ -103,6 +103,12 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
const [hotkeyModePromptOpen, setHotkeyModePromptOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
const [styleOpen, setStyleOpen] = useState(false);
const shellRef = useRef<HTMLDivElement>(null);
// The dialog records and moves focus before its background becomes inert.
useEffect(() => {
if (shellRef.current) shellRef.current.inert = settingsOpen;
}, [settingsOpen]);
// tab 切换的 cross-fade:旧页 blur+fade out(180ms),结束后挂载新页(走 ol-page-slide enter)。
// displayTab 是实际渲染的 tab,currentTab 是用户点中的目标 tab。
@@ -171,7 +177,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
}, []);
// 之前监听的 NAVIGATE_LOCAL_ASR_EVENT 已无意义——「模型设置」独立 tab 已下线,
// 模型管理 UI 现在通过 Settings → Advanced 的 <LocalAsr embedded /> 渲染,
// 模型管理 UI 现在通过 Settings → Services 的 <LocalAsr embedded /> 渲染,
// 用户在 Settings 内即可一站式管理,无需跨页跳转。
const rememberProviderPrompt = () => {
@@ -191,17 +197,17 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
setStyleOpen(false);
};
// ⌘, 打开设置页面
// 跟随平台的设置快捷键。
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.metaKey && e.key === ',') {
if ((os === 'mac' ? e.metaKey : e.ctrlKey) && e.key === ',') {
e.preventDefault();
openSettings();
}
};
window.addEventListener('keydown', onKeyDown, true);
return () => window.removeEventListener('keydown', onKeyDown, true);
}, []);
}, [os]);
const openProviderSettings = () => {
rememberProviderPrompt();
@@ -227,15 +233,18 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
<div style={{ flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', minHeight: 0, paddingTop: 0, background: 'var(--ol-app-shell-bg)' }}>
{mobile && (
<div ref={element => { if (element) element.inert = settingsOpen; }} style={{ display: 'contents' }}>
<MobileTopBar
title={mobileTitle}
onOpenSettings={() => openSettings()}
settingsActive={settingsOpen}
/>
</div>
)}
{/* Main shell — flush with the frosted backplate (no separate float). */}
<div
ref={shellRef}
data-ol-settings-open={settingsOpen ? 'true' : undefined}
className="ol-app-shell-bg"
style={{
@@ -461,7 +470,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
}}
>
{displayTab === 'overview' ? (
<Overview onOpenHistory={() => setCurrentTab('history')} />
<Overview onOpenHistory={() => setCurrentTab('history')} onOpenSettings={openSettings} />
) : (
<div
className={conservative ? 'ol-conservative-scope' : undefined}
+146 -287
View File
@@ -1,31 +1,17 @@
// SettingsModal.tsx — 居中弹窗,左侧单层侧栏。
//
// 重构(2026-05):原本是「外层弹窗侧栏 + 设置页内层侧栏」双层嵌套,用户点
// 「设置」还要再面对第二个侧栏。现在拍平成单层 —— 通用 / 服务 / 隐私 / 高级 /
// 关于 五个 tab + 帮助外链组。每个 tab 的内容见 pages/settings/tabs.tsx,
// 分组原则:按「用户带着什么问题来」归类 —— 怎么录(通用)/ 识别润色由谁提供
// (服务,含本地模型)/ 数据去哪了(隐私)/ 实验功能(高级)/ 版本与更新(关于)。
//
// 设计原则:每个可见控件都必须可用。
import { useLayoutEffect, useRef, useState, type CSSProperties } from 'react';
// Settings keeps one navigation level; every category reuses the existing settings consumers.
import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from './Icon';
import { SavedToast } from './SavedToast';
import { useSavedToastListener } from '../lib/savedEvent';
import { openExternal } from '../lib/ipc';
import { getPlatformCapabilities, getCachedPlatformCapabilities } from '../lib/platform';
import { useMobileLayout, useConservativeLayout } from '../lib/useMobileLayout';
import type { OS } from './WindowChrome';
import { AboutTab, GeneralTab, ServicesTab, PrivacyTab, AdvancedTab } from '../pages/settings/tabs';
import { chipSelectedStyle } from '../pages/settings/shared';
import { AboutTab, GeneralTab, ServicesTab, PrivacyTab, AdvancedTab, ShortcutsTab, AppearanceTab } from '../pages/settings/tabs';
import { searchSettingsSections, visibleSettingsSections, type SettingsSectionId } from '../pages/settings/navigation';
// 稳定 tab ID(与 i18n key `modal.sections.*` 一致)。
export type SettingsSectionId =
| 'general'
| 'services'
| 'privacy'
| 'advanced'
| 'about';
export type { SettingsSectionId } from '../pages/settings/navigation';
interface SettingsModalProps {
os: OS;
@@ -33,257 +19,161 @@ interface SettingsModalProps {
initialSettingsSection?: SettingsSectionId;
}
interface ModalNavItem {
id: string;
icon: string;
external?: boolean;
href?: string;
}
const HELP_URL = 'https://github.com/Open-Less/openless#readme';
const RELEASE_NOTES_URL = 'https://github.com/Open-Less/openless/releases';
// 第一组:可选中的 tab;第二组:外部链接(永远不 active)。
const TAB_ITEMS: ModalNavItem[] = [
{ id: 'general', icon: 'settings' },
{ id: 'services', icon: 'cloud' },
{ id: 'privacy', icon: 'shield' },
{ id: 'advanced', icon: 'bolt' },
{ id: 'about', icon: 'info' },
];
const LINK_ITEMS: ModalNavItem[] = [
{ id: 'helpCenter', icon: 'help', external: true, href: HELP_URL },
{ id: 'releaseNotes', icon: 'doc', external: true, href: RELEASE_NOTES_URL },
const LINKS = [
{ id: 'helpCenter', icon: 'help', href: 'https://github.com/Open-Less/openless#readme' },
{ id: 'releaseNotes', icon: 'doc', href: 'https://github.com/Open-Less/openless/releases' },
];
export function SettingsModal({ os: _os, onClose, initialSettingsSection }: SettingsModalProps) {
export function SettingsModal({ os, onClose, initialSettingsSection }: SettingsModalProps) {
const { t } = useTranslation();
const mobile = useMobileLayout();
const conservative = useConservativeLayout();
const [section, setSection] = useState<SettingsSectionId>(initialSettingsSection ?? 'general');
const [query, setQuery] = useState('');
const [platformCaps, setPlatformCaps] = useState(getCachedPlatformCapabilities);
const savedToast = useSavedToastListener();
const surfaceRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
const mountedRef = useRef(false);
const supportsShortcuts = platformCaps?.supportsDesktopHotkey ?? os !== 'android';
const sections = visibleSettingsSections(supportsShortcuts).map(item => ({
...item,
title: t(`modal.sections.${item.id}`),
description: t(`modal.descriptions.${item.id}`),
keywords: t(`modal.searchKeywords.${item.id}`),
}));
const searching = query.trim().length > 0;
const results = searchSettingsSections(sections, query);
// 与 sidebar nav 一致的滑动指示器:仅 tab 组有 pill;外链组永远不画 pill(desktop)。
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
const [pillRect, setPillRect] = useState<{ top: number; height: number } | null>(null);
useLayoutEffect(() => {
if (mobile) {
setPillRect(null);
return;
useEffect(() => {
let cancelled = false;
void getPlatformCapabilities().then(caps => { if (!cancelled) setPlatformCaps(caps); });
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (!supportsShortcuts && section === 'shortcuts') setSection('general');
}, [supportsShortcuts, section]);
useEffect(() => {
mountedRef.current = true;
if (!previousFocusRef.current && document.activeElement instanceof HTMLElement) {
previousFocusRef.current = document.activeElement;
}
const idx = TAB_ITEMS.findIndex(it => it.id === section);
const el = tabRefs.current[idx];
if (!el) return;
setPillRect({ top: el.offsetTop, height: el.offsetHeight });
}, [section, mobile]);
// Do not summon a software keyboard when opening mobile settings.
(mobile ? closeRef.current : searchRef.current)?.focus();
return () => {
mountedRef.current = false;
window.requestAnimationFrame(() => {
if (!mountedRef.current && previousFocusRef.current?.isConnected) previousFocusRef.current.focus();
});
};
}, []);
useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = 0;
}, [section, searching]);
const selectSection = (next: SettingsSectionId, fromSearch = false) => {
setSection(next);
setQuery('');
if (fromSearch) window.requestAnimationFrame(() => headingRef.current?.focus());
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
// Nested selectors and shortcut recorders own their keys before the dialog does.
if (event.defaultPrevented || event.nativeEvent.isComposing) return;
const nestedPopup = document.querySelector('[role="listbox"], [data-base-ui-portal]');
const target = event.target instanceof Element ? event.target : null;
if (nestedPopup || (target?.closest('[role="dialog"]') !== surfaceRef.current)) return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
if (query) { setQuery(''); searchRef.current?.focus(); }
else onClose();
}
if (event.key === 'Tab') {
const focusable = Array.from(surfaceRef.current?.querySelectorAll<HTMLElement>(
'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex="0"]',
) ?? []).filter(element => element.getClientRects().length > 0);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && (document.activeElement === first || document.activeElement === headingRef.current)) {
event.preventDefault(); last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault(); first?.focus();
}
}
};
return (
<div
onClick={mobile ? undefined : onClose}
style={{
position: mobile ? 'fixed' : 'absolute',
inset: 0,
// 白/实底哲学:遮罩只做半透明压暗(overlay token),不再叠 backdrop-filter 玻璃模糊。
background: mobile ? 'var(--ol-surface)' : 'var(--ol-overlay-bg)',
display: 'flex',
alignItems: mobile ? 'stretch' : 'center',
justifyContent: mobile ? 'stretch' : 'center',
padding: mobile ? 0 : 28,
zIndex: mobile ? 70 : 50,
animation: mobile ? undefined : 'ol-modal-backdrop-in 0.18s var(--ol-motion-soft)',
}}>
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 }}
>
<div
ref={surfaceRef}
role="dialog"
// Existing menus and child dialogs portal to document.body. Keep those
// accessible; FloatingShell makes the covered application inert.
aria-label={t('shell.footer.settings')}
className="ol-settings-surface"
data-ol-mobile={mobile ? 'true' : undefined}
onClick={(e) => e.stopPropagation()}
style={{
width: '100%',
maxWidth: mobile ? undefined : 880,
height: '100%',
maxHeight: mobile ? undefined : 600,
background: 'var(--ol-settings-content-bg)',
borderRadius: mobile ? 0 : 14,
border: mobile ? 'none' : '0.5px solid var(--ol-line)',
boxShadow: mobile ? 'none' : 'var(--ol-shadow-xl)',
display: 'flex',
flexDirection: mobile ? 'column' : 'row',
overflow: 'hidden',
animation: mobile ? undefined : 'ol-modal-card-in 0.24s var(--ol-motion-spring)',
position: 'relative',
}}>
{mobile ? (
<div style={{
flexShrink: 0,
display: 'flex',
alignItems: 'center',
gap: 8,
padding: 'calc(10px + env(safe-area-inset-top, 0px)) 12px 10px',
borderBottom: '0.5px solid var(--ol-line-soft)',
}}>
<button
type="button"
onClick={onClose}
aria-label={t('common.close')}
style={mobileHeaderBtnStyle}
>
<Icon name="close" size={16} />
</button>
<div
className="ol-thinscroll"
style={{ flex: 1, minWidth: 0, display: 'flex', gap: 6, overflowX: 'auto', paddingBottom: 2 }}
>
{TAB_ITEMS.map(it => {
const active = section === it.id;
return (
<button
key={it.id}
type="button"
onClick={() => setSection(it.id as SettingsSectionId)}
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
style={mobileTabChipStyle(active)}
>
{t(`modal.sections.${it.id}`)}
</button>
);
})}
</div>
onClick={event => event.stopPropagation()}
onKeyDown={handleKeyDown}
style={{ width: '100%', maxWidth: mobile ? undefined : 1020, height: '100%', maxHeight: mobile ? undefined : 760, minHeight: 0, background: 'var(--ol-settings-content-bg)', borderRadius: mobile ? 0 : 14, border: mobile ? 'none' : '0.5px solid var(--ol-line)', boxShadow: mobile ? 'none' : 'var(--ol-shadow-xl)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
>
<header style={{ display: 'flex', alignItems: 'center', flexWrap: mobile ? 'wrap' : 'nowrap', gap: 12, padding: mobile ? 'calc(12px + env(safe-area-inset-top, 0px)) 16px 12px' : '16px 20px', borderBottom: '0.5px solid var(--ol-line-soft)', flexShrink: 0 }}>
<div style={{ flex: mobile ? 1 : '0 0 168px', order: 0, fontSize: 16, fontWeight: 650 }}>{t('shell.footer.settings')}</div>
{!mobile && <span style={{ order: 2, color: 'var(--ol-ink-3)', fontSize: 12 }}>{t('modal.autoSaveHint')}</span>}
<button ref={closeRef} type="button" onClick={onClose} aria-label={t('common.close')} style={{ ...iconButtonStyle, order: mobile ? 1 : 3, marginLeft: mobile ? undefined : 'auto' }}><Icon name="close" size={17} /></button>
<div style={{ order: mobile ? 2 : 1, flex: mobile ? '1 0 100%' : 1, maxWidth: mobile ? undefined : 420, 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} />
<input ref={searchRef} type="search" value={query} onChange={event => setQuery(event.target.value)} placeholder={t('modal.searchPlaceholder')} aria-label={t('modal.searchPlaceholder')} style={{ width: '100%', minWidth: 0, border: 0, outline: 'none', background: 'transparent', color: 'var(--ol-ink)', font: 'inherit', fontSize: 13 }} />
{query && <button type="button" aria-label={t('modal.clearSearch')} onClick={() => { setQuery(''); searchRef.current?.focus(); }} style={{ ...iconButtonStyle, width: 22, height: 22 }}><Icon name="close" size={12} /></button>}
</div>
) : (
<aside
style={{
width: 200, flexShrink: 0,
background: 'var(--ol-settings-rail-bg)',
borderRight: '0.5px solid var(--ol-line-soft)',
padding: '18px 12px',
display: 'flex', flexDirection: 'column', gap: 14,
}}>
{/* tab 组 */}
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 1 }}>
{pillRect && (
<div
aria-hidden
style={{
position: 'absolute',
left: 0,
right: 0,
top: pillRect.top,
height: pillRect.height,
background: 'var(--ol-segmented-active-bg)',
borderRadius: 8,
boxShadow: 'var(--ol-segmented-active-shadow)',
transition: 'top 0.36s var(--ol-motion-spring), height 0.36s var(--ol-motion-spring)',
pointerEvents: 'none',
zIndex: 0,
}}
/>
)}
{TAB_ITEMS.map((it, idx) => {
const active = section === it.id;
return (
<button
key={it.id}
ref={el => { tabRefs.current[idx] = el; }}
onClick={() => setSection(it.id as SettingsSectionId)}
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
style={navBtnStyle}>
<Icon name={it.icon} size={14} />
<span style={{ flex: 1 }}>{t(`modal.sections.${it.id}`)}</span>
</header>
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: mobile ? 'column' : 'row' }}>
<aside style={{ width: mobile ? undefined : 200, flexShrink: 0, minHeight: 0, overflow: 'auto', padding: mobile ? '8px 12px' : '16px 12px', background: 'var(--ol-settings-rail-bg)', borderRight: mobile ? undefined : '0.5px solid var(--ol-line-soft)', borderBottom: mobile ? '0.5px solid var(--ol-line-soft)' : undefined }}>
<nav aria-label={t('modal.categoriesLabel')} className="ol-thinscroll" style={{ display: 'flex', flexDirection: mobile ? 'row' : 'column', gap: 4, overflowX: mobile ? 'auto' : undefined }}>
{sections.map(item => (
<button key={item.id} type="button" aria-current={!searching && section === item.id ? 'page' : undefined} onClick={() => selectSection(item.id)} className={!searching && section === item.id ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'} style={{ ...navButtonStyle, flexShrink: 0, padding: mobile ? '8px 10px' : '10px', whiteSpace: 'nowrap', background: !searching && section === item.id ? 'var(--ol-blue-soft)' : 'transparent', color: !searching && section === item.id ? 'var(--ol-blue)' : 'var(--ol-ink-2)', fontWeight: !searching && section === item.id ? 600 : 400 }}>
{!mobile && <Icon name={item.icon} size={15} />}{item.title}
</button>
);
})}
</div>
{/* 外链组 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 1, paddingTop: 8, borderTop: '0.5px solid var(--ol-line-soft)' }}>
{LINK_ITEMS.map(it => (
<button
key={it.id}
onClick={() => { if (it.href) void openExternal(it.href); }}
className="ol-nav-btn"
style={navBtnStyle}>
<Icon name={it.icon} size={14} />
<span style={{ flex: 1 }}>{t(`modal.sections.${it.id}`)}</span>
<Icon name="external" size={11} />
</button>
))}
</div>
</aside>
)}
{/* ─── 内容区 ────────────────────────────────────────────── */}
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', position: 'relative', display: 'flex', flexDirection: 'column' }}>
<SavedToast
saveState={savedToast.state}
message={savedToast.message}
slideFrom="top"
offsetStyle={{ position: 'absolute', top: mobile ? 12 : 16, right: mobile ? 14 : 54 }}
/>
{!mobile && (
<button
onClick={onClose}
style={{
position: 'absolute', top: 14, right: 14, zIndex: 2,
width: 28, height: 28, border: 0, borderRadius: 999,
background: 'transparent', color: 'var(--ol-ink-3)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'default',
transition: 'background 0.16s var(--ol-motion-quick)',
}}
// hover 底色适配白/实底内容区:旧 token 是白玻璃 rgba(白底上不可见),改用 surface-2。
onMouseEnter={e => (e.currentTarget.style.background = 'var(--ol-surface-2)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
title={t('common.close')}>
<Icon name="close" size={14} />
</button>
)}
{!mobile && (
<h2 style={{ margin: 0, padding: '22px 28px 8px', fontSize: 22, fontWeight: 600, letterSpacing: '-0.02em', flexShrink: 0 }}>
{t(`modal.sections.${section}`)}
</h2>
)}
<div
className={[
'ol-thinscroll ol-scroll-fade',
conservative ? 'ol-conservative-scope' : undefined,
].filter(Boolean).join(' ')}
style={{
flex: 1,
minHeight: 0,
overflow: 'auto',
padding: mobile ? '12px 16px calc(16px + env(safe-area-inset-bottom, 0px))' : '10px 28px 28px',
}}>
{/* key=section 让切 tab 时整块重挂载,ol-tab-fade 轻微淡入。 */}
<div
key={section}
style={{ display: 'flex', flexDirection: 'column', gap: 12, animation: 'ol-tab-fade 0.2s var(--ol-motion-soft)' }}>
{section === 'general' && <GeneralTab />}
{section === 'services' && <ServicesTab />}
{section === 'privacy' && <PrivacyTab />}
{section === 'advanced' && <AdvancedTab />}
{section === 'about' && <AboutTab />}
))}
</nav>
{!mobile && <HelpLinks />}
</aside>
<div style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', position: 'relative' }}>
<SavedToast saveState={savedToast.state} message={savedToast.message} slideFrom="top" offsetStyle={{ position: 'absolute', top: 12, right: 16 }} />
<div style={{ padding: mobile ? '18px 16px 12px' : '24px 28px 16px', flexShrink: 0 }}>
<h2 ref={headingRef} tabIndex={-1} style={{ margin: 0, fontSize: mobile ? 20 : 24, fontWeight: 650, letterSpacing: '-0.025em', outline: 'none' }}>{searching ? t('modal.searchResults') : t(`modal.sections.${section}`)}</h2>
<p style={{ margin: '7px 0 0', fontSize: 12.5, lineHeight: 1.6, color: 'var(--ol-ink-3)' }} aria-live="polite">{searching ? t('modal.searchCount', { count: results.length }) : t(`modal.descriptions.${section}`)}</p>
</div>
{mobile && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 20, paddingTop: 16, borderTop: '0.5px solid var(--ol-line-soft)' }}>
{LINK_ITEMS.map(it => (
<button
key={it.id}
type="button"
onClick={() => { if (it.href) void openExternal(it.href); }}
className="ol-nav-btn"
style={navBtnStyle}
>
<Icon name={it.icon} size={14} />
<span style={{ flex: 1 }}>{t(`modal.sections.${it.id}`)}</span>
<Icon name="external" size={11} />
</button>
))}
<div ref={scrollRef} className={['ol-thinscroll', conservative ? 'ol-conservative-scope' : ''].join(' ')} style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: mobile ? '0 16px calc(20px + env(safe-area-inset-bottom, 0px))' : '0 28px 28px' }}>
{searching && <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{results.map(item => <button key={item.id} type="button" onClick={() => selectSection(item.id, true)} style={{ ...navButtonStyle, border: '0.5px solid var(--ol-line)', padding: 16, alignItems: 'flex-start', background: 'var(--ol-surface)' }}>
<Icon name={item.icon} size={18} />
<span style={{ flex: 1, minWidth: 0 }}><span style={{ display: 'block', fontWeight: 600, marginBottom: 5 }}>{item.title}</span><span style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.6 }}>{item.description}</span></span>
<Icon name="chevRight" size={14} />
</button>)}
{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>}
<div key={section} style={{ display: searching ? 'none' : 'flex', flexDirection: 'column', gap: 16 }}>
{section === 'general' && <GeneralTab />}
{section === 'shortcuts' && <ShortcutsTab />}
{section === 'appearance' && <AppearanceTab />}
{section === 'services' && <ServicesTab />}
{section === 'privacy' && <PrivacyTab />}
{section === 'advanced' && <AdvancedTab />}
{section === 'about' && <AboutTab />}
</div>
)}
{mobile && !searching && <HelpLinks />}
</div>
</div>
</div>
</div>
@@ -291,41 +181,10 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
);
}
const mobileHeaderBtnStyle: CSSProperties = {
width: 36,
height: 36,
flexShrink: 0,
border: 0,
borderRadius: 10,
background: 'transparent',
color: 'var(--ol-ink-3)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'default',
};
function mobileTabChipStyle(active: boolean): CSSProperties {
return {
flexShrink: 0,
padding: '6px 12px',
borderRadius: 999,
fontFamily: 'inherit',
fontSize: 12,
fontWeight: active ? 600 : 500,
cursor: 'default',
...chipSelectedStyle(active),
};
function HelpLinks() {
const { t } = useTranslation();
return <div style={{ display: 'flex', flexDirection: 'column', gap: 4, paddingTop: 16, marginTop: 20, borderTop: '0.5px solid var(--ol-line-soft)' }}>{LINKS.map(link => <button key={link.id} type="button" onClick={() => void openExternal(link.href)} className="ol-nav-btn" style={navButtonStyle}><Icon name={link.icon} size={14} /><span style={{ flex: 1 }}>{t(`modal.sections.${link.id}`)}</span><Icon name="external" size={11} /></button>)}</div>;
}
const navBtnStyle = {
display: 'flex', alignItems: 'center', gap: 10,
padding: '7px 10px',
borderRadius: 8, border: 0,
background: 'transparent',
fontFamily: 'inherit', fontSize: 13,
cursor: 'default', textAlign: 'left' as const,
position: 'relative' as const,
zIndex: 1,
transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)',
};
const iconButtonStyle: CSSProperties = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, width: 30, height: 30, padding: 0, border: 0, borderRadius: 8, background: 'transparent', color: 'var(--ol-ink-3)', cursor: 'pointer' };
const navButtonStyle: CSSProperties = { display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', border: 0, borderRadius: 8, background: 'transparent', color: 'var(--ol-ink-2)', font: 'inherit', fontSize: 13, cursor: 'pointer', textAlign: 'left' };
@@ -59,15 +59,21 @@ export function ShortcutRecorder({
useEffect(() => {
if (!menuOpen) return;
const onKeyDown = (e: globalThis.KeyboardEvent) => {
if (e.key === 'Escape') setMenuOpen(false);
if (e.key === 'Escape' && !e.isComposing) {
e.preventDefault();
e.stopPropagation();
setMenuOpen(false);
rootRef.current?.querySelector<HTMLButtonElement>('[aria-expanded]')?.focus();
}
};
const onPointerDown = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setMenuOpen(false);
};
window.addEventListener('keydown', onKeyDown);
// Capture Escape before the surrounding settings dialog handles it.
window.addEventListener('keydown', onKeyDown, true);
window.addEventListener('mousedown', onPointerDown);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keydown', onKeyDown, true);
window.removeEventListener('mousedown', onPointerDown);
};
}, [menuOpen]);
@@ -19,6 +19,7 @@
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
@@ -82,6 +83,7 @@ export function SelectLite({
onOpenChange,
}: SelectLiteProps) {
const [open, setOpen] = useState(false);
const listboxId = useId();
// leaving 让 popover 在卸载前播完 exit keyframe(用户报"没有收缩动画"——之前直接 unmount)
const [leaving, setLeaving] = useState(false);
const [highlight, setHighlight] = useState<number>(-1);
@@ -277,6 +279,9 @@ export function SelectLite({
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? listboxId : undefined}
aria-owns={open ? listboxId : undefined}
aria-activedescendant={open && highlight >= 0 ? `${listboxId}-${highlight}` : undefined}
aria-disabled={disabled}
aria-label={ariaLabel}
disabled={disabled}
@@ -305,7 +310,9 @@ export function SelectLite({
{open && anchor && createPortal(
<div
ref={setPopoverRef}
id={listboxId}
role="listbox"
aria-label={ariaLabel}
style={{
position: 'fixed',
left: anchor.left,
@@ -337,6 +344,7 @@ export function SelectLite({
return (
<div
key={option.value || `__opt_${index}`}
id={`${listboxId}-${index}`}
data-option-index={index}
role="option"
aria-selected={isSelected}
+136 -17
View File
@@ -317,13 +317,52 @@ export const en: typeof zhCN = {
},
},
overview: {
refresh: "Refresh status",
servicesTitle: "Current voice services",
statsTitle: "Your activity",
omniKind: "Multimodal voice",
omniName: "Current Omni model",
statusLoading: "Reading service configuration…",
configureProvider: "Configure",
manageProvider: "Manage service",
recentEmptyHint: "No dictations yet. Follow the guide above to try one; your result will appear here.",
providerHelp: {
asr: "Turns your speech into text.",
llm: "Organizes and polishes text in your style.",
omni: "One model handles both speech recognition and text processing.",
},
actions: {
refresh: "Try again",
services: "AI services & models",
general: "Recording & input",
shortcuts: "Shortcuts",
privacy: "Permissions & data",
},
guide: {
nextStep: "Next step",
loadingTitle: "Reading your configuration",
loadingDesc: "Your current services and next step will appear shortly.",
unavailableTitle: "Service status is unavailable",
unavailableDesc: "Try reading it again, or open AI services to review your configuration.",
servicesTitle: "Set up your voice services",
servicesDesc: "Start here: choose services for speech recognition and text processing. In Omni mode, only the active multimodal model needs configuration.",
permissionsTitle: "Check your shortcut status",
permissionsDesc: "The shortcut adapter is unavailable. Open Permissions & data to see its status and available options.",
shortcutsTitle: "Choose a recording shortcut",
shortcutsDesc: "Pick a shortcut that feels natural so you can start dictating while you type.",
recordingTitle: "Choose how you record",
recordingDesc: "Your service configuration is saved. Open recording settings to choose your microphone and recording mode.",
tryDictationTitle: "Try a dictation",
tryDictationDesc: "Place the cursor where you want to type. {{shortcut}}",
permissionsHint: "Recording or shortcuts not responding? Check permissions, microphone access, and shortcut status in Permissions & data.",
},
kicker: 'DASHBOARD',
title: "Today's overview",
desc: 'Today\'s dictation stats and system status.',
pressPrefix: 'Press',
pressSuffix: 'to start',
asrKind: 'ASR',
llmKind: 'LLM',
asrKind: 'Speech recognition',
llmKind: 'Text processing',
asrName: 'Volcengine',
asrSubname: 'bigmodel',
llmName: 'OpenAI-compatible',
@@ -946,6 +985,29 @@ export const en: typeof zhCN = {
startupAtBootError: 'Failed to toggle launch at login: {{message}}',
},
channels: {
llmTitle: "Text processing channels",
asrTitle: "Speech recognition channels",
current: "Currently used",
enabled: "Enabled",
disabled: "Disabled",
enabledFor: "Enable {{name}}",
modelNotSet: "No model set explicitly",
localModelManaged: "Model managed by the system or Local models",
lastCheck: "Last check",
verifying: "Checking…",
notVerified: "Not checked yet",
passed: "Check passed",
failed: "Check failed · {{reason}}",
elapsed: "Took {{ms}} ms",
staleResult: "Result is over 24 hours old",
connectionTitle: "Service connection",
modelTitle: "Model settings",
availableModels: "Available models",
validationTitle: "Connection check",
validationHint: "Manually send a real request to check this configuration. It may use service credits. Saving settings does not run a check.",
autoSaveHint: "Changes save automatically. Once configured, you can check the connection manually.",
nameHint: "This name distinguishes channels from the same provider. It does not affect the model or connection.",
errModel: "Model",
verify: 'Verify',
verifyHint: 'Makes one real API call to check this channel works right now',
errTimeout: 'timeout',
@@ -953,14 +1015,14 @@ export const en: typeof zhCN = {
errEndpoint: 'endpoint',
errGeneric: 'failed',
dragHint: 'Drag to change priority',
orderHint: 'Drag to reorder — the topmost one is used first. Disabled channels sink to the bottom.',
empty: 'No channels yet. Use "Add channel" below to create one.',
orderHint: 'Requests use the first enabled channel. Drag to reorder; disabled channels move to the bottom.',
empty: 'No channels yet. Choose "Add channel" to connect your first service.',
add: 'Add channel',
edit: 'Edit',
createTitle: 'Add channel',
editTitle: 'Edit channel',
providerLabel: 'Provider',
nameLabel: 'Name',
nameLabel: 'Channel name (optional)',
namePlaceholder: 'e.g. SiliconFlow — main key',
create: 'Create',
delete: 'Delete channel',
@@ -970,7 +1032,7 @@ export const en: typeof zhCN = {
minutesAgo: '{{count}}m ago',
hoursAgo: '{{count}}h ago',
daysAgo: '{{count}}d ago',
localEngineModelHint: 'Local engine models are downloaded and switched under Advanced → Local models.',
localEngineModelHint: 'Download and switch local models under AI services & models → Local models.',
},
providers: {
localEngineNoCredentials: 'Local engines need no API key or endpoint.',
@@ -1477,14 +1539,58 @@ export const en: typeof zhCN = {
},
},
},
startup: {
loading: "Starting OpenLess…",
loadingDesc: "Connecting to the local service and checking compatibility.",
failed: "OpenLess could not start",
recovery: "Check again. If the problem continues, fully quit and reopen the app. If this started after an upgrade, make sure the complete app is on the same version.",
retry: "Check again",
details: "Show error details",
},
modal: {
serviceViews: {
label: "Service settings",
llm: "Language models",
asr: "Speech recognition",
omni: "Multimodal",
models: "Local models",
connections: "Connections",
},
searchPlaceholder: "Find a settings category…",
clearSearch: "Clear search",
categoriesLabel: "Settings categories",
searchResults: "Search results",
searchCount: "Categories found: {{count}}",
noResults: "No matching categories. Try “microphone”, “models” or “theme”.",
autoSaveHint: "Changes save automatically",
descriptions: {
general: "Choose a microphone, adjust recording and text input, or connect your phone.",
shortcuts: "Set up shortcuts and choose what happens when you select text.",
services: "Choose speech recognition and text processing services. Manage channels, local models and connections.",
appearance: "Adjust the theme, page layout and interface language for comfortable reading.",
privacy: "Check system permissions and connections. Manage history, recordings and local data.",
advanced: "Configure Less Computer, multimodal processing and debugging as needed.",
about: "View your version, update channel and automatic update settings.",
},
searchKeywords: {
general: "microphone recording input phone remote LAN PIN capsule mute startup autostart",
shortcuts: "shortcut hotkey key combination selection polish voice editing",
services: "ASR LLM API channel model cloud local offline network proxy marketplace",
appearance: "theme dark light language font text size layout heatmap",
privacy: "permission microphone accessibility history recording storage privacy export",
advanced: "Less Computer Claude Agent multimodal Omni debug logs experiment",
about: "version Beta stable update upgrade",
},
sections: {
general: 'General',
services: 'Services',
privacy: 'Privacy',
advanced: 'Advanced',
appearance: "Appearance & language",
shortcuts: "Shortcuts & selection",
general: "Recording & input",
services: "AI services & models",
privacy: "Permissions & data",
advanced: "Experiments & extensions",
personalize: 'Personalize',
about: 'About',
about: "About & updates",
helpCenter: 'Help center',
releaseNotes: 'Release notes',
},
@@ -1651,8 +1757,8 @@ export const en: typeof zhCN = {
sherpaModelWhisperLargeV3Desc: 'The best open-source multilingual Whisper tier — high quality, large download.',
sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8',
sherpaModelQwen3Desc: 'Converted sherpa-onnx Qwen3-ASR model with multilingual recognition and stronger long-form context handling.',
modelSelectTitle: 'Model download & manage',
modelSelectDesc: 'Downloaded models automatically appear under the local provider in ASR Transcription above — pick one to use it. Click "+ Download new model" to get more.',
modelSelectTitle: "Models on this device",
modelSelectDesc: "Track downloads, manage files, or load a model to test it.",
modelSelectPlaceholder: 'Select a downloaded model…',
modelSelectEmpty: 'No downloaded models yet — grab one under “Download & manage”.',
groupDownload: 'Download & manage',
@@ -1694,15 +1800,15 @@ export const en: typeof zhCN = {
keep5min: '5 minutes after last use (default)',
keep30min: '30 minutes after last use',
keepForever: 'Never release (always loaded)',
sidebarTitle: 'Models',
sidebarTitle: "Downloaded & downloading",
activePill: 'Active',
setDefault: 'Set as default',
downloading: 'Downloading',
startDownload: 'Start download',
downloadNewModel: 'Download new model',
downloadDialogTitle: 'Download Model',
downloadDialogAlreadyHave: 'Already downloaded — ready to use',
downloadDialogDesc: 'Pick a model on the left, then click "Start download". Once downloaded, select the local model provider in ASR Transcription (Services → AI Providers) to use it.',
downloadDialogAlreadyHave: "The model files are downloaded. Return to the model page to load and test, or choose its provider in ASR transcription.",
downloadDialogDesc: "Compare model sizes and descriptions, then download your choice. Select the matching local service under Speech recognition when it is ready.",
detailRepo: 'Repository',
hfDownloads: 'Downloads',
hfLikes: 'Likes',
@@ -1712,12 +1818,25 @@ export const en: typeof zhCN = {
detailFiles: 'files',
detailDownloaded: 'Downloaded',
detailEmpty: 'Select a model on the left to see details',
detailEmpty: "Select a model to view its details",
foundryLanguage: 'Language',
foundryRuntimeSource: 'Runtime source',
mirrorGithubRelease: 'GitHub Releases',
keep: 'Keep loaded',
downloadSettingsTitle: 'Download & storage',
downloadSettingsDesc: 'Mirror source · model storage location · in-memory engine',
libraryEmptyTitle: "No local models yet",
libraryEmptyDesc: "Download a speech recognition model to process audio on this device. If an existing model is missing, reload the catalog.",
catalogTitle: "Model catalog",
catalogEmpty: "No models are available to display. Reload the catalog and try again.",
reloadCatalog: "Reload catalog",
engineLabel: "Recognition engine",
sizeLabel: "Model size",
allEngines: "All",
backToCatalog: "Back to catalog",
detailsTitle: "Model details",
testActivateHint: "Load and test makes this the active model, then runs the built-in audio test.",
downloadProgressHint: "After starting, track progress or cancel the download on the model page.",
errorDetails: "Error details",
},
};
+136 -17
View File
@@ -319,13 +319,52 @@ export const ja: typeof zhCN = {
},
},
overview: {
refresh: "状態を更新",
servicesTitle: "使用中の音声サービス",
statsTitle: "利用記録",
omniKind: "マルチモーダル音声",
omniName: "現在の Omni モデル",
statusLoading: "サービス設定を読み込み中…",
configureProvider: "設定する",
manageProvider: "サービスを管理",
recentEmptyHint: "まだ音声入力の記録がありません。上の案内に沿って試すと、ここに結果が表示されます。",
providerHelp: {
asr: "音声をテキストに変換します。",
llm: "あなたのスタイルに合わせて文章を整えます。",
omni: "1つのモデルで音声認識とテキスト処理を行います。",
},
actions: {
refresh: "再読み込み",
services: "AI サービスとモデル",
general: "録音と入力",
shortcuts: "ショートカット",
privacy: "権限とデータ",
},
guide: {
nextStep: "次のステップ",
loadingTitle: "設定を読み込んでいます",
loadingDesc: "使用中のサービスと次の操作をまもなく表示します。",
unavailableTitle: "サービスの状態を読み込めません",
unavailableDesc: "再読み込みするか、AI サービスで設定を確認してください。",
servicesTitle: "まず音声サービスを設定しましょう",
servicesDesc: "ここから始めるのがおすすめです。音声認識とテキスト処理のサービスを選びましょう。Omni モードでは、使用するマルチモーダルモデルだけを設定します。",
permissionsTitle: "ショートカットの状態を確認しましょう",
permissionsDesc: "ショートカット機能を利用できません。「権限とデータ」で状態と対処方法を確認してください。",
shortcutsTitle: "録音ショートカットを設定しましょう",
shortcutsDesc: "使いやすいキーを選ぶと、入力中に音声入力を始められます。",
recordingTitle: "録音方法を確認しましょう",
recordingDesc: "サービス設定は保存されています。録音設定でマイクと録音モードを選びましょう。",
tryDictationTitle: "音声入力を試してみましょう",
tryDictationDesc: "入力したい場所にカーソルを置いてください。{{shortcut}}",
permissionsHint: "録音やショートカットが反応しない場合は、「権限とデータ」で権限、マイク、ショートカットの状態を確認してください。",
},
kicker: 'DASHBOARD',
title: '本日の概要',
desc: '本日のディクテーション統計とシステム状態。',
pressPrefix: '押す',
pressSuffix: 'で録音開始',
asrKind: 'ASR 音声',
llmKind: 'LLM モデル',
asrKind: '音声認識',
llmKind: 'テキスト処理',
asrName: 'Volcengine',
asrSubname: 'bigmodel',
llmName: 'OpenAI 互換',
@@ -948,6 +987,29 @@ export const ja: typeof zhCN = {
startupAtBootError: '自動起動の切り替えに失敗:{{message}}',
},
channels: {
llmTitle: "テキスト処理チャンネル",
asrTitle: "音声認識チャンネル",
current: "使用中",
enabled: "有効",
disabled: "無効",
enabledFor: "{{name}} を有効にする",
modelNotSet: "モデルの個別設定なし",
localModelManaged: "モデルはシステムまたは「ローカルモデル」で管理",
lastCheck: "前回の接続確認",
verifying: "確認中…",
notVerified: "未確認",
passed: "確認に成功",
failed: "確認に失敗 · {{reason}}",
elapsed: "所要時間 {{ms}} ms",
staleResult: "24 時間以上前の結果",
connectionTitle: "サービス接続",
modelTitle: "モデル設定",
availableModels: "利用可能なモデル",
validationTitle: "接続の確認",
validationHint: "実際にリクエストを送信して設定を確認します。サービスの利用枠を消費する場合があります。設定の保存だけでは確認を実行しません。",
autoSaveHint: "変更は自動保存されます。設定が終わったら、手動で接続を確認できます。",
nameHint: "同じプロバイダーのチャンネルを区別するための名前です。モデルや接続には影響しません。",
errModel: "モデル",
verify: '検証',
verifyHint: '実際に API を1回呼んで、このチャネルが今使えるか確認します',
errTimeout: 'タイムアウト',
@@ -955,14 +1017,14 @@ export const ja: typeof zhCN = {
errEndpoint: 'エンドポイント',
errGeneric: '失敗',
dragHint: 'ドラッグで優先順位を変更',
orderHint: 'ドラッグで並べ替え。一番上が優先で使われます。オフにしたチャネルは末尾に移動します。',
empty: 'チャネルがまだありません。下の「チャネルを追加」から作成してください。',
orderHint: '有効なチャネルのうち、先頭のものを使用します。ドラッグで順序を変更できます。無効なチャネルは末尾に移動します。',
empty: 'チャネルがまだありません。「チャネルを追加」で最初のサービスを接続しましょう。',
add: 'チャネルを追加',
edit: '編集',
createTitle: 'チャネルを追加',
editTitle: 'チャネルを編集',
providerLabel: 'プロバイダー',
nameLabel: '名前',
nameLabel: 'チャネル名(任意)',
namePlaceholder: '例:SiliconFlow — メインキー',
create: '作成',
delete: 'チャネルを削除',
@@ -972,7 +1034,7 @@ export const ja: typeof zhCN = {
minutesAgo: '{{count}}分前',
hoursAgo: '{{count}}時間前',
daysAgo: '{{count}}日前',
localEngineModelHint: 'ローカルエンジンのモデルは「詳細 → ローカルモデル」でダウンロード・切り替えします。',
localEngineModelHint: '「AI サービスとモデル → ローカルモデル」でモデルをダウンロード・切り替えできます。',
},
providers: {
localEngineNoCredentials: 'ローカルエンジンに API キーやエンドポイントは不要です。',
@@ -1445,14 +1507,58 @@ export const ja: typeof zhCN = {
},
},
},
startup: {
loading: "OpenLess を起動中…",
loadingDesc: "ローカルサービスに接続し、互換性を確認しています。",
failed: "OpenLess を起動できません",
recovery: "再確認してください。解決しない場合はアプリを完全に終了して開き直してください。更新後に発生した場合は、アプリ全体が同じバージョンであることを確認してください。",
retry: "再確認",
details: "エラーの詳細",
},
modal: {
serviceViews: {
label: "サービス設定",
llm: "言語モデル",
asr: "音声認識",
omni: "マルチモーダル",
models: "ローカルモデル",
connections: "接続と拡張",
},
searchPlaceholder: "設定カテゴリを検索…",
clearSearch: "検索をクリア",
categoriesLabel: "設定カテゴリ",
searchResults: "検索結果",
searchCount: "関連するカテゴリ:{{count}} 件",
noResults: "カテゴリが見つかりません。「マイク」「モデル」「テーマ」などをお試しください。",
autoSaveHint: "変更は自動保存されます",
descriptions: {
general: "マイク、録音方法、文字入力を設定し、スマートフォンからの入力を接続します。",
shortcuts: "各機能のショートカットと、テキスト選択後の操作を設定します。",
services: "音声認識と文章処理のサービス、チャンネル、ローカルモデル、接続を管理します。",
appearance: "テーマ、レイアウト、表示言語を読みやすく調整します。",
privacy: "システム権限と接続を確認し、履歴、録音、ローカルデータを管理します。",
advanced: "必要に応じて Less Computer、マルチモーダル処理、デバッグを設定します。",
about: "現在のバージョン、更新チャンネル、自動更新を確認します。",
},
searchKeywords: {
general: "マイク 録音 入力 スマホ リモート LAN PIN カプセル ミュート 起動",
shortcuts: "ショートカット ホットキー キー 選択 推敲 音声編集",
services: "ASR LLM API チャンネル モデル クラウド ローカル ネットワーク プロキシ マーケット",
appearance: "テーマ ダーク ライト 言語 フォント 文字 サイズ レイアウト ヒートマップ",
privacy: "権限 マイク アクセシビリティ 履歴 録音 保存 プライバシー エクスポート",
advanced: "Less Computer Claude Agent マルチモーダル Omni デバッグ ログ 実験",
about: "バージョン Beta 安定 更新 アップデート",
},
sections: {
general: '一般',
services: 'サービス',
privacy: 'プライバシー',
advanced: '詳細設定',
appearance: "外観と言語",
shortcuts: "ショートカットと選択",
general: "録音と入力",
services: "AI サービスとモデル",
privacy: "権限とデータ",
advanced: "実験機能と拡張",
personalize: 'パーソナライズ',
about: '情報',
about: "バージョンと更新",
helpCenter: 'ヘルプセンター',
releaseNotes: 'リリースノート',
},
@@ -1619,8 +1725,8 @@ export const ja: typeof zhCN = {
sherpaModelWhisperLargeV3Desc: 'オープンソース多言語モデルの中で最高品質の Whisper 系。高品質だが大容量。',
sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8',
sherpaModelQwen3Desc: '変換済み sherpa-onnx Qwen3-ASR モデル。多言語認識とより強い長文コンテキスト処理に対応。',
modelSelectTitle: 'モデルのダウンロードと管理',
modelSelectDesc: 'ダウンロードしたモデルは上部の「ASR 文字起こし」のローカルプロバイダーに自動で表示され、選択すると利用できます。「+ 新しいモデルをダウンロード」でさらに入手できます。',
modelSelectTitle: "この端末のモデル",
modelSelectDesc: "ダウンロード状況の確認、ファイルの管理、モデルの読み込みとテストができます。",
modelSelectPlaceholder: 'ダウンロード済みモデルを選択…',
modelSelectEmpty: 'ダウンロード済みモデルがありません。「ダウンロードと管理」から入手してください。',
groupDownload: 'ダウンロードと管理',
@@ -1662,15 +1768,15 @@ export const ja: typeof zhCN = {
keep5min: '最終使用から 5 分(既定)',
keep30min: '最終使用から 30 分',
keepForever: '解放しない(常にロード)',
sidebarTitle: 'モデル一覧',
sidebarTitle: "ダウンロード済み・進行中",
activePill: '使用中',
setDefault: 'デフォルトに設定',
downloading: 'ダウンロード中',
startDownload: 'ダウンロード開始',
downloadNewModel: '新しいモデルをダウンロード',
downloadDialogTitle: 'モデルをダウンロード',
downloadDialogAlreadyHave: 'ダウンロード済みです',
downloadDialogDesc: '左のモデルを選択して「ダウンロード開始」をクリックします。完了後、「サービス → AI プロバイダー → ASR 文字起こし」でローカルモデルのプロバイダーを選択すると利用できます。',
downloadDialogAlreadyHave: "モデルファイルはダウンロード済みです。モデルページで読み込みとテストを行うか、「ASR 音声文字起こし」で対応するプロバイダーを選択してください。",
downloadDialogDesc: "サイズと説明を確認してモデルをダウンロードします。完了後、「音声認識」で対応するローカルサービスを選択してください。",
detailRepo: 'リポジトリ',
hfDownloads: 'ダウンロード数',
hfLikes: 'いいね',
@@ -1680,12 +1786,25 @@ export const ja: typeof zhCN = {
detailFiles: 'ファイル',
detailDownloaded: 'ダウンロード済み',
detailEmpty: '左側からモデルを選択',
detailEmpty: "モデルを選択して詳細を表示",
foundryLanguage: '言語',
foundryRuntimeSource: 'ランタイムソース',
mirrorGithubRelease: 'GitHub Releases',
keep: '保持',
downloadSettingsTitle: 'ダウンロードとストレージ設定',
downloadSettingsDesc: 'ミラーソース · モデル保存場所 · メモリ内エンジン',
libraryEmptyTitle: "ローカルモデルがありません",
libraryEmptyDesc: "音声認識モデルをダウンロードすると、この端末で音声を処理できます。既存のモデルが表示されない場合は、一覧を再読み込みしてください。",
catalogTitle: "モデルカタログ",
catalogEmpty: "表示できるモデルがありません。カタログを再読み込みしてください。",
reloadCatalog: "一覧を再読み込み",
engineLabel: "認識エンジン",
sizeLabel: "モデルサイズ",
allEngines: "すべて",
backToCatalog: "カタログに戻る",
detailsTitle: "モデルの詳細",
testActivateHint: "「読み込みとテスト」はこのモデルを使用中に設定してから、内蔵音声テストを実行します。",
downloadProgressHint: "開始後はモデルページで進行状況の確認やダウンロードのキャンセルができます。",
errorDetails: "エラーの詳細",
},
};
+136 -17
View File
@@ -319,13 +319,52 @@ export const ko: typeof zhCN = {
},
},
overview: {
refresh: "상태 새로고침",
servicesTitle: "현재 음성 서비스",
statsTitle: "사용 기록",
omniKind: "멀티모달 음성",
omniName: "현재 Omni 모델",
statusLoading: "서비스 설정을 불러오는 중…",
configureProvider: "설정하기",
manageProvider: "서비스 관리",
recentEmptyHint: "아직 받아쓰기 기록이 없습니다. 위 안내에 따라 사용해 보면 결과가 여기에 표시됩니다.",
providerHelp: {
asr: "음성을 텍스트로 변환합니다.",
llm: "내 스타일에 맞게 글을 정리하고 다듬습니다.",
omni: "하나의 모델로 음성 인식과 텍스트 처리를 수행합니다.",
},
actions: {
refresh: "다시 불러오기",
services: "AI 서비스 및 모델",
general: "녹음 및 입력",
shortcuts: "단축키",
privacy: "권한 및 데이터",
},
guide: {
nextStep: "다음 단계",
loadingTitle: "설정을 불러오고 있어요",
loadingDesc: "잠시 후 현재 서비스와 다음 할 일을 보여 드릴게요.",
unavailableTitle: "서비스 상태를 불러올 수 없어요",
unavailableDesc: "다시 불러오거나 AI 서비스에서 설정을 확인해 주세요.",
servicesTitle: "먼저 음성 서비스를 설정하세요",
servicesDesc: "여기서 시작하는 것을 추천해요. 음성 인식과 텍스트 처리 서비스를 선택하세요. Omni 모드에서는 사용할 멀티모달 모델만 설정하면 됩니다.",
permissionsTitle: "단축키 상태를 확인하세요",
permissionsDesc: "현재 단축키 기능을 사용할 수 없습니다. 권한 및 데이터에서 상태와 해결 방법을 확인해 주세요.",
shortcutsTitle: "녹음 단축키를 설정하세요",
shortcutsDesc: "편한 단축키를 선택하면 입력 중에 받아쓰기를 시작할 수 있어요.",
recordingTitle: "녹음 방식을 확인하세요",
recordingDesc: "서비스 설정이 저장되어 있습니다. 녹음 설정에서 마이크와 원하는 녹음 방식을 선택하세요.",
tryDictationTitle: "받아쓰기를 해 보세요",
tryDictationDesc: "입력할 곳에 커서를 놓으세요. {{shortcut}}",
permissionsHint: "녹음이나 단축키가 반응하지 않나요? 권한 및 데이터에서 권한, 마이크, 단축키 상태를 확인하세요.",
},
kicker: 'DASHBOARD',
title: '오늘 개요',
desc: '오늘의 받아쓰기 통계와 시스템 상태.',
pressPrefix: '누르기',
pressSuffix: '녹음 시작',
asrKind: 'ASR 음성',
llmKind: 'LLM 모델',
asrKind: '음성 인식',
llmKind: '텍스트 처리',
asrName: 'Volcengine',
asrSubname: 'bigmodel',
llmName: 'OpenAI 호환',
@@ -948,6 +987,29 @@ export const ko: typeof zhCN = {
startupAtBootError: '자동 시작 전환 실패: {{message}}',
},
channels: {
llmTitle: "텍스트 처리 채널",
asrTitle: "음성 인식 채널",
current: "현재 사용 중",
enabled: "사용",
disabled: "사용 안 함",
enabledFor: "{{name}} 사용",
modelNotSet: "모델을 별도로 설정하지 않음",
localModelManaged: "시스템 또는 로컬 모델 페이지에서 모델 관리",
lastCheck: "마지막 확인",
verifying: "확인 중…",
notVerified: "아직 확인하지 않음",
passed: "확인 성공",
failed: "확인 실패 · {{reason}}",
elapsed: "소요 시간 {{ms}} ms",
staleResult: "24시간이 지난 결과",
connectionTitle: "서비스 연결",
modelTitle: "모델 설정",
availableModels: "사용 가능한 모델",
validationTitle: "연결 확인",
validationHint: "실제 요청을 보내 현재 설정을 확인합니다. 서비스 사용량이 차감될 수 있습니다. 설정을 저장해도 자동으로 확인하지 않습니다.",
autoSaveHint: "변경 사항은 자동으로 저장됩니다. 설정을 마친 후 연결을 직접 확인할 수 있습니다.",
nameHint: "같은 제공업체의 여러 채널을 구분하는 이름입니다. 모델이나 연결에는 영향을 주지 않습니다.",
errModel: "모델",
verify: '검증',
verifyHint: '실제로 API를 한 번 호출해 이 채널이 지금 되는지 확인합니다',
errTimeout: '시간 초과',
@@ -955,14 +1017,14 @@ export const ko: typeof zhCN = {
errEndpoint: '주소',
errGeneric: '실패',
dragHint: '드래그해서 우선순위 변경',
orderHint: '드래그해서 순서를 바꾸세요. 맨 위가 먼저 사용됩니다. 끈 채널은 맨 아래로 내려갑니다.',
empty: '아직 채널이 없습니다. 아래 "채널 추가"로 만들어 보세요.',
orderHint: '사용 중인 채널 중 맨 위의 채널로 요청합니다. 드래그로 순서를 바꾸면 사용하지 않는 채널은 맨 아래로 이동합니다.',
empty: '아직 채널이 없습니다. "채널 추가"로 첫 서비스를 연결하세요.',
add: '채널 추가',
edit: '편집',
createTitle: '채널 추가',
editTitle: '채널 편집',
providerLabel: '공급자',
nameLabel: '이름',
nameLabel: '채널 이름 (선택)',
namePlaceholder: '예: SiliconFlow — 메인 키',
create: '만들기',
delete: '채널 삭제',
@@ -972,7 +1034,7 @@ export const ko: typeof zhCN = {
minutesAgo: '{{count}}분 전',
hoursAgo: '{{count}}시간 전',
daysAgo: '{{count}}일 전',
localEngineModelHint: '로컬 엔진 모델은 고급 → 로컬 모델에서 내려받고 전환합니다.',
localEngineModelHint: 'AI 서비스 및 모델 → 로컬 모델에서 모델을 내려받고 전환할 수 있습니다.',
},
providers: {
localEngineNoCredentials: '로컬 엔진은 API 키나 엔드포인트가 필요 없습니다.',
@@ -1445,14 +1507,58 @@ export const ko: typeof zhCN = {
},
},
},
startup: {
loading: "OpenLess 시작 중…",
loadingDesc: "로컬 서비스에 연결하고 호환성을 확인하고 있습니다.",
failed: "OpenLess를 시작할 수 없습니다",
recovery: "다시 확인하세요. 문제가 계속되면 앱을 완전히 종료한 후 다시 여세요. 업그레이드 후 발생한 경우 앱 전체가 동일한 버전인지 확인하세요.",
retry: "다시 확인",
details: "오류 세부 정보",
},
modal: {
serviceViews: {
label: "서비스 설정",
llm: "언어 모델",
asr: "음성 인식",
omni: "멀티모달",
models: "로컬 모델",
connections: "연결 및 확장",
},
searchPlaceholder: "설정 카테고리 찾기…",
clearSearch: "검색 지우기",
categoriesLabel: "설정 카테고리",
searchResults: "검색 결과",
searchCount: "관련 카테고리 {{count}}개",
noResults: "일치하는 카테고리가 없습니다. “마이크”, “모델” 또는 “테마”를 검색해 보세요.",
autoSaveHint: "변경 사항이 자동 저장됩니다",
descriptions: {
general: "마이크, 녹음 방식, 텍스트 입력을 설정하고 휴대폰 입력을 연결합니다.",
shortcuts: "기능별 단축키와 텍스트 선택 후 동작을 설정합니다.",
services: "음성 인식과 텍스트 처리 서비스, 채널, 로컬 모델 및 연결을 관리합니다.",
appearance: "테마, 페이지 배치, 인터페이스 언어를 편하게 읽도록 조정합니다.",
privacy: "시스템 권한과 연결을 확인하고 기록, 녹음 및 로컬 데이터를 관리합니다.",
advanced: "필요에 따라 Less Computer, 멀티모달 처리 및 디버깅을 설정합니다.",
about: "현재 버전, 업데이트 채널 및 자동 업데이트 설정을 확인합니다.",
},
searchKeywords: {
general: "마이크 녹음 입력 휴대폰 원격 LAN PIN 캡슐 음소거 시작 자동시작",
shortcuts: "단축키 핫키 키 조합 선택 다듬기 음성 편집",
services: "ASR LLM API 채널 모델 클라우드 로컬 네트워크 프록시 마켓",
appearance: "테마 다크 라이트 언어 글꼴 글자 크기 배치 레이아웃 히트맵",
privacy: "권한 마이크 접근성 기록 녹음 저장 개인정보 내보내기",
advanced: "Less Computer Claude Agent 멀티모달 Omni 디버그 로그 실험",
about: "버전 Beta 안정 업데이트 업그레이드",
},
sections: {
general: '일반',
services: '서비스',
privacy: '프라이버시',
advanced: '고급',
appearance: "모양 및 언어",
shortcuts: "단축키 및 선택",
general: "녹음 및 입력",
services: "AI 서비스 및 모델",
privacy: "권한 및 데이터",
advanced: "실험 기능 및 확장",
personalize: '개인 설정',
about: '정보',
about: "정보 및 업데이트",
helpCenter: '도움말 센터',
releaseNotes: '릴리스 노트',
},
@@ -1619,8 +1725,8 @@ export const ko: typeof zhCN = {
sherpaModelWhisperLargeV3Desc: '오픈소스 다국어 모델 중 품질이 가장 좋은 Whisper 계열. 고품질이지만 용량이 큽니다.',
sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8',
sherpaModelQwen3Desc: '변환된 sherpa-onnx Qwen3-ASR 모델로 다국어 인식과 더 강한 긴 문맥 처리를 지원합니다.',
modelSelectTitle: '모델 다운로드 및 관리',
modelSelectDesc: '다운로드한 모델은 위의 「ASR 음성 전사」 로컬 공급자에 자동으로 표시되며, 선택하면 사용할 수 있습니다. 「+ 새 모델 다운로드」로 더 많은 모델을 받을 수 있습니다.',
modelSelectTitle: "이 기기의 모델",
modelSelectDesc: "다운로드 상태를 확인하고 파일을 관리하거나 모델을 불러와 테스트하세요.",
modelSelectPlaceholder: '다운로드된 모델 선택…',
modelSelectEmpty: '다운로드된 모델이 없습니다. 「다운로드 및 관리」에서 받으세요.',
groupDownload: '다운로드 및 관리',
@@ -1662,15 +1768,15 @@ export const ko: typeof zhCN = {
keep5min: '마지막 사용 후 5분(기본)',
keep30min: '마지막 사용 후 30분',
keepForever: '해제하지 않음(항상 유지)',
sidebarTitle: '모델 목록',
sidebarTitle: "다운로드 완료 및 진행 중",
activePill: '현재 사용 중',
setDefault: '기본값으로 설정',
downloading: '다운로드 중',
startDownload: '다운로드 시작',
downloadNewModel: '새 모델 다운로드',
downloadDialogTitle: '모델 다운로드',
downloadDialogAlreadyHave: '이미 다운로드됨 — 바로 사용 가능',
downloadDialogDesc: '왼쪽에서 모델을 선택하고 「다운로드 시작」을 클릭하세요. 완료 후 「서비스 → AI 공급자 → ASR 음성 전사」에서 로컬 모델 공급자를 선택하면 사용할 수 있습니다.',
downloadDialogAlreadyHave: "모델 파일이 다운로드되었습니다. 모델 페이지에서 불러와 테스트하거나 ASR 음성 전사에서 해당 제공업체를 선택하세요.",
downloadDialogDesc: "모델 크기와 설명을 확인하고 다운로드하세요. 완료되면 음성 인식에서 해당 로컬 서비스를 선택하세요.",
detailRepo: '저장소',
hfDownloads: '다운로드 수',
hfLikes: '좋아요',
@@ -1680,12 +1786,25 @@ export const ko: typeof zhCN = {
detailFiles: '개 파일',
detailDownloaded: '다운로드됨',
detailEmpty: '왼쪽에서 모델을 선택하세요',
detailEmpty: "모델을 선택하여 세부 정보 보기",
foundryLanguage: '언어',
foundryRuntimeSource: '런타임 소스',
mirrorGithubRelease: 'GitHub Releases',
keep: '유지',
downloadSettingsTitle: '다운로드 및 저장 설정',
downloadSettingsDesc: '미러 소스 · 모델 저장 위치 · 메모리 내 엔진',
libraryEmptyTitle: "아직 로컬 모델이 없습니다",
libraryEmptyDesc: "음성 인식 모델을 다운로드하면 이 기기에서 오디오를 처리할 수 있습니다. 기존 모델이 보이지 않으면 목록을 새로 불러오세요.",
catalogTitle: "모델 카탈로그",
catalogEmpty: "표시할 모델이 없습니다. 카탈로그를 새로 불러온 후 다시 시도하세요.",
reloadCatalog: "목록 새로고침",
engineLabel: "인식 엔진",
sizeLabel: "모델 크기",
allEngines: "전체",
backToCatalog: "카탈로그로 돌아가기",
detailsTitle: "모델 세부 정보",
testActivateHint: "불러오기 및 테스트를 실행하면 이 모델을 현재 사용 모델로 설정한 후 내장 오디오 테스트를 진행합니다.",
downloadProgressHint: "시작 후 모델 페이지에서 진행 상황을 확인하거나 다운로드를 취소할 수 있습니다.",
errorDetails: "오류 세부 정보",
},
};
+136 -17
View File
@@ -315,13 +315,52 @@ export const zhCN = {
},
},
overview: {
refresh: "刷新状态",
servicesTitle: "当前语音服务",
statsTitle: "使用记录",
omniKind: "多模态语音",
omniName: "当前 Omni 模型",
statusLoading: "正在读取服务配置…",
configureProvider: "去配置",
manageProvider: "管理服务",
recentEmptyHint: "还没有听写记录。跟着上方的引导试一次,结果会显示在这里。",
providerHelp: {
asr: "将语音转成文字。",
llm: "按你的风格整理和润色文字。",
omni: "由一个模型完成语音识别和文字处理。",
},
actions: {
refresh: "重新读取",
services: "AI 服务与模型",
general: "录音与输入",
shortcuts: "快捷键",
privacy: "权限与数据",
},
guide: {
nextStep: "下一步",
loadingTitle: "正在读取你的配置",
loadingDesc: "稍等一下,马上显示当前服务和下一步操作。",
unavailableTitle: "暂时无法读取服务状态",
unavailableDesc: "重新读取,或进入 AI 服务查看配置。",
servicesTitle: "先配置语音服务",
servicesDesc: "推荐从这里开始:选择语音识别和文字处理服务;使用 Omni 时,只需配置当前多模态模型。",
permissionsTitle: "先检查快捷键状态",
permissionsDesc: "当前快捷键适配器不可用。打开权限与数据,查看状态和可用的处理方式。",
shortcutsTitle: "设置一个录音快捷键",
shortcutsDesc: "选择顺手的快捷键,之后就能在输入时发起听写。",
recordingTitle: "确认你的录音方式",
recordingDesc: "服务配置已保存。打开录音设置,选择麦克风和适合你的录音方式。",
tryDictationTitle: "试一次听写",
tryDictationDesc: "把光标放到要输入的地方。{{shortcut}}",
permissionsHint: "录音或快捷键没有反应?在「权限与数据」中查看权限、麦克风和快捷键状态。",
},
kicker: 'DASHBOARD',
title: '今日概览',
desc: '今日口述统计与系统状态。',
pressPrefix: '按',
pressSuffix: '开始录音',
asrKind: 'ASR 语音',
llmKind: 'LLM 模型',
asrKind: '语音识别',
llmKind: '文字处理',
asrName: '火山引擎',
asrSubname: 'bigmodel',
llmName: 'OpenAI 兼容',
@@ -944,6 +983,29 @@ export const zhCN = {
startupAtBootError: '开机自启切换失败:{{message}}',
},
channels: {
llmTitle: "文字处理渠道",
asrTitle: "语音识别渠道",
current: "当前使用",
enabled: "启用",
disabled: "已停用",
enabledFor: "启用 {{name}}",
modelNotSet: "模型未单独设置",
localModelManaged: "模型由系统或「本地模型」页管理",
lastCheck: "上次验证",
verifying: "正在验证…",
notVerified: "尚未验证",
passed: "验证通过",
failed: "验证失败 · {{reason}}",
elapsed: "耗时 {{ms}} ms",
staleResult: "结果已超过 24 小时",
connectionTitle: "服务连接",
modelTitle: "模型设置",
availableModels: "可用模型",
validationTitle: "连接验证",
validationHint: "手动发起一次真实请求,检查当前配置;可能消耗服务额度。保存设置不会自动验证。",
autoSaveHint: "字段修改后自动保存;完成配置后,可手动验证连接。",
nameHint: "名称仅用于区分同一供应商的多个渠道,不影响模型或连接。",
errModel: "模型",
verify: '验证',
verifyHint: '点一下真实调用一次接口,确认这张卡现在能用',
errTimeout: '超时',
@@ -951,14 +1013,14 @@ export const zhCN = {
errEndpoint: '地址',
errGeneric: '失败',
dragHint: '按住拖动可调整优先级',
orderHint: '拖动排序,最上面的优先使用;关掉的渠道会自动排到末尾。',
empty: '还没有渠道。点下面的「添加渠道」新建一个。',
orderHint: '列表中第一个启用的渠道用于请求。拖动调整顺序;停用的渠道移到末尾。',
empty: '还没有渠道。点击「添加渠道」,连接你的第一个服务。',
add: '添加渠道',
edit: '编辑',
createTitle: '添加渠道',
editTitle: '编辑渠道',
providerLabel: '供应商',
nameLabel: '名称',
nameLabel: '渠道名称(可选)',
namePlaceholder: '例如:硅基流动-主号',
create: '创建',
delete: '删除渠道',
@@ -968,7 +1030,7 @@ export const zhCN = {
minutesAgo: '{{count}} 分钟前',
hoursAgo: '{{count}} 小时前',
daysAgo: '{{count}} 天前',
localEngineModelHint: '本地引擎的模型下载与切换在「高级 → 本地模型」里。',
localEngineModelHint: '可在「AI 服务与模型 → 本地模型」中下载和切换本地模型。',
},
providers: {
localEngineNoCredentials: '本地引擎无需 API Key 与地址。',
@@ -1475,14 +1537,58 @@ export const zhCN = {
},
},
},
startup: {
loading: "正在启动 OpenLess…",
loadingDesc: "正在连接本地服务并检查兼容性。",
failed: "OpenLess 暂时无法启动",
recovery: "请重新检查。如果仍然失败,请完全退出后重开应用;升级后出现此问题时,确认已安装完整的同一版本。",
retry: "重新检查",
details: "查看错误详情",
},
modal: {
serviceViews: {
label: "服务设置分类",
llm: "语言模型",
asr: "语音识别",
omni: "多模态模型",
models: "本地模型",
connections: "连接与扩展",
},
searchPlaceholder: "查找设置分类…",
clearSearch: "清除搜索",
categoriesLabel: "设置分类",
searchResults: "查找结果",
searchCount: "找到 {{count}} 个相关分类",
noResults: "没有找到相关分类。试试“麦克风”“模型”或“主题”。",
autoSaveHint: "修改后自动保存",
descriptions: {
general: "选择麦克风、设置录音方式与文字输入,也可连接手机输入。",
shortcuts: "设置各功能的触发方式,以及选中文字后的操作。",
services: "选择语音识别与文字处理服务,管理渠道、本地模型和网络连接。",
appearance: "调整主题、页面排版和界面语言,让阅读更舒服。",
privacy: "检查系统权限与连接状态,管理历史、录音和本地数据。",
advanced: "按需配置 Less Computer、多模态与调试功能。",
about: "查看当前版本、更新渠道与自动更新设置。",
},
searchKeywords: {
general: "麦克风 录音 输入 手机 远程 局域网 PIN 胶囊 静音 自启 开机",
shortcuts: "快捷键 热键 组合键 选区 润色 语音编辑",
services: "ASR LLM API 渠道 模型 云 本地 网络 代理 市场",
appearance: "主题 深色 浅色 暗色 语言 字号 排版 布局 热力图",
privacy: "权限 麦克风 辅助功能 历史 录音 存储 隐私 导出",
advanced: "Less Computer Claude Agent 多模态 Omni 调试 日志 实验",
about: "版本 Beta 稳定 更新 升级",
},
sections: {
general: '通用',
services: '服务',
privacy: '隐私',
advanced: '高级',
appearance: "外观与语言",
shortcuts: "快捷键与选区",
general: "录音与输入",
services: "AI 服务与模型",
privacy: "权限与数据",
advanced: "实验与扩展",
personalize: '个性化',
about: '关于',
about: "关于与更新",
helpCenter: '帮助中心',
releaseNotes: '发布日志',
},
@@ -1649,8 +1755,8 @@ export const zhCN = {
sherpaModelWhisperLargeV3Desc: '开源多语通用里效果最好的 Whisper 档,质量高、体积大,适合高质量转写。',
sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8',
sherpaModelQwen3Desc: '转换后的 sherpa-onnx Qwen3-ASR 模型,支持多语言识别与更强的长上下文能力。',
modelSelectTitle: '模型下载与管理',
modelSelectDesc: '下载的模型会自动出现在上方「ASR 语音转写」的本地供应商里,选择即可使用;点击「+ 下载新模型」获取更多模型。',
modelSelectTitle: "本机模型",
modelSelectDesc: "查看下载状态、管理文件,或加载模型进行测试。",
modelSelectPlaceholder: '选择已下载的模型…',
modelSelectEmpty: '还没有已下载的模型,先到「下载与管理」下载一个。',
groupDownload: '下载与管理',
@@ -1692,15 +1798,15 @@ export const zhCN = {
keep5min: '上次使用后 5 分钟(默认)',
keep30min: '上次使用后 30 分钟',
keepForever: '不释放(始终保留)',
sidebarTitle: '模型列表',
sidebarTitle: "已下载与下载中",
activePill: '当前使用',
setDefault: '设为默认',
downloading: '下载中',
startDownload: '开始下载',
downloadNewModel: '下载新模型',
downloadDialogTitle: '下载模型',
downloadDialogAlreadyHave: '该模型已下载,可直接使用',
downloadDialogDesc: '选择左侧模型后点击「开始下载」。下载完成后,到「服务 → AI 提供商 → ASR 语音转写」选择本地模型供应商即可使用。',
downloadDialogAlreadyHave: "模型文件已下载。可回到模型页加载并测试,或在「ASR 语音转写」中选择对应供应商。",
downloadDialogDesc: "查看模型大小与简介,选择后开始下载。下载完成后,在「语音识别」中选择对应的本地服务。",
detailRepo: '模型仓库',
hfDownloads: '下载量',
hfLikes: '收藏数',
@@ -1710,12 +1816,25 @@ export const zhCN = {
detailFiles: '个文件',
detailDownloaded: '已下载',
detailEmpty: '从左侧选择模型查看详情',
detailEmpty: "选择一个模型查看详情",
foundryLanguage: '语言',
foundryRuntimeSource: '运行时来源',
mirrorGithubRelease: 'GitHub Releases',
keep: '保持加载',
downloadSettingsTitle: '下载与存储设置',
downloadSettingsDesc: '镜像源 · 模型存储位置 · 内存引擎',
libraryEmptyTitle: "还没有本地模型",
libraryEmptyDesc: "下载一个语音识别模型,让音频在本机处理。已有模型却没有显示时,可重新读取目录。",
catalogTitle: "模型目录",
catalogEmpty: "当前没有可显示的模型。重新读取目录后再试。",
reloadCatalog: "重新读取",
engineLabel: "识别引擎",
sizeLabel: "模型大小",
allEngines: "全部",
backToCatalog: "返回模型目录",
detailsTitle: "模型详情",
testActivateHint: "「加载并测试」会将此模型设为当前使用,再运行内置音频测试。",
downloadProgressHint: "开始后返回模型页查看进度,也可随时取消下载。",
errorDetails: "错误详情",
},
};
+136 -17
View File
@@ -317,13 +317,52 @@ export const zhTW: typeof zhCN = {
},
},
overview: {
refresh: "重新整理狀態",
servicesTitle: "目前的語音服務",
statsTitle: "使用紀錄",
omniKind: "多模態語音",
omniName: "目前的 Omni 模型",
statusLoading: "正在讀取服務設定…",
configureProvider: "前往設定",
manageProvider: "管理服務",
recentEmptyHint: "還沒有聽寫紀錄。依照上方引導試一次,結果就會顯示在這裡。",
providerHelp: {
asr: "將語音轉成文字。",
llm: "依照你的風格整理和潤飾文字。",
omni: "由一個模型完成語音辨識和文字處理。",
},
actions: {
refresh: "重新讀取",
services: "AI 服務與模型",
general: "錄音與輸入",
shortcuts: "快捷鍵",
privacy: "權限與資料",
},
guide: {
nextStep: "下一步",
loadingTitle: "正在讀取你的設定",
loadingDesc: "請稍候,馬上顯示目前的服務和下一步操作。",
unavailableTitle: "暫時無法讀取服務狀態",
unavailableDesc: "重新讀取,或前往 AI 服務檢查設定。",
servicesTitle: "先設定語音服務",
servicesDesc: "建議從這裡開始:選擇語音辨識和文字處理服務;使用 Omni 時,只需設定目前的多模態模型。",
permissionsTitle: "先檢查快捷鍵狀態",
permissionsDesc: "目前無法使用快捷鍵介面。請開啟權限與資料,查看狀態和可用的處理方式。",
shortcutsTitle: "設定一個錄音快捷鍵",
shortcutsDesc: "選擇順手的快捷鍵,之後就能在輸入時開始聽寫。",
recordingTitle: "確認你的錄音方式",
recordingDesc: "服務設定已儲存。開啟錄音設定,選擇麥克風和適合你的錄音方式。",
tryDictationTitle: "試一次聽寫",
tryDictationDesc: "將游標放到要輸入的位置。{{shortcut}}",
permissionsHint: "錄音或快捷鍵沒有反應?在「權限與資料」中查看權限、麥克風和快捷鍵狀態。",
},
kicker: 'DASHBOARD',
title: '今日概覽',
desc: '今日口述統計與系統狀態。',
pressPrefix: '按',
pressSuffix: '開始錄音',
asrKind: 'ASR 語音',
llmKind: 'LLM 模型',
asrKind: '語音辨識',
llmKind: '文字處理',
asrName: '火山引擎',
asrSubname: 'bigmodel',
llmName: 'OpenAI 兼容',
@@ -946,6 +985,29 @@ export const zhTW: typeof zhCN = {
startupAtBootError: '開機自啓切換失敗:{{message}}',
},
channels: {
llmTitle: "文字處理渠道",
asrTitle: "語音辨識渠道",
current: "目前使用",
enabled: "啟用",
disabled: "已停用",
enabledFor: "啟用 {{name}}",
modelNotSet: "未單獨設定模型",
localModelManaged: "模型由系統或「本地模型」頁管理",
lastCheck: "上次驗證",
verifying: "正在驗證…",
notVerified: "尚未驗證",
passed: "驗證通過",
failed: "驗證失敗 · {{reason}}",
elapsed: "耗時 {{ms}} ms",
staleResult: "結果已超過 24 小時",
connectionTitle: "服務連線",
modelTitle: "模型設定",
availableModels: "可用模型",
validationTitle: "連線驗證",
validationHint: "手動發出一次實際請求,檢查目前設定;可能消耗服務額度。儲存設定不會自動驗證。",
autoSaveHint: "修改欄位後會自動儲存;完成設定後,可手動驗證連線。",
nameHint: "名稱僅用於區分同一供應商的多個渠道,不影響模型或連線。",
errModel: "模型",
verify: '驗證',
verifyHint: '點一下會真實呼叫一次介面,確認這張卡現在可用',
errTimeout: '逾時',
@@ -953,14 +1015,14 @@ export const zhTW: typeof zhCN = {
errEndpoint: '網址',
errGeneric: '失敗',
dragHint: '按住拖曳可調整優先順序',
orderHint: '拖曳排序,最上面的優先使用;關掉的渠道會自動排到末尾。',
empty: '還沒有渠道。點下面的「新增渠道」建立一個。',
orderHint: '請求會使用列表中第一個啟用的渠道。拖曳可調整順序;停用的渠道會移到末尾。',
empty: '還沒有渠道。點選「新增渠道」,連接你的第一個服務。',
add: '新增渠道',
edit: '編輯',
createTitle: '新增渠道',
editTitle: '編輯渠道',
providerLabel: '供應商',
nameLabel: '名稱',
nameLabel: '渠道名稱(選填)',
namePlaceholder: '例如:矽基流動-主帳號',
create: '建立',
delete: '刪除渠道',
@@ -970,7 +1032,7 @@ export const zhTW: typeof zhCN = {
minutesAgo: '{{count}} 分鐘前',
hoursAgo: '{{count}} 小時前',
daysAgo: '{{count}} 天前',
localEngineModelHint: '本機引擎的模型下載與切換在「進階 → 本機模型」裡。',
localEngineModelHint: '可在「AI 服務與模型 → 本地模型」中下載和切換本地模型。',
},
providers: {
localEngineNoCredentials: '本機引擎不需要 API Key 與網址。',
@@ -1443,14 +1505,58 @@ export const zhTW: typeof zhCN = {
},
},
},
startup: {
loading: "正在啟動 OpenLess…",
loadingDesc: "正在連接本機服務並檢查相容性。",
failed: "OpenLess 暫時無法啟動",
recovery: "請重新檢查。如果仍然失敗,請完全結束後重開應用程式;升級後出現此問題時,確認已安裝完整的同一版本。",
retry: "重新檢查",
details: "查看錯誤詳情",
},
modal: {
serviceViews: {
label: "服務設定分類",
llm: "語言模型",
asr: "語音辨識",
omni: "多模態模型",
models: "本機模型",
connections: "連線與擴充",
},
searchPlaceholder: "尋找設定分類…",
clearSearch: "清除搜尋",
categoriesLabel: "設定分類",
searchResults: "搜尋結果",
searchCount: "找到 {{count}} 個相關分類",
noResults: "找不到相關分類。試試「麥克風」「模型」或「主題」。",
autoSaveHint: "修改後自動儲存",
descriptions: {
general: "選擇麥克風、設定錄音方式與文字輸入,也可連接手機輸入。",
shortcuts: "設定各功能的觸發方式,以及選取文字後的操作。",
services: "選擇語音辨識與文字處理服務,管理管道、本機模型和網路連線。",
appearance: "調整主題、頁面排版和介面語言,讓閱讀更舒服。",
privacy: "檢查系統權限與連線狀態,管理歷史、錄音和本機資料。",
advanced: "按需設定 Less Computer、多模態與除錯功能。",
about: "查看目前版本、更新管道與自動更新設定。",
},
searchKeywords: {
general: "麥克風 錄音 輸入 手機 遠端 區域網路 PIN 膠囊 靜音 開機",
shortcuts: "快捷鍵 熱鍵 組合鍵 選取 潤飾 語音編輯",
services: "ASR LLM API 管道 模型 雲端 本機 網路 代理 市場",
appearance: "主題 深色 淺色 暗色 語言 字體 排版 版面 熱圖",
privacy: "權限 麥克風 輔助功能 歷史 錄音 儲存 隱私 匯出",
advanced: "Less Computer Claude Agent 多模態 Omni 除錯 日誌 實驗",
about: "版本 Beta 穩定 更新 升級",
},
sections: {
general: '通用',
services: '服務',
privacy: '隱私',
advanced: '高級',
appearance: "外觀與語言",
shortcuts: "快捷鍵與選取文字",
general: "錄音與輸入",
services: "AI 服務與模型",
privacy: "權限與資料",
advanced: "實驗與擴充",
personalize: '個性化',
about: '關於',
about: "關於與更新",
helpCenter: '幫助中心',
releaseNotes: '發佈日誌',
},
@@ -1617,8 +1723,8 @@ export const zhTW: typeof zhCN = {
sherpaModelWhisperLargeV3Desc: '開源多語通用中效果最好的 Whisper 檔,品質高、體積大,適合高品質轉寫。',
sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8',
sherpaModelQwen3Desc: '轉換後的 sherpa-onnx Qwen3-ASR 模型,支援多語言識別與更強的長上下文能力。',
modelSelectTitle: '模型下載與管理',
modelSelectDesc: '下載的模型會自動出現在上方「ASR 語音轉寫」的本地供應商裡,選擇即可使用;點擊「+ 下載新模型」取得更多模型。',
modelSelectTitle: "本機模型",
modelSelectDesc: "查看下載狀態、管理檔案,或載入模型進行測試。",
modelSelectPlaceholder: '選擇已下載的模型…',
modelSelectEmpty: '還沒有已下載的模型,先到「下載與管理」下載一個。',
groupDownload: '下載與管理',
@@ -1660,15 +1766,15 @@ export const zhTW: typeof zhCN = {
keep5min: '上次使用後 5 分鐘(默認)',
keep30min: '上次使用後 30 分鐘',
keepForever: '不釋放(始終保留)',
sidebarTitle: '模型列表',
sidebarTitle: "已下載與下載中",
activePill: '目前使用',
setDefault: '設為預設',
downloading: '下載中',
startDownload: '開始下載',
downloadNewModel: '下載新模型',
downloadDialogTitle: '下載模型',
downloadDialogAlreadyHave: '該模型已下載,可直接使用',
downloadDialogDesc: '選擇左側模型後點擊「開始下載」。下載完成後,到「服務 → AI 提供商 → ASR 語音轉寫」選擇本地模型供應商即可使用。',
downloadDialogAlreadyHave: "模型檔案已下載。可返回模型頁載入並測試,或在「ASR 語音轉寫」中選擇對應供應商。",
downloadDialogDesc: "查看模型大小與簡介,選擇後開始下載。下載完成後,在「語音辨識」中選擇對應的本機服務。",
detailRepo: '模型倉庫',
hfDownloads: '下載量',
hfLikes: '收藏數',
@@ -1678,12 +1784,25 @@ export const zhTW: typeof zhCN = {
detailFiles: '個檔案',
detailDownloaded: '已下載',
detailEmpty: '從左側選擇模型查看詳情',
detailEmpty: "選擇一個模型查看詳情",
foundryLanguage: '語言',
foundryRuntimeSource: '執行時來源',
mirrorGithubRelease: 'GitHub Releases',
keep: '保持載入',
downloadSettingsTitle: '下載與儲存設定',
downloadSettingsDesc: '鏡像源 · 模型儲存位置 · 記憶體引擎',
libraryEmptyTitle: "還沒有本機模型",
libraryEmptyDesc: "下載一個語音辨識模型,讓音訊在本機處理。已有模型卻未顯示時,可重新讀取目錄。",
catalogTitle: "模型目錄",
catalogEmpty: "目前沒有可顯示的模型。請重新讀取目錄後再試。",
reloadCatalog: "重新讀取",
engineLabel: "辨識引擎",
sizeLabel: "模型大小",
allEngines: "全部",
backToCatalog: "返回模型目錄",
detailsTitle: "模型詳情",
testActivateHint: "「載入並測試」會將此模型設為目前使用,再執行內建音訊測試。",
downloadProgressHint: "開始後返回模型頁查看進度,也可隨時取消下載。",
errorDetails: "錯誤詳情",
},
};
@@ -0,0 +1,450 @@
{
"_generatedFrom": "openless-core::provider_rules::provider_descriptors",
"asr": [
{
"authRequirement": "volcengine",
"defaultEndpoint": null,
"defaultModel": null,
"kind": "asr",
"labelKey": "asrVolcengine",
"providerType": "volcengine",
"staticModels": [],
"validationProbe": "asr_silence_allows_no_final"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://api.elevenlabs.io/v1",
"defaultModel": "scribe_v2",
"kind": "asr",
"labelKey": "asrElevenLabs",
"providerType": "elevenlabs",
"staticModels": [
"scribe_v2"
],
"validationProbe": "asr_silence"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "wss://dashscope.aliyuncs.com/api-ws/v1/inference/",
"defaultModel": "fun-asr-realtime",
"kind": "asr",
"labelKey": "asrBailian",
"providerType": "bailian",
"staticModels": [
"fun-asr-realtime",
"fun-asr-flash-8k-realtime",
"qwen3-asr-flash-realtime",
"qwen3-asr-flash-realtime-2026-02-10",
"qwen3-asr-flash-realtime-2025-10-27",
"qwen-audio-3.0-asr-flash",
"fun-asr-flash-2026-06-15",
"qwen3-asr-flash",
"fun-asr",
"fun-asr-2025-11-07",
"fun-asr-2025-08-25",
"fun-asr-mtl",
"fun-asr-mtl-2025-08-25",
"paraformer-v2"
],
"validationProbe": "asr_silence"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "wss://dashscope.aliyuncs.com/api-ws/v1/realtime",
"defaultModel": "qwen3-asr-flash-realtime",
"kind": "asr",
"labelKey": "asrBailianQwen3",
"providerType": "bailian-qwen3-realtime",
"staticModels": [
"qwen3-asr-flash-realtime",
"qwen3-asr-flash-realtime-2026-02-10",
"qwen3-asr-flash-realtime-2025-10-27"
],
"validationProbe": "asr_silence"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
"defaultModel": "fun-asr-flash-2026-06-15",
"kind": "asr",
"labelKey": "asrBailianFunAsrFlash",
"providerType": "bailian-fun-asr-flash",
"staticModels": [
"qwen-audio-3.0-asr-flash",
"fun-asr-flash-2026-06-15"
],
"validationProbe": "asr_non_silent"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://api.siliconflow.cn/v1",
"defaultModel": "FunAudioLLM/SenseVoiceSmall",
"kind": "asr",
"labelKey": "asrSiliconflow",
"providerType": "siliconflow",
"staticModels": [],
"validationProbe": "asr_silence"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://api.stepfun.com/v1",
"defaultModel": "stepaudio-2.5-asr",
"kind": "asr",
"labelKey": "asrStepfun",
"providerType": "stepfun",
"staticModels": [],
"validationProbe": "stepfun_no_speech"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://open.bigmodel.cn/api/paas/v4",
"defaultModel": "glm-asr-2512",
"kind": "asr",
"labelKey": "asrZhipu",
"providerType": "zhipu",
"staticModels": [],
"validationProbe": "asr_silence"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://api.groq.com/openai/v1",
"defaultModel": "whisper-large-v3-turbo",
"kind": "asr",
"labelKey": "asrGroq",
"providerType": "groq",
"staticModels": [],
"validationProbe": "asr_silence"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://api.openai.com/v1",
"defaultModel": "whisper-1",
"kind": "asr",
"labelKey": "asrWhisper",
"providerType": "whisper",
"staticModels": [],
"validationProbe": "asr_silence"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://openrouter.ai/api/v1",
"defaultModel": "openai/whisper-large-v3-turbo",
"kind": "asr",
"labelKey": "asrOpenrouter",
"providerType": "openrouter",
"staticModels": [],
"validationProbe": "asr_silence"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://zenmux.ai/api/v1",
"defaultModel": "qwen/qwen3-asr-flash",
"kind": "asr",
"labelKey": "asrZenmux",
"providerType": "zenmux",
"staticModels": [],
"validationProbe": "asr_silence"
},
{
"authRequirement": "endpoint_model_optional_api_key",
"defaultEndpoint": null,
"defaultModel": null,
"kind": "asr",
"labelKey": "asrOpenAiCompatible",
"providerType": "openai-compatible",
"staticModels": [],
"validationProbe": "asr_silence"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://api.xiaomimimo.com/v1",
"defaultModel": "mimo-v2.5-asr",
"kind": "asr",
"labelKey": "asrXiaomiMimo",
"providerType": "xiaomi-mimo-asr",
"staticModels": [
"mimo-v2.5-asr"
],
"validationProbe": "asr_silence"
},
{
"authRequirement": "xfyun",
"defaultEndpoint": null,
"defaultModel": null,
"kind": "asr",
"labelKey": "asrIflytek",
"providerType": "iflytek",
"staticModels": [],
"validationProbe": "asr_silence_allows_no_final"
},
{
"authRequirement": "none",
"defaultEndpoint": null,
"defaultModel": null,
"kind": "asr",
"labelKey": "asrFoundryLocalWhisper",
"providerType": "foundry-local-whisper",
"staticModels": [],
"validationProbe": "unsupported"
},
{
"authRequirement": "none",
"defaultEndpoint": null,
"defaultModel": null,
"kind": "asr",
"labelKey": "asrLocalWhisper",
"providerType": "local-whisper",
"staticModels": [],
"validationProbe": "unsupported"
},
{
"authRequirement": "none",
"defaultEndpoint": null,
"defaultModel": null,
"kind": "asr",
"labelKey": "asrSherpaOnnxLocal",
"providerType": "sherpa-onnx-local",
"staticModels": [],
"validationProbe": "unsupported"
},
{
"authRequirement": "none",
"defaultEndpoint": null,
"defaultModel": null,
"kind": "asr",
"labelKey": "asrLocalQwen3Mlx",
"providerType": "local-qwen3-mlx",
"staticModels": [],
"validationProbe": "unsupported"
},
{
"authRequirement": "none",
"defaultEndpoint": null,
"defaultModel": null,
"kind": "asr",
"labelKey": "asrLocalQwen3C",
"providerType": "local-qwen3-c",
"staticModels": [],
"validationProbe": "unsupported"
},
{
"authRequirement": "none",
"defaultEndpoint": null,
"defaultModel": null,
"kind": "asr",
"labelKey": "asrLocalQwen3",
"providerType": "local-qwen3",
"staticModels": [],
"validationProbe": "unsupported"
},
{
"authRequirement": "none",
"defaultEndpoint": null,
"defaultModel": null,
"kind": "asr",
"labelKey": "asrAppleSpeech",
"providerType": "apple-speech",
"staticModels": [],
"validationProbe": "unsupported"
}
],
"llm": [
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://ark.cn-beijing.volces.com/api/v3",
"defaultModel": "deepseek-v3-2",
"kind": "llm",
"labelKey": "ark",
"providerType": "ark",
"staticModels": [],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://api.deepseek.com/v1",
"defaultModel": "deepseek-v4-flash",
"kind": "llm",
"labelKey": "deepseek",
"providerType": "deepseek",
"staticModels": [],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://api.siliconflow.cn/v1",
"defaultModel": "Qwen/Qwen2.5-7B-Instruct",
"kind": "llm",
"labelKey": "siliconflow",
"providerType": "siliconflow",
"staticModels": [],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://api.atlascloud.ai/v1",
"defaultModel": "qwen/qwen3.5-flash",
"kind": "llm",
"labelKey": "atlascloud",
"providerType": "atlascloud",
"staticModels": [],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://api.openai.com/v1",
"defaultModel": "gpt-4o",
"kind": "llm",
"labelKey": "openai",
"providerType": "openai",
"staticModels": [],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://generativelanguage.googleapis.com/v1beta",
"defaultModel": "gemini-2.5-flash",
"kind": "llm",
"labelKey": "gemini",
"providerType": "gemini",
"staticModels": [],
"validationProbe": "llm_text"
},
{
"authRequirement": "o_auth",
"defaultEndpoint": null,
"defaultModel": "gpt-5.5",
"kind": "llm",
"labelKey": "codexOAuth",
"providerType": "codex_oauth",
"staticModels": [
"gpt-5.5",
"gpt-5.3-codex",
"gpt-5.4",
"gpt-5.5"
],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://api.xiaomimimo.com/v1",
"defaultModel": "xiaomi/mimo-v2-flash",
"kind": "llm",
"labelKey": "mimo",
"providerType": "mimo",
"staticModels": [],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://api.cometapi.com/v1",
"defaultModel": "gpt-4o",
"kind": "llm",
"labelKey": "cometapi",
"providerType": "cometapi",
"staticModels": [],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://openrouter.ai/api/v1",
"defaultModel": "qwen/qwen3-coder:free",
"kind": "llm",
"labelKey": "openrouterFree",
"providerType": "openrouterFree",
"staticModels": [],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://coding-intl.dashscope.aliyuncs.com/v1",
"defaultModel": "qwen3-coder-plus",
"kind": "llm",
"labelKey": "alibabaCoding",
"providerType": "alibabaCoding",
"staticModels": [],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://api.codingplanx.ai/v1",
"defaultModel": "gpt-5-mini",
"kind": "llm",
"labelKey": "codingPlanX",
"providerType": "codingPlanX",
"staticModels": [],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://api.minimaxi.com/v1",
"defaultModel": "MiniMax-M3",
"kind": "llm",
"labelKey": "minimax",
"providerType": "minimax",
"staticModels": [],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": "https://api.stepfun.com/v1",
"defaultModel": "step-1o-turbo-vision",
"kind": "llm",
"labelKey": "stepfun",
"providerType": "stepfun",
"staticModels": [],
"validationProbe": "llm_text"
},
{
"authRequirement": "api_key_unless_custom_endpoint",
"defaultEndpoint": null,
"defaultModel": null,
"kind": "llm",
"labelKey": "custom",
"providerType": "custom",
"staticModels": [],
"validationProbe": "llm_text"
}
],
"omni": [
{
"authRequirement": "api_key",
"defaultEndpoint": "https://api.openai.com/v1",
"defaultModel": "gpt-4o-audio-preview",
"kind": "omni",
"labelKey": "omniOpenai",
"providerType": "openai",
"staticModels": [],
"validationProbe": "omni_text"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://generativelanguage.googleapis.com/v1beta",
"defaultModel": "gemini-2.5-flash",
"kind": "omni",
"labelKey": "omniGemini",
"providerType": "gemini",
"staticModels": [],
"validationProbe": "omni_text"
},
{
"authRequirement": "api_key",
"defaultEndpoint": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"defaultModel": "qwen3-omni-flash",
"kind": "omni",
"labelKey": "omniDashscope",
"providerType": "dashscope-omni",
"staticModels": [],
"validationProbe": "omni_text"
},
{
"authRequirement": "api_key",
"defaultEndpoint": null,
"defaultModel": null,
"kind": "omni",
"labelKey": "custom",
"providerType": "custom",
"staticModels": [],
"validationProbe": "omni_text"
}
]
}
@@ -0,0 +1,15 @@
import { listProviderDescriptors, type ProviderKind } from './providers';
for (const kind of ['asr', 'llm', 'omni'] as ProviderKind[]) {
const descriptors = await listProviderDescriptors(kind);
if (!descriptors.length) throw new Error(`${kind}: browser preview must expose the Core provider catalog so its editor can load`);
const ids = new Set<string>();
for (const descriptor of descriptors) {
if (descriptor.kind !== kind || !descriptor.providerType || !descriptor.labelKey || !descriptor.authRequirement) {
throw new Error(`${kind}: incomplete provider descriptor`);
}
if (ids.has(descriptor.providerType)) throw new Error(`${kind}: duplicate provider ${descriptor.providerType}`);
ids.add(descriptor.providerType);
}
}
console.log('browser provider catalog exposes ASR, LLM and Omni editors');
+8 -2
View File
@@ -1,4 +1,4 @@
import { invokeOrMock } from './shared'
import { invokeOrMock, isTauri } from './shared'
export type ProviderKind = 'asr' | 'llm' | 'omni'
@@ -23,6 +23,12 @@ export interface ProviderDescriptor {
}
/** Core owns protocol, defaults, and credential requirements. */
export function listProviderDescriptors(kind: ProviderKind): Promise<ProviderDescriptor[]> {
export async function listProviderDescriptors(kind: ProviderKind): Promise<ProviderDescriptor[]> {
if (!isTauri) {
// Generated by Core; keep browser previews usable without duplicating
// provider defaults or authentication rules in handwritten frontend data.
const { default: catalog } = await import('./provider-descriptors.generated.json')
return catalog[kind] as ProviderDescriptor[]
}
return invokeOrMock('list_provider_descriptors', { kind }, () => [])
}
@@ -0,0 +1,65 @@
import { getOverviewSetup } from './overviewSetup';
import type { CredentialsStatus } from './types';
function assert(condition: boolean, message: string) {
if (!condition) throw new Error(message);
}
const configured: CredentialsStatus = {
activeAsrProvider: 'local-whisper',
activeLlmProvider: 'deepseek',
pipelineMode: 'traditional',
asrConfigured: true,
llmConfigured: true,
omniConfigured: false,
volcengineConfigured: false,
arkConfigured: false,
};
const input = {
credentials: configured,
loading: false,
error: false,
desktop: true,
hotkeyAvailable: true,
hasShortcut: true,
};
// No service recommendation or success badge may be derived from an unfinished read.
for (const credentials of [null, configured]) {
const loading = getOverviewSetup({ ...input, credentials, loading: true });
assert(loading.step === 'loading' && loading.providers.length === 0, 'loading must hide stale readiness');
}
const failed = getOverviewSetup({ ...input, error: true });
assert(failed.step === 'unavailable' && failed.action === 'refresh', 'failed reads must offer retry');
assert(failed.providers.length === 0, 'a failed read must not present cached providers as current');
assert(getOverviewSetup({ ...input, credentials: null }).action === 'refresh', 'missing status is unknown, not unconfigured');
const traditional = getOverviewSetup(input);
assert(traditional.step === 'tryDictation', 'active local ASR must not depend on Volcengine credentials');
assert(traditional.providers[0].id === 'local-whisper' && traditional.providers[0].configured, 'preserve the active local model provider');
assert(traditional.providers[1].id === 'deepseek', 'preserve the actual LLM provider');
for (const missing of ['asrConfigured', 'llmConfigured'] as const) {
const setup = getOverviewSetup({ ...input, credentials: { ...configured, [missing]: false } });
assert(setup.step === 'services' && setup.action === 'services', 'an incomplete traditional pipeline must lead to services');
}
const omni = getOverviewSetup({
...input,
credentials: { ...configured, pipelineMode: 'multimodal', asrConfigured: false, llmConfigured: false, omniConfigured: true },
omniProvider: 'dashscope-omni',
});
assert(omni.step === 'tryDictation', 'Omni must not require inactive ASR/LLM credentials');
assert(omni.providers.length === 1 && omni.providers[0].kind === 'omni', 'Omni mode must show only its active pipeline');
assert(omni.providers[0].id === 'dashscope-omni' && omni.providers[0].configured, 'Omni must keep its selected provider and status');
const missingOmni = getOverviewSetup({ ...input, credentials: { ...configured, pipelineMode: 'multimodal' } });
assert(missingOmni.action === 'services', 'configured traditional providers cannot satisfy a missing Omni model');
assert(missingOmni.providers[0].id === null, 'an unknown Omni provider must not be invented');
assert(getOverviewSetup({ ...input, hotkeyAvailable: false }).action === 'privacy', 'an unavailable hotkey adapter must lead to diagnostics');
assert(getOverviewSetup({ ...input, hasShortcut: false }).action === 'shortcuts', 'a missing recording shortcut must lead to shortcuts');
assert(getOverviewSetup({ ...input, hotkeyAvailable: null }).action === 'general', 'unknown hotkey capability must not promise a working shortcut');
assert(getOverviewSetup({ ...input, hotkeyAvailable: null, hasShortcut: false }).action === 'general', 'unread preferences must not be mistaken for a missing shortcut');
const phone = getOverviewSetup({ ...input, desktop: false, hotkeyAvailable: false, hasShortcut: false });
assert(phone.step === 'recording' && phone.action === 'general', 'mobile must not be directed to desktop shortcuts');
console.log('overview setup behavior tests passed');
+54
View File
@@ -0,0 +1,54 @@
import { areProvidersConfigured } from './providerSetup';
import type { CredentialsStatus } from './types';
export type OverviewSettingsSection = 'general' | 'services' | 'privacy' | 'shortcuts';
type SetupStep = 'loading' | 'unavailable' | 'services' | 'permissions' | 'shortcuts' | 'recording' | 'tryDictation';
interface OverviewProvider {
kind: 'asr' | 'llm' | 'omni';
id: string | null;
configured: boolean;
}
interface OverviewSetup {
step: SetupStep;
action: OverviewSettingsSection | 'refresh' | null;
providers: OverviewProvider[];
}
/** Configuration is not a connectivity test or a permission check. Keep those
* diagnostics in the existing settings pages instead of inferring success here. */
export function getOverviewSetup({
credentials,
loading,
error,
omniProvider,
desktop,
hotkeyAvailable,
hasShortcut,
}: {
credentials: CredentialsStatus | null;
loading: boolean;
error: boolean;
omniProvider?: string | null;
desktop: boolean;
hotkeyAvailable: boolean | null;
hasShortcut: boolean;
}): OverviewSetup {
if (loading) return { step: 'loading', action: null, providers: [] };
if (error || !credentials) return { step: 'unavailable', action: 'refresh', providers: [] };
const providers: OverviewProvider[] = credentials.pipelineMode === 'multimodal'
? [{ kind: 'omni', id: omniProvider || null, configured: credentials.omniConfigured === true }]
: [
{ kind: 'asr', id: credentials.activeAsrProvider, configured: credentials.asrConfigured ?? credentials.volcengineConfigured },
{ kind: 'llm', id: credentials.activeLlmProvider, configured: credentials.llmConfigured ?? credentials.arkConfigured },
];
if (!areProvidersConfigured(credentials)) return { step: 'services', action: 'services', providers };
if (!desktop) return { step: 'recording', action: 'general', providers };
if (hotkeyAvailable === false) return { step: 'permissions', action: 'privacy', providers };
if (hotkeyAvailable === null) return { step: 'recording', action: 'general', providers };
if (!hasShortcut) return { step: 'shortcuts', action: 'shortcuts', providers };
return { step: 'tryDictation', action: 'general', providers };
}
+8 -2
View File
@@ -96,8 +96,14 @@ export function History() {
});
}, []);
const { prefs } = useHotkeySettings();
const mobile = useMobileLayout();
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
// The list/detail split needs space after the main sidebar and page padding.
const mobile = useMobileLayout(1000);
const [mobileDetailOpen, setMobileDetailOpen] = useState(() => !mobile);
// A wide layout already shows the detail. Keep that same subtree mounted
// when shrinking, including its in-flight or completed repolish result.
useEffect(() => {
if (!mobile) setMobileDetailOpen(true);
}, [mobile]);
// 风格包在本页有两个用途:给历史条目显示包名、给「重新润色」面板选风格。加载提到这里
// 一次拿全,两处共用,省掉切换条目时 RepolishPanel 重挂载带来的重复 IPC。
// 注意这里存的是**全部**包(含已禁用):历史条目可能出自后来被禁用的包,显示名字要能查到;
File diff suppressed because it is too large. Load diff
+86 -69
View File
@@ -18,6 +18,7 @@ import {
import { useTranslation } from "react-i18next"
import { restartApp } from "../../lib/ipc/permissions"
import { isTauri } from "../../lib/ipc"
import { useLayoutStack } from "../../lib/useMobileLayout"
import {
FOUNDRY_LOCAL_ASR_MODELS,
SHERPA_ONNX_ASR_MODELS,
@@ -82,6 +83,7 @@ import { useHotkeySettings } from "../../state/HotkeySettingsContext"
import { detectOS } from "../../components/WindowChrome"
import { getPlatformCapabilities } from "../../lib/platform"
import { SelectLite } from "../../components/ui/SelectLite"
import { Icon } from "../../components/Icon"
import { Btn, Card, Collapsible, PageHeader, Pill } from "../_atoms"
import {
formatBytes,
@@ -169,6 +171,7 @@ type RefreshGuard = () => boolean
export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
const { t } = useTranslation()
const stackLayout = useLayoutStack(1000)
const { prefs, updatePrefs } = useHotkeySettings()
const [settings, setSettings] = useState<LocalAsrSettings | null>(null)
// 等待 native capability 查询完成,避免 Intel Mac 先闪现 MLX 渠道。
@@ -193,6 +196,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
const [error, setError] = useState<string | null>(null)
const [busyModelId, setBusyModelId] = useState<string | null>(null)
const [storageBusy, setStorageBusy] = useState(false)
const [catalogRefreshing, setCatalogRefreshing] = useState(false)
const [foundryStatus, setFoundryStatus] =
useState<FoundryLocalAsrStatus | null>(null)
const [foundryCatalog, setFoundryCatalog] = useState<
@@ -244,6 +248,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
>({})
const [engineStatus, setEngineStatus] =
useState<LocalAsrEngineStatus | null>(null)
const catalogReloadRequestedRef = useRef(false)
const downloadDialogOpenRef = useRef(downloadDialogOpen)
const refreshGenerationRef = useRef(0)
const refreshTimer = useRef<number | null>(null)
@@ -1765,10 +1770,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
progress[m.id]?.phase === "progress")
entries.push({
id: m.id,
name: m.id,
name: m.id.replace(/^qwen3-asr-/, "Qwen3-ASR ").replace(/^whisper-/, "Whisper "),
repo: m.hfRepo,
remoteBytes:
remoteSizes[m.id]?.totalBytes || m.downloadedBytes || undefined,
remoteSizes[m.id]?.totalBytes || (m.isDownloaded ? m.downloadedBytes : undefined),
isDownloaded: m.isDownloaded,
isDownloading,
percent: isDownloading
@@ -1776,7 +1781,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
? (progress[m.id]!.bytesDownloaded /
progress[m.id]!.bytesTotal) *
100
: 0
: null
: null,
isActive:
settings?.activeModel === m.id &&
@@ -1788,6 +1793,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
"local-qwen3-c",
].includes(prefs?.activeAsrProvider ?? "")),
engine: isWhisper ? "whisper" : "qwen3",
downloadError: progress[m.id]?.phase === "failed" ? progress[m.id]?.error || t("localAsr.failed") : null,
})
}
// Windows:sherpa-onnx + foundry
@@ -1810,12 +1816,13 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
? (sherpaDownloadProgress[c.alias]!.bytesDownloaded /
sherpaDownloadProgress[c.alias]!.bytesTotal) *
100
: 0
: null
: null,
isActive:
sherpaStatus?.activeModel === c.alias &&
prefs?.activeAsrProvider === "sherpa-onnx-local",
engine: "sherpa",
downloadError: sherpaDownloadProgress[c.alias]?.phase === "failed" ? sherpaDownloadProgress[c.alias]?.error || t("localAsr.failed") : null,
})
}
for (const c of foundryCatalog) {
@@ -1857,6 +1864,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
foundryCatalog,
foundryProgress,
foundryStatus?.activeModel,
t,
])
// 看板只展示已下载 / 下载中的模型(下载中必须有实时进度可见)。
@@ -1876,7 +1884,8 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
allSidebarEntries.find((e) => !e.isDownloaded) ??
allSidebarEntries[0] ??
null
setSelectedModelId(fallback?.id ?? null)
const nextSelectedId = fallback?.id ?? null
if (nextSelectedId !== selectedModelId) setSelectedModelId(nextSelectedId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [downloadDialogOpen, allSidebarEntries, selectedModelId])
@@ -1895,7 +1904,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
sidebarEntries.some((e) => e.id === selectedModelId)
if (stillExists) return
const firstDownloaded = sidebarEntries.find((e) => e.isDownloaded)
setSelectedModelId(firstDownloaded?.id ?? sidebarEntries[0]?.id ?? null)
const nextSelectedId = firstDownloaded?.id ?? sidebarEntries[0]?.id ?? null
// Empty catalogs and metadata refreshes can leave the same selection.
// Do not schedule a layout-phase update when nothing has changed.
if (nextSelectedId !== selectedModelId) setSelectedModelId(nextSelectedId)
}, [sidebarEntries, selectedModelId, downloadDialogOpen])
// 从侧栏/看板分派引擎动作。不再有 setActive——激活 = 在 ASR 语音转写里
@@ -1960,12 +1972,24 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
: selectedEntry?.engine === "sherpa"
? sherpaDownloadProgress[selectedEntry.id]
: undefined
const selectedEntryPercent =
selectedEntryProgress && selectedEntryProgress.bytesTotal > 0
? (selectedEntryProgress.bytesDownloaded /
selectedEntryProgress.bytesTotal) *
100
: null
const reloadModels = async () => {
setCatalogRefreshing(true)
try {
await refresh()
} finally {
setCatalogRefreshing(false)
}
}
useEffect(() => {
if (downloadDialogOpen || !catalogReloadRequestedRef.current) return
// Run after the dialog poller's cleanup advances the refresh generation.
catalogReloadRequestedRef.current = false
void reloadModels()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [downloadDialogOpen])
const modelLoadPending = (settings === null && !error) || catalogRefreshing
const downloadDisabled = modelLoadPending || busyModelId !== null || sherpaBusy !== null || anyDownloadInFlight
const visibleError = error || allSidebarEntries.find(entry => entry.downloadError)?.downloadError
return (
<LocalAsrContentWrapper embedded={embedded}>
@@ -1986,7 +2010,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
<Card
style={{
marginBottom: 16,
background: "rgba(255, 215, 130, 0.18)",
background: "var(--ol-surface-2)",
}}
>
<div
@@ -2003,24 +2027,21 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
{/* Windows 已由下方 Foundry / sherpa 卡片完成模型管理;
macOS / Linux 仍需要该看板管理 Qwen3 / Whisper 模型。 */}
{!IS_WINDOWS && <Card style={{ marginBottom: 16 }}>
<div
style={{
fontSize: 14,
fontWeight: 700,
color: "var(--ol-ink)",
marginBottom: 2,
}}
>
{t("localAsr.modelSelectTitle")}
{visibleError && (
<div className="ol-model-error" role="alert" style={{ marginBottom: 20 }}>{visibleError}</div>
)}
{!IS_WINDOWS && <section className="ol-model-manager">
{sidebarEntries.length > 0 ? <>
<div className="ol-model-manager-heading">
<div>
<h3>{t("localAsr.modelSelectTitle")}</h3>
<p>{t("localAsr.modelSelectDesc")}</p>
</div>
<Btn variant="blue" disabled={downloadDisabled} onClick={() => setDownloadDialog(true)}>
{t("localAsr.downloadNewModel")}
</Btn>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 240px) minmax(0, 1fr)",
gap: 16,
}}
>
<div className={`ol-model-workspace${stackLayout ? " is-stacked" : ""}`}>
<ModelSidebar
entries={sidebarEntries}
selectedId={selectedModelId}
@@ -2030,19 +2051,8 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
// 立刻反映到列表与详情,不等 3s 轮询。
void refresh()
}}
onOpenDownload={() => setDownloadDialog(true)}
downloadDisabled={
busyModelId !== null ||
sherpaBusy !== null ||
anyDownloadInFlight
}
/> <div
style={{
paddingLeft: 16,
borderLeft: "0.5px solid var(--ol-line)",
minWidth: 0,
}}
>
/>
<div className="ol-model-selected-panel">
<ModelDetailPanel
entry={selectedEntry}
fileCount={selectedEntryRemote?.fileCount ?? null}
@@ -2056,9 +2066,8 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
? (settings?.mirror ?? "huggingface")
: undefined
}
downloading={selectedEntry ? Boolean(selectedEntryProgress) : false}
progressPercent={selectedEntryPercent}
busy={busyModelId !== null || sherpaBusy !== null}
progress={selectedEntryProgress}
busy={busyModelId !== null || sherpaBusy !== null || testingModelId !== null}
onDownload={() =>
selectedEntry && dispatchEntryAction(selectedEntry, "download")
}
@@ -2110,24 +2119,36 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
/>
</div>
</div>
</Card>}
</> : (
<div className="ol-model-empty" aria-busy={modelLoadPending}>
<span className="ol-model-empty-icon"><Icon name="download" size={25} /></span>
<h3>{modelLoadPending ? t("common.loading") : t("localAsr.libraryEmptyTitle")}</h3>
<p className="ol-model-muted">{t("localAsr.libraryEmptyDesc")}</p>
<div className="ol-model-actions">
<Btn variant="blue" disabled={downloadDisabled} onClick={() => setDownloadDialog(true)}>{t("localAsr.downloadNewModel")}</Btn>
<Btn variant="ghost" disabled={catalogRefreshing} onClick={() => void reloadModels()}>{t("localAsr.reloadCatalog")}</Btn>
</div>
</div>
)}
</section>}
{/* ─── 收纳:下载与存储设置(镜像源 · 模型存储位置 · 内存引擎)——默认收起,
需要手动点开。日常的下载 / 管理 / 测试不依赖这些低频配置。 ─── */}
<div style={{ marginBottom: 16 }}>
<div className="ol-model-advanced">
<Collapsible
title={t("localAsr.downloadSettingsTitle")}
desc={t("localAsr.downloadSettingsDesc")}
>
{IS_QWEN_PLATFORM && (
<>
<Card style={{ marginBottom: 16 }}>
<div className="ol-model-setting-group">
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 16,
flexWrap: "wrap",
}}
>
<div>
@@ -2173,10 +2194,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
</option>
</select>
</div>
</Card>
</div>
{/* 运行时设置卡:内存中的引擎状态 + 多久释放 + 立即释放 */}
{engineAvailable && (
<Card style={{ marginBottom: 16 }}>
<div className="ol-model-setting-group">
<div
style={{
display: "flex",
@@ -2310,11 +2331,11 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
</select>
</div>
</div>
</Card>
</div>
)}
</>
)}
<Card style={{ marginBottom: 16 }}>
<div className="ol-model-setting-group">
<div
style={{
display: "flex",
@@ -2416,7 +2437,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
{t("localAsr.storageDesc")}
</div>
</div>
</Card>
</div>
</Collapsible>
</div>
{/* ─── 下载弹框:左侧模型选择 + 右侧详情,最下方开始下载。 ─── */}
@@ -2441,6 +2462,14 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
return remote?.fileCount ?? null
}}
busy={busyModelId !== null || anyDownloadInFlight}
loading={modelLoadPending}
error={error}
onRetryCatalog={() => {
// Keep the dialog refresh-generation guard: close first, then query.
catalogReloadRequestedRef.current = true
setDownloadDialog(false)
}}
onRetryCard={(id) => void ensureHfCard(id, settings?.mirror ?? "huggingface")}
hfCardOf={(id) => {
const state = hfCards[id]
if (!state) return null
@@ -2460,9 +2489,9 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
)}
{/* ─── 分组:下载与管理(各引擎的模型获取/准备/下载) ─── */}
<LocalAsrGroupTitle>
{IS_WINDOWS && <LocalAsrGroupTitle>
{t("localAsr.groupDownload")}
</LocalAsrGroupTitle>
</LocalAsrGroupTitle>}
{IS_WINDOWS && (
@@ -3286,7 +3315,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
<Card
style={{
marginBottom: 16,
background: "rgba(255, 235, 200, 0.4)",
background: "var(--ol-surface-2)",
}}
>
<div
@@ -3300,18 +3329,6 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
</Card>
)}
{error && (
<Card
style={{
marginBottom: 16,
background: "rgba(255, 220, 220, 0.5)",
}}
>
<div style={{ fontSize: 13, color: "#9b2c2c" }}>
{error}
</div>
</Card>
)}
</LocalAsrContentWrapper>
)
}
@@ -0,0 +1,108 @@
/* Local model management only. All panels have a solid theme-aware surface. */
.ol-local-model-section, .ol-model-manager { min-width: 0; }
.ol-local-model-heading, .ol-model-manager-heading { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
.ol-local-model-heading h2, .ol-model-manager-heading h3 { margin: 0; color: var(--ol-ink); font-size: 17px; font-weight: 650; }
.ol-local-model-heading p, .ol-model-manager-heading p { margin: 7px 0 0; max-width: 560px; font-size: 13px; line-height: 1.65; color: var(--ol-ink-3); }
.ol-local-model-heading { border-bottom: 1px solid var(--ol-line); padding-bottom: 20px; }
.ol-local-model-heading .ol-model-status { color: var(--ol-warn); }
.ol-model-manager { margin-bottom: 24px; container-type: inline-size; }
.ol-model-manager-heading { align-items: center; }
.ol-model-workspace { display: grid; grid-template-columns: minmax(230px, .85fr) minmax(0, 1.15fr); border: 1px solid var(--ol-line); border-radius: 12px; overflow: hidden; background: var(--ol-surface); }
.ol-model-workspace.is-stacked { grid-template-columns: minmax(0, 1fr); }
.ol-model-library { min-width: 0; padding: 16px 12px; background: var(--ol-settings-rail-bg); }
.ol-model-list-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0 4px 12px; font-size: 13px; font-weight: 600; color: var(--ol-ink-3); }
.ol-model-list-heading > :last-child { font-variant-numeric: tabular-nums; color: var(--ol-ink-4); }
.ol-model-list { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.ol-model-choice { display: flex; flex-direction: column; gap: 9px; width: 100%; min-width: 0; padding: 14px; border: 1px solid transparent; border-radius: 9px; background: var(--ol-settings-rail-bg); color: var(--ol-ink); font: inherit; text-align: left; cursor: pointer; }
.ol-model-choice:hover { background: var(--ol-surface-2); }
.ol-model-choice[aria-pressed="true"] { background: var(--ol-surface); border-color: var(--ol-blue); box-shadow: inset 3px 0 var(--ol-blue); }
.ol-model-choice:focus-visible, .ol-model-filters button:focus-visible, .ol-model-close:focus-visible, .ol-model-back:focus-visible { outline: 2px solid var(--ol-blue); outline-offset: -2px; }
.ol-model-choice-name { font-size: 14px; font-weight: 600; line-height: 1.45; overflow-wrap: anywhere; }
.ol-model-choice-meta, .ol-model-choice-state { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 6px 12px; font-size: 12px; line-height: 1.5; color: var(--ol-ink-3); }
.ol-model-choice-state { font-variant-numeric: tabular-nums; }
.ol-model-status { display: inline-flex; align-items: center; gap: 5px; max-width: 100%; padding: 3px 8px; border-radius: 5px; background: var(--ol-surface-2); color: var(--ol-ink-3); font-size: 12px; font-weight: 500; line-height: 1.5; }
.ol-model-status.is-active { color: var(--ol-blue); }
.ol-model-status.is-error { color: var(--ol-err); }
.ol-model-selected-panel { min-width: 0; padding: 24px; border-left: 1px solid var(--ol-line); }
.ol-model-workspace.is-stacked .ol-model-selected-panel { border-left: 0; border-top: 1px solid var(--ol-line); }
.ol-model-workspace.is-stacked .ol-model-library .ol-model-list { max-height: 300px; overflow-y: auto; }
.ol-model-detail { display: flex; flex-direction: column; gap: 20px; min-width: 0; height: 100%; }
.ol-model-detail-heading h3 { margin: 7px 0 12px; font-size: 22px; line-height: 1.35; letter-spacing: -.025em; font-weight: 650; color: var(--ol-ink); overflow-wrap: anywhere; }
.ol-model-eyebrow { font-size: 12px; color: var(--ol-ink-3); font-weight: 500; }
.ol-model-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 20px; margin: 0; }
.ol-model-facts dt { margin-bottom: 5px; font-size: 12px; color: var(--ol-ink-3); }
.ol-model-facts dd { margin: 0; font-size: 13px; font-weight: 500; line-height: 1.6; color: var(--ol-ink); overflow-wrap: anywhere; }
.ol-model-fact-wide { grid-column: 1 / -1; }
.ol-model-muted { margin: 0; color: var(--ol-ink-3); font-size: 13px; line-height: 1.65; overflow-wrap: anywhere; }
.ol-model-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; }
.ol-model-actions > button { max-width: 100%; height: auto !important; min-height: 34px; white-space: normal !important; overflow-wrap: anywhere; }
.ol-model-detail > .ol-model-actions { margin-top: auto; padding-top: 16px; border-top: 1px solid var(--ol-line); }
.ol-model-empty { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 38px 24px; border: 1px solid var(--ol-line); border-radius: 12px; background: var(--ol-surface); text-align: center; }
.ol-model-empty-icon { display: grid; place-items: center; width: 52px; height: 52px; border-radius: 14px; background: var(--ol-surface-2); color: var(--ol-blue); }
.ol-model-empty h3 { margin: 3px 0 0; font-size: 17px; font-weight: 600; }
.ol-model-empty p { max-width: 380px; margin-bottom: 8px; }
.ol-model-error { padding: 12px 14px; border-left: 3px solid var(--ol-err); border-radius: 4px; background: var(--ol-surface-2); color: var(--ol-err); font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
.ol-model-error p { margin: 0 0 8px; }
.ol-model-error details { margin-bottom: 10px; color: var(--ol-ink-3); }
.ol-model-error summary { cursor: pointer; }
.ol-model-progress { display: block; width: 100%; height: 6px; border: 0; border-radius: 999px; overflow: hidden; background: var(--ol-line); accent-color: var(--ol-blue); }
.ol-model-progress::-webkit-progress-bar { background: var(--ol-line); border-radius: 999px; }
.ol-model-progress::-webkit-progress-value { background: var(--ol-blue); border-radius: 999px; }
.ol-model-progress::-moz-progress-bar { background: var(--ol-blue); border-radius: 999px; }
.ol-model-download-progress { display: flex; flex-direction: column; gap: 10px; padding: 14px; border-radius: 8px; background: var(--ol-surface-2); font-size: 13px; color: var(--ol-ink-3); overflow-wrap: anywhere; }
.ol-model-download-progress header { display: flex; justify-content: space-between; gap: 12px; font-weight: 600; color: var(--ol-ink); }
.ol-model-download-progress .is-error { color: var(--ol-err); }
.ol-model-download-progress small { font-size: 12px; }
.ol-model-advanced { margin-bottom: 20px; }
.ol-model-setting-group { padding: 20px 0; border-bottom: 1px solid var(--ol-line); }
.ol-model-setting-group:last-child { padding-bottom: 0; border-bottom: 0; }
.ol-model-setting-group select { min-width: 0 !important; max-width: 100%; }
.ol-model-setting-group code { overflow-wrap: anywhere; }
.ol-model-dialog-overlay { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 24px; background: var(--ol-overlay-bg); }
.ol-model-dialog { display: flex; flex-direction: column; width: min(1000px, 100%); height: min(720px, calc(100dvh - 48px)); min-height: 0; overflow: hidden; border: 1px solid var(--ol-line-strong); border-radius: 14px; box-shadow: var(--ol-shadow-xl); background: var(--ol-surface); color: var(--ol-ink); outline: none; }
.ol-model-dialog-header { display: flex; flex-shrink: 0; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 22px 24px; border-bottom: 1px solid var(--ol-line); }
.ol-model-dialog-header h2 { margin: 0; font-size: 20px; font-weight: 650; letter-spacing: -.02em; }
.ol-model-dialog-header p { margin: 7px 0 0; max-width: 660px; color: var(--ol-ink-3); font-size: 13px; line-height: 1.6; }
.ol-model-close { display: grid; place-items: center; flex-shrink: 0; width: 32px; height: 32px; border: 0; border-radius: 8px; background: var(--ol-surface-2); color: var(--ol-ink-2); cursor: pointer; }
.ol-model-dialog-body { display: grid; grid-template-columns: minmax(260px, .85fr) minmax(0, 1.15fr); flex: 1; min-height: 0; overflow: hidden; }
.ol-model-catalog { display: flex; flex-direction: column; min-width: 0; min-height: 0; background: var(--ol-settings-rail-bg); border-right: 1px solid var(--ol-line); }
.ol-model-catalog-toolbar { padding: 18px 16px 12px; }
.ol-model-catalog-list { padding: 0 12px 16px; overflow-y: auto; min-height: 0; }
.ol-model-filters { display: flex; flex-wrap: wrap; gap: 6px; }
.ol-model-filters button { border: 1px solid var(--ol-line); border-radius: 6px; padding: 5px 9px; background: var(--ol-surface); font: inherit; font-size: 12px; color: var(--ol-ink-3); cursor: pointer; }
.ol-model-filters button[aria-pressed="true"] { border-color: var(--ol-blue); color: var(--ol-blue); font-weight: 600; }
.ol-model-catalog-detail { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.ol-model-catalog-content { display: flex; flex-direction: column; gap: 24px; min-height: 0; flex: 1; overflow-y: auto; padding: 26px; }
.ol-model-catalog-content h3:focus { outline: none; }
.ol-model-description { border-top: 1px solid var(--ol-line); padding-top: 20px; }
.ol-model-description h4 { margin: 0 0 10px; font-size: 13px; font-weight: 600; }
.ol-model-description > p { font-size: 13px; line-height: 1.7; color: var(--ol-ink-3); white-space: pre-line; overflow-wrap: anywhere; margin: 0 0 20px; }
.ol-model-dialog-footer { display: flex; flex-direction: column; gap: 12px; flex-shrink: 0; padding: 16px 24px; border-top: 1px solid var(--ol-line); background: var(--ol-surface); }
.ol-model-dialog-footer .ol-model-actions { justify-content: flex-end; }
.ol-model-back { align-self: flex-start; padding: 4px 0; border: 0; background: var(--ol-surface); color: var(--ol-blue); font: inherit; font-size: 13px; cursor: pointer; }
.ol-model-dialog.is-stacked .ol-model-dialog-body { grid-template-columns: minmax(0, 1fr); }
.ol-model-dialog.is-stacked .ol-model-catalog { border-right: 0; }
@container (max-width: 580px) {
.ol-model-workspace { grid-template-columns: minmax(0, 1fr); }
.ol-model-selected-panel { border-left: 0; border-top: 1px solid var(--ol-line); padding: 20px; }
.ol-model-library .ol-model-list { max-height: 300px; overflow-y: auto; }
}
@media (max-width: 759px) {
.ol-model-dialog-overlay { padding: 10px; }
.ol-model-dialog { height: calc(100dvh - 20px); border-radius: 12px; }
.ol-model-dialog-header { padding: 18px; gap: 10px; }
.ol-model-catalog-content { padding: 20px; }
.ol-model-dialog-footer { padding: 14px 18px; }
.ol-model-empty { padding: 28px 18px; }
}
@media (max-height: 500px) {
.ol-model-dialog-overlay { padding: 8px; }
.ol-model-dialog { height: calc(100dvh - 16px); }
.ol-model-dialog-header { padding: 12px 18px; }
.ol-model-dialog-header p { max-height: 42px; overflow-y: auto; }
}
.ol-model-catalog-empty { display: flex; flex-direction: column; align-items: flex-start; gap: 16px; padding: 24px 14px; color: var(--ol-blue); }
.ol-model-dialog-body.is-empty { grid-template-columns: minmax(0, 1fr); }
.ol-model-dialog-body.is-empty .ol-model-catalog { border-right: 0; }
.ol-model-dialog-body.is-empty .ol-model-catalog-empty { align-items: center; text-align: center; padding: 40px 24px; }
+151 -108
View File
@@ -3,11 +3,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from '../components/Icon';
import { formatComboLabel } from '../lib/hotkey';
import { getHotkeyUsageHint } from '../lib/hotkey';
import { getActivityStats, getCredentials, listHistory } from '../lib/ipc';
import { Heatmap } from '../components/Heatmap';
import { useMobileLayout } from '../lib/useMobileLayout';
import { countCodePoints } from '../lib/unicode';
import { isDesktop } from '../lib/platform';
import { getOverviewSetup, type OverviewSettingsSection } from '../lib/overviewSetup';
import {
ACTIVITY_METRICS,
ACTIVITY_PERIODS,
@@ -32,6 +34,7 @@ function useModeLabels(): Record<PolishMode, string> {
interface OverviewProps {
onOpenHistory?: () => void;
onOpenSettings?: (section: 'general' | 'services' | 'privacy' | 'shortcuts') => void;
}
// id → i18n nameKey;这里只保留展示文案,provider 行为来自 Core descriptor。
@@ -46,33 +49,30 @@ const LLM_NAME_KEY_BY_ID: Record<string, string> = {
siliconflow: 'siliconflow',
atlascloud: 'atlascloud',
openai: 'openai',
gemini: 'gemini',
codex_oauth: 'codexOAuth',
mimo: 'mimo',
cometapi: 'cometapi',
openrouterFree: 'openrouterFree',
alibabaCoding: 'alibabaCoding',
codingPlanX: 'codingPlanX',
minimax: 'minimax',
stepfun: 'stepfun',
custom: 'custom',
};
export function Overview({ onOpenHistory }: OverviewProps) {
export function Overview({ onOpenHistory, onOpenSettings }: OverviewProps) {
const { t } = useTranslation();
const mobile = useMobileLayout();
const modeLabel = useModeLabels();
const [history, setHistory] = useState<DictationSession[]>([]);
const [historyError, setHistoryError] = useState(false);
const [credsError, setCredsError] = useState(false);
const [creds, setCreds] = useState<CredentialsStatus>({
activeAsrProvider: 'volcengine',
activeLlmProvider: 'ark',
pipelineMode: 'traditional',
asrConfigured: false,
llmConfigured: false,
omniConfigured: false,
volcengineConfigured: false,
arkConfigured: false,
});
const { prefs } = useHotkeySettings();
const [credsLoading, setCredsLoading] = useState(true);
const [creds, setCreds] = useState<CredentialsStatus | null>(null);
const { prefs, capability } = useHotkeySettings();
// A narrow desktop window still uses desktop shortcuts.
const desktop = isDesktop();
const credentialsRequestSeq = useRef(0);
const historyRequestSeq = useRef(0);
const activityRequestSeq = useRef(0);
@@ -124,6 +124,7 @@ export function Overview({ onOpenHistory }: OverviewProps) {
const requestSeq = credentialsRequestSeq.current + 1;
credentialsRequestSeq.current = requestSeq;
setCredsError(false);
setCredsLoading(true);
getCredentials()
.then(status => {
if (requestSeq !== credentialsRequestSeq.current) return;
@@ -134,6 +135,9 @@ export function Overview({ onOpenHistory }: OverviewProps) {
if (requestSeq !== credentialsRequestSeq.current) return;
console.error('[overview] failed to load credentials status', error);
setCredsError(true);
})
.finally(() => {
if (requestSeq === credentialsRequestSeq.current) setCredsLoading(false);
});
}, []);
@@ -143,7 +147,7 @@ export function Overview({ onOpenHistory }: OverviewProps) {
useEffect(() => {
refreshCredentials();
}, [refreshCredentials, prefs?.activeLlmProvider, prefs?.activeAsrProvider]);
}, [refreshCredentials, prefs?.activeLlmProvider, prefs?.activeAsrProvider, prefs?.pipelineMode, prefs?.activeOmniProvider]);
// ⌘R / Ctrl+R 重新拉取本页的三份数据(历史、活动、凭据),与历史页同键同语义。
// preventDefault 拦掉 webview 默认的整页 reload,避免整个前端重挂载。
@@ -216,60 +220,94 @@ export function Overview({ onOpenHistory }: OverviewProps) {
[activity, period, metric],
);
const asrProviderId = creds.activeAsrProvider || 'volcengine';
const llmProviderId = creds.activeLlmProvider || 'ark';
const asrNameKey = ASR_NAME_KEY_BY_ID[asrProviderId];
const llmNameKey = LLM_NAME_KEY_BY_ID[llmProviderId];
const asrProviderName = asrNameKey
? t(`settings.providers.presets.${asrNameKey}`)
: asrProviderId;
const llmProviderName = llmNameKey
? t(`settings.providers.presets.${llmNameKey}`)
: llmProviderId;
const setup = getOverviewSetup({
credentials: creds,
loading: credsLoading,
error: credsError,
omniProvider: prefs?.activeOmniProvider,
desktop,
hotkeyAvailable: prefs && capability ? capability.adapter !== 'unavailable' : null,
hasShortcut: Boolean(prefs?.dictationHotkey.primary.trim()),
});
const shortcutHint = prefs && desktop && prefs.dictationHotkey.primary.trim()
? getHotkeyUsageHint(prefs.hotkey, undefined, prefs.dictationHotkey)
: null;
const openSettings = (section: OverviewSettingsSection) => onOpenSettings?.(section);
const guideDescription = setup.step === 'tryDictation'
? t('overview.guide.tryDictationDesc', { shortcut: shortcutHint })
: t(`overview.guide.${setup.step}Desc`);
return (
<>
<PageHeader title={t('overview.title')} />
// Own a natural-height column: the shell scrolls when the window is short.
<div style={{ display: 'flex', flexDirection: 'column', flex: '0 0 auto', minWidth: 0 }}>
<PageHeader
title={t('overview.title')}
right={<Btn size="sm" icon="refresh" onClick={refreshAll}>{t('overview.refresh')}</Btn>}
/>
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : '1fr 1fr', gap: 12, marginBottom: 18 }}>
<ProviderCard
kind={t('overview.asrKind')}
name={asrProviderName}
subname={asrProviderId}
status={credsError ? 'error' : creds.asrConfigured ? 'configured' : 'notConfigured'}
/>
<ProviderCard
kind={t('overview.llmKind')}
name={llmProviderName}
subname={llmProviderId}
status={credsError ? 'error' : creds.llmConfigured ? 'configured' : 'notConfigured'}
/>
<Card style={{ marginBottom: 18 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 16 }}>
<div style={{ flex: '1 1 240px', minWidth: 0 }} aria-live="polite" aria-busy={credsLoading}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ol-blue)', marginBottom: 6 }}>{t('overview.guide.nextStep')}</div>
<h2 style={{ fontSize: 18, fontWeight: 600, color: 'var(--ol-ink)', margin: 0 }}>{t(`overview.guide.${setup.step}Title`)}</h2>
<p style={{ fontSize: 13, color: 'var(--ol-ink-3)', lineHeight: 1.6, margin: '8px 0 0' }}>{guideDescription}</p>
</div>
{setup.action && (
<Btn
variant="blue"
icon={setup.action === 'refresh' ? 'refresh' : 'chevRight'}
disabled={setup.action !== 'refresh' && !onOpenSettings}
style={{ whiteSpace: 'normal', textAlign: 'start' }}
onClick={() => setup.action === 'refresh' ? refreshCredentials() : setup.action && openSettings(setup.action)}
>
{t(`overview.actions.${setup.action}`)}
</Btn>
)}
</div>
<div style={{ borderTop: '0.5px solid var(--ol-line)', marginTop: 16, paddingTop: 14, display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{setup.action !== 'general' && <Btn size="sm" icon="mic" disabled={!onOpenSettings} onClick={() => openSettings('general')}>{t('overview.actions.general')}</Btn>}
{desktop && setup.action !== 'shortcuts' && <Btn size="sm" icon="cmd" disabled={!onOpenSettings} onClick={() => openSettings('shortcuts')}>{t('overview.actions.shortcuts')}</Btn>}
{setup.action !== 'privacy' && <Btn size="sm" icon="shield" disabled={!onOpenSettings} onClick={() => openSettings('privacy')}>{t('overview.actions.privacy')}</Btn>}
</div>
<p style={{ margin: '10px 0 0', color: 'var(--ol-ink-4)', fontSize: 11.5, lineHeight: 1.5 }}>{t('overview.guide.permissionsHint')}</p>
</Card>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8, marginBottom: 10 }}>
<h2 style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-2)', margin: 0 }}>{t('overview.servicesTitle')}</h2>
<Btn size="sm" variant="soft" disabled={!onOpenSettings} onClick={() => openSettings('services')}>{t('overview.actions.services')}</Btn>
</div>
<div style={{ display: 'grid', gridTemplateColumns: mobile || setup.providers.length < 2 ? 'minmax(0, 1fr)' : 'repeat(2, minmax(0, 1fr))', gap: 12, marginBottom: 18 }}>
{setup.providers.map(provider => {
const nameKey = provider.id && (provider.kind === 'asr' ? ASR_NAME_KEY_BY_ID : LLM_NAME_KEY_BY_ID)[provider.id];
const name = nameKey ? t(`settings.providers.presets.${nameKey}`) : provider.id || t(provider.kind === 'omni' ? 'overview.omniName' : 'overview.statusUnknown');
return (
<ProviderCard
key={provider.kind}
kind={provider.kind}
name={name}
status={provider.configured ? 'configured' : 'notConfigured'}
onConfigure={onOpenSettings ? () => openSettings('services') : undefined}
/>
);
})}
{setup.providers.length === 0 && (
<Card padding={16}>
<div role="status" style={{ fontSize: 12, color: 'var(--ol-ink-3)' }}>{t(credsLoading ? 'overview.statusLoading' : 'overview.credentialsLoadError')}</div>
</Card>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: mobile ? 'repeat(2, 1fr)' : 'repeat(4, 1fr)', gap: 12, marginBottom: 18 }}>
<h2 style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-3)', margin: '0 0 10px' }}>{t('overview.statsTitle')}</h2>
<div style={{ display: 'grid', gridTemplateColumns: mobile ? 'repeat(2, minmax(0, 1fr))' : 'repeat(4, minmax(0, 1fr))', gap: 12, marginBottom: 18 }}>
<Metric icon="hash" label={t('overview.metricChars')} value={historyError ? '—' : metrics.charsToday.toLocaleString()} trend={historyError ? t('overview.historyLoadError') : t('overview.metricSegments', { count: metrics.segmentsToday })} />
<Metric icon="mic" label={t('overview.metricDuration')} value={historyError ? '—' : formatDuration(metrics.totalDurationMs, t)} trend={historyError ? t('overview.historyLoadError') : ''} />
<Metric icon="clock" label={t('overview.metricAvg')} value={historyError ? '—' : formatDuration(metrics.avgLatencyMs, t)} trend={historyError ? t('overview.historyLoadError') : metrics.segmentsToday > 0 ? t('overview.metricAvgTrend') : t('overview.metricNoData')} />
<Metric icon="bolt" label={t('overview.metricTotal')} value={historyError ? '—' : String(history.length)} trend={historyError ? t('overview.historyLoadError') : t('overview.metricTotalTrend')} accent />
<Metric icon="bolt" label={t('overview.metricTotal')} value={historyError ? '—' : String(history.length)} trend={historyError ? t('overview.historyLoadError') : t('overview.metricTotalTrend')} />
</div>
{/* 年度活动热力图(8starlabs Heatmap 规格):过去一年每日听写次数。
数据来自独立的 activity 计数存储,与历史保留策略解耦;
可在设置 → 通用 → 外观 里单独关闭。
移动端(useMobileLayout)不渲染,避免窄屏横向溢出(issue #861)。 */}
{!mobile &&
prefs?.showOverviewActivityHeatmap !== false &&
activity &&
activity.length > 0 && (
<ActivityHeatmapCard activity={activity} />
)}
{/* 底部一行 = flex:1 撑满剩余高度(父 wrapper 是 display:flex/column)。
只有「最近识别」内部允许滚动;其他卡片按内容自然高度,不破裂底部圆角。
issue #243 follow-up:去掉外层 overflow 后底部圆角被裁的视觉问题。 */}
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : '1fr 1.4fr', gap: 12, flex: mobile ? undefined : 1, minHeight: mobile ? undefined : 0 }}>
{/* overflow:hidden:窗口过小时这一行 flex:1 会被压到比内容还矮,柱状图(固定高)
原本会溢出卡片圆角外(issue #782)。裁进卡片内,与右侧「最近识别」卡片一致。 */}
{/* Both cards keep their content height; overflow belongs to the shell. */}
<div style={{ display: 'grid', gridTemplateColumns: mobile ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) minmax(0, 1.4fr)', gap: 12, marginBottom: 18 }}>
<PeriodMetricsCard
series={series}
period={period}
@@ -277,14 +315,15 @@ export function Overview({ onOpenHistory }: OverviewProps) {
onPeriodChange={setPeriod}
onMetricChange={setMetric}
loadError={activityError}
onRetry={refreshActivity}
/>
<Card padding={0} style={{ display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
<Card padding={0} style={{ display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden' }}>
<div style={{ padding: '14px 18px', borderBottom: '0.5px solid var(--ol-line)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-2)' }}>{t('overview.recentTitle')}</span>
<Btn size="sm" variant="ghost" onClick={onOpenHistory}>{t('overview.recentAll')}</Btn>
<Btn size="sm" variant="ghost" disabled={!onOpenHistory} onClick={onOpenHistory}>{t('overview.recentAll')}</Btn>
</div>
<div className="ol-thinscroll" style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
<div>
{historyError ? (
<div style={{ padding: 24, textAlign: 'center', fontSize: 12, color: 'var(--ol-ink-4)', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10 }}>
<span>{t('overview.recentLoadFailed')}</span>
@@ -294,7 +333,7 @@ export function Overview({ onOpenHistory }: OverviewProps) {
<>
{history.length === 0 && (
<div style={{ padding: 24, textAlign: 'center', fontSize: 12, color: 'var(--ol-ink-4)' }}>
{t('overview.recentEmpty', { trigger: prefs ? formatComboLabel(prefs.dictationHotkey) : '' })}
{t('overview.recentEmptyHint')}
</div>
)}
{history.slice(0, 5).map(s => (
@@ -305,53 +344,57 @@ export function Overview({ onOpenHistory }: OverviewProps) {
</div>
</Card>
</div>
</>
{/* Independent activity storage survives history clearing; keep the existing visibility preference. */}
{!mobile && prefs?.showOverviewActivityHeatmap !== false && activity && activity.length > 0 && (
<ActivityHeatmapCard activity={activity} />
)}
</div>
);
}
interface ProviderCardProps {
kind: string;
kind: 'asr' | 'llm' | 'omni';
name: string;
subname: string;
status: 'configured' | 'notConfigured' | 'error';
status: 'configured' | 'notConfigured';
onConfigure?: () => void;
}
function ProviderCard({ kind, name, subname, status }: ProviderCardProps) {
function ProviderCard({ kind, name, status, onConfigure }: ProviderCardProps) {
const { t } = useTranslation();
// ASR 卡用 mic 图标,其他用 sparkle —— 通过比较译文判断会随语言改变,故改用本地化无关的字面量比较。
const isAsr = kind === t('overview.asrKind');
return (
<Card padding={16} style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div
style={{
width: 38, height: 38, borderRadius: 10,
background: 'var(--ol-blue-soft)',
color: 'var(--ol-blue)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<Icon name={isAsr ? 'mic' : 'sparkle'} size={18} />
<Card padding={16} style={{ display: 'flex', flexDirection: 'column', gap: 12, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div
style={{
width: 38, height: 38, borderRadius: 10, flexShrink: 0,
background: 'var(--ol-blue-soft)',
color: 'var(--ol-blue)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<Icon name={kind === 'asr' ? 'mic' : 'sparkle'} size={18} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 8, marginBottom: 4 }}>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', fontWeight: 600 }}>{t(`overview.${kind}Kind`)}</span>
{status === 'configured' && (
<Pill tone="ok" size="sm">
<span style={{ width: 5, height: 5, borderRadius: 999, background: 'var(--ol-ok)' }} />
{t('overview.statusConfigured')}
</Pill>
)}
{status === 'notConfigured' && (
<Pill tone="outline" size="sm">{t('overview.statusNotConfigured')}</Pill>
)}
</div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ol-ink)', overflowWrap: 'anywhere' }}>{name}</div>
</div>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 2 }}>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', fontWeight: 600, letterSpacing: '.06em', textTransform: 'uppercase' }}>{kind}</span>
{status === 'configured' && (
<Pill tone="ok" size="sm">
<span style={{ width: 5, height: 5, borderRadius: 999, background: 'var(--ol-ok)' }} />
{t('overview.statusConfigured')}
</Pill>
)}
{status === 'notConfigured' && (
<Pill tone="outline" size="sm">{t('overview.statusNotConfigured')}</Pill>
)}
{status === 'error' && (
<Pill tone="outline" size="sm" style={{ color: 'var(--ol-red, #ef4444)', borderColor: 'rgba(239,68,68,0.24)' }}>{t('overview.statusUnknown')}</Pill>
)}
</div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ol-ink)' }}>{name}</div>
<div style={{ fontSize: 11.5, color: status === 'error' ? 'var(--ol-red, #ef4444)' : 'var(--ol-ink-3)', marginTop: 1, fontFamily: status === 'error' ? undefined : 'var(--ol-font-mono)' }}>
{status === 'error' ? t('overview.credentialsLoadError') : subname}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<span style={{ flex: '1 1 160px', fontSize: 11.5, color: 'var(--ol-ink-3)', lineHeight: 1.5 }}>{t(`overview.providerHelp.${kind}`)}</span>
<Btn size="sm" icon="chevRight" disabled={!onConfigure} onClick={onConfigure}>
{t(status === 'configured' ? 'overview.manageProvider' : 'overview.configureProvider')}
</Btn>
</div>
</Card>
);
@@ -385,9 +428,7 @@ function ActivityHeatmapCard({ activity }: { activity: ActivityDay[] }) {
};
}, [activity, i18n.language]);
return (
// marginBottom 与上方 provider / metrics 两行的 18px 节奏保持一致:否则「年度活动」
// 会直接贴住下面「近 7 天 / 最近识别」那一行,中间没有间隔(issue #781)。
<Card padding={18} style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 18 }}>
<Card padding={18} style={{ display: 'flex', flexDirection: 'column', gap: 12, minWidth: 0 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-2)' }}>
{t('overview.activityTitle')}
</span>
@@ -409,17 +450,16 @@ interface MetricProps {
label: string;
value: string;
trend: string;
accent?: boolean;
}
function Metric({ icon, label, value, trend, accent }: MetricProps) {
function Metric({ icon, label, value, trend }: MetricProps) {
return (
<Card padding={16}>
<Card padding={14} style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8, color: 'var(--ol-ink-3)' }}>
<Icon name={icon} size={13} />
<span style={{ fontSize: 11.5 }}>{label}</span>
</div>
<div style={{ fontSize: 26, fontWeight: 600, letterSpacing: '-0.02em', color: accent ? 'var(--ol-blue)' : 'var(--ol-ink)', lineHeight: 1.1 }}>{value}</div>
<div style={{ fontSize: 22, fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--ol-ink)', lineHeight: 1.2, overflowWrap: 'anywhere' }}>{value}</div>
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 6 }}>{trend || ' '}</div>
</Card>
);
@@ -493,6 +533,7 @@ function PeriodMetricsCard({
onPeriodChange,
onMetricChange,
loadError,
onRetry,
}: {
series: ReturnType<typeof buildPeriodSeries>;
period: ActivityPeriod;
@@ -500,6 +541,7 @@ function PeriodMetricsCard({
onPeriodChange: (next: ActivityPeriod) => void;
onMetricChange: (next: ActivityMetric) => void;
loadError: boolean;
onRetry: () => void;
}) {
const { t } = useTranslation();
const periodOptions = ACTIVITY_PERIODS.map(days => ({
@@ -512,7 +554,7 @@ function PeriodMetricsCard({
}));
return (
<Card padding={18} style={{ display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
<Card padding={18} style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
{/* flexWrap:卡片在 1fr 列里较窄,两组切换器放不下时换行而不是压扁按钮。 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
<SegmentedToggle
@@ -530,8 +572,9 @@ function PeriodMetricsCard({
</div>
{loadError ? (
<div style={{ height: 132, display: 'flex', alignItems: 'center', justifyContent: 'center', textAlign: 'center', fontSize: 12, color: 'var(--ol-ink-4)' }}>
<div style={{ minHeight: 132, display: 'flex', flexDirection: 'column', gap: 10, alignItems: 'center', justifyContent: 'center', textAlign: 'center', fontSize: 12, color: 'var(--ol-ink-4)' }}>
{t('overview.activityLoadError')}
<Btn size="sm" onClick={onRetry}>{t('overview.historyRetry')}</Btn>
</div>
) : (
<>
+1 -1
View File
@@ -87,7 +87,7 @@ export function SelectionAsk() {
return (
<>
<PageHeader title={t('selectionAsk.title')} />
<PageHeader title={t('selectionAsk.title')} desc={t('selectionAsk.desc')} />
<SavedToast saveState={saveState} message={saveMessage} />
+2 -2
View File
@@ -89,7 +89,7 @@ export function Translation() {
if (!prefs) {
return (
<>
<PageHeader title={t('translation.title')} />
<PageHeader title={t('translation.title')} desc={t('translation.desc')} />
<Card>
{error ? (
<div role="alert" style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
@@ -160,7 +160,7 @@ export function Translation() {
return (
<>
<PageHeader title={t('translation.title')} />
<PageHeader title={t('translation.title')} desc={t('translation.desc')} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{error && (
+5 -4
View File
@@ -27,9 +27,10 @@ export function PageHeader({ kicker, title, desc, right, titleRight }: PageHeade
justifyContent: 'space-between',
gap: stackLayout ? 12 : mobile ? 12 : 24,
marginBottom: stackLayout ? 16 : mobile ? 16 : 24,
flexWrap: stackLayout ? 'wrap' : 'nowrap',
flexWrap: 'wrap',
flexShrink: 0,
}}>
<div style={{ minWidth: 0 }}>
<div style={{ minWidth: 0, flex: '1 1 240px' }}>
{kicker && (
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '.08em', textTransform: 'uppercase', color: 'var(--ol-ink-4)', marginBottom: 8 }}>{kicker}</div>
)}
@@ -41,11 +42,11 @@ export function PageHeader({ kicker, title, desc, right, titleRight }: PageHeade
</div>
{right && (
preferenceStack ? (
<div className="ol-flex-row ol-flex-split" style={{ width: '100%' }}>
<div className="ol-flex-row ol-flex-split ol-page-header-actions" style={{ width: '100%' }}>
{right}
</div>
) : (
right
<div className="ol-page-header-actions" style={{ maxWidth: '100%', minWidth: 0, display: 'flex', flexWrap: 'wrap', gap: 8 }}>{right}</div>
)
)}
</div>
@@ -7,7 +7,7 @@
// 卡片解决的两件事:同一家厂商可以存多把 key;key 之间切换只是拖一下顺序,
// 而不是把旧 key 覆盖掉。
import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react';
import { useCallback, useEffect, useId, useRef, useState, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from '../../components/Icon';
import { Modal } from '../../components/ui/Modal';
@@ -34,13 +34,15 @@ import { emitSaved } from '../../lib/savedEvent';
import { useMobileLayout, useReadableLayout, useConservativeLayout } from '../../lib/useMobileLayout';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
import { getPlatformCapabilities } from '../../lib/platform';
import { Card } from '../_atoms';
import { Btn, Pill } from '../_atoms';
import {
ChannelCredentialFields,
ChannelFormRow,
ChannelSectionHeading,
LLM_LABELS,
OmniChannelSection,
} from './ProvidersSection';
import { ASR_LABELS, inputStyle, SectionTitle, Toggle } from './shared';
import { ASR_LABELS, inputStyle } from './shared';
type ChannelKind = 'llm' | 'asr';
@@ -266,8 +268,6 @@ export function ChannelList({
// 一段静音音频上去)。做成打开设置就全部自动验一遍的话,等于每次开设置都按卡片数
// 烧一遍额度,还容易把自己撞进限流。
const [testingIds, setTestingIds] = useState<Record<string, boolean>>({});
/** 刚验通过的短暂高亮(id → 延迟 ms),几秒后落回常驻的灰色数字。 */
const [justPassed, setJustPassed] = useState<Record<string, number>>({});
const runTest = async (channel: Channel) => {
if (testingIds[channel.id]) return;
@@ -283,16 +283,7 @@ export function ChannelList({
result.ok ? latency : null,
result.ok ? null : 'validateFailed',
);
if (result.ok) {
setJustPassed(prev => ({ ...prev, [channel.id]: latency }));
window.setTimeout(() => {
setJustPassed(prev => {
const next = { ...prev };
delete next[channel.id];
return next;
});
}, 3000);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
try {
@@ -447,28 +438,32 @@ export function ChannelList({
};
return (
<Card>
<div style={{ marginBottom: 10 }}>
<SectionTitle>
{t(kind === 'llm' ? 'settings.providers.llmTitle' : 'settings.providers.asrTitle')}
</SectionTitle>
</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6, marginBottom: 10 }}>
{t('settings.channels.orderHint')}
<section aria-label={t(`settings.channels.${kind}Title`)} style={{ minWidth: 0, marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap', gap: 16, marginBottom: 20 }}>
<div style={{ flex: '1 1 260px', minWidth: 0 }}>
<h2 style={{ margin: 0, color: 'var(--ol-ink)', fontSize: 17, fontWeight: 600 }}>{t(`settings.channels.${kind}Title`)}</h2>
<p style={{ margin: '7px 0 0', fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.65 }}>{t('settings.channels.orderHint')}</p>
</div>
<Btn variant="blue" icon="plus" disabled={creatingBusy} onClick={() => void startCreate()}>
{creatingBusy ? t('common.loading') : t('settings.channels.add')}
</Btn>
</div>
{!loaded && <div role="status" style={emptyStyle}>{t('common.loading')}</div>}
{loaded && channels.length === 0 && (
<div style={{ fontSize: 12.5, color: 'var(--ol-ink-4)', padding: '10px 0 14px', lineHeight: 1.6 }}>
{t('settings.channels.empty')}
<div style={emptyStyle}>
<Icon name={kind === 'llm' ? 'sparkle' : 'mic'} size={24} style={{ color: 'var(--ol-blue)', marginBottom: 10 }} />
<div>{t('settings.channels.empty')}</div>
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ borderTop: channels.length ? '1px solid var(--ol-line)' : undefined }}>
{channels.map(channel => {
const isActive = channel.id === activeId;
const label = channel.name.trim() || presetLabel(kind, channel.providerType, t, descriptors);
const providerLabel = presetLabel(kind, channel.providerType, t, descriptors);
const label = channel.name.trim() || providerLabel;
const model = models[channel.id] ?? '';
const failed = channel.lastTest && !channel.lastTest.ok;
const localEngine = descriptors.find(item => item.providerType === channel.providerType)?.authRequirement === 'none';
return (
<div
key={channel.id}
@@ -477,100 +472,61 @@ export function ChannelList({
else rowsRef.current.delete(channel.id);
}}
style={{
display: 'flex',
flexDirection: preferenceStack ? 'column' : 'row',
alignItems: preferenceStack ? 'stretch' : 'center',
gap: 10,
// 左侧补偿 2px 竖条与 0.5px 细边的宽度差,文字基线保持对齐。
padding: isActive ? '10px 12px 10px 10.5px' : '10px 12px',
borderRadius: 10,
border: '0.5px solid var(--ol-line-strong)',
// 「当前在用」只用一条竖条表达位置,**不用绿色也不用文字**:
// 绿色和「生效中」会被读成"这张是健康的",可它只代表排在最前面 ——
// 一张 key 已经失效的卡片照样排第一。健康与否只有验证说了算。
borderLeft: isActive
? '2.5px solid var(--ol-blue)'
: '0.5px solid var(--ol-line-strong)',
background: channel.enabled ? 'var(--ol-surface)' : 'var(--ol-bg-2, transparent)',
opacity: draggingId === channel.id ? 0.55 : channel.enabled ? 1 : 0.62,
boxShadow: draggingId === channel.id ? '0 6px 18px rgba(0,0,0,0.14)' : undefined,
transition: draggingId ? undefined : 'opacity 0.16s var(--ol-motion-quick)',
display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '14px 20px',
padding: '18px 8px', borderBottom: '1px solid var(--ol-line)',
background: isActive ? 'var(--ol-blue-soft)' : 'transparent',
boxShadow: isActive ? 'inset 3px 0 var(--ol-blue)' : undefined,
opacity: draggingId === channel.id ? 0.55 : 1,
transition: draggingId ? undefined : 'background 0.16s var(--ol-motion-quick)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0, flex: 1, width: preferenceStack ? '100%' : undefined }}>
<span
onPointerDown={e => onDragHandleDown(e, channel.id)}
onClick={e => e.stopPropagation()}
title={t('settings.channels.dragHint')}
aria-label={t('settings.channels.dragHint')}
style={{
color: 'var(--ol-ink-4)',
fontSize: 13,
flexShrink: 0,
cursor: draggingId === channel.id ? 'grabbing' : 'grab',
// 触摸设备上按住手柄不要变成页面滚动。
touchAction: 'none',
padding: '2px 2px',
userSelect: 'none',
}}
>
⠿
</span>
<div style={{ minWidth: 0, flex: 1 }}>
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--ol-ink)' }}>{label}</span>
{/* minHeight 保证「未命名 + 没验证过」的卡片不会比别的矮一截,
列表高度参差看起来像坏了。 */}
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 2, minHeight: 15, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{/* 未命名时主标题已经是厂商名,副行再来一遍就成了重复的两行同名。 */}
{channel.name.trim() && <span>{presetLabel(kind, channel.providerType, t, descriptors)}</span>}
{model && <span style={{ fontFamily: 'var(--ol-font-mono)' }}>{model}</span>}
{/* 验证结果什么时候来的 —— 让"这条结论会过期"这件事可见。 */}
{channel.lastTest && <span>{relativeTime(channel.lastTest.at, t)}</span>}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, minWidth: 0, flex: preferenceStack ? '1 1 100%' : '1 1 260px' }}>
<span
onPointerDown={e => onDragHandleDown(e, channel.id)}
onClick={e => e.stopPropagation()}
title={t('settings.channels.dragHint')}
aria-label={t('settings.channels.dragHint')}
style={{ color: 'var(--ol-ink-4)', fontSize: 18, flexShrink: 0, cursor: draggingId === channel.id ? 'grabbing' : 'grab', touchAction: 'none', padding: '0 4px', userSelect: 'none' }}
>⠿</span>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--ol-ink)', overflowWrap: 'anywhere' }}>{label}</span>
{isActive && <Pill tone="blue" size="sm">{t('settings.channels.current')}</Pill>}
{!channel.enabled && <Pill tone="outline" size="sm">{t('settings.channels.disabled')}</Pill>}
</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', marginTop: 6, lineHeight: 1.6, overflowWrap: 'anywhere' }}>
{channel.name.trim() && <span>{providerLabel} · </span>}
<span style={{ fontFamily: model ? 'var(--ol-font-mono)' : undefined }}>
{model || t(localEngine ? 'settings.channels.localModelManaged' : 'settings.channels.modelNotSet')}
</span>
</div>
<ChannelTestResult channel={channel} testing={Boolean(testingIds[channel.id])} t={t} />
</div>
</div>
<div className={conservative ? 'ol-conservative-stack' : undefined} style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 8, marginLeft: preferenceStack ? 0 : 30, width: preferenceStack ? '100%' : undefined }}>
<Btn size="sm" disabled={Boolean(testingIds[channel.id])} onClick={() => void runTest(channel)}>
{t(testingIds[channel.id] ? 'settings.channels.verifying' : 'settings.channels.verify')}
</Btn>
<button
type="button"
role="switch"
aria-checked={channel.enabled}
aria-label={t('settings.channels.enabledFor', { name: label })}
onClick={() => void onToggle(channel)}
style={{ ...ghostBtn, display: 'inline-flex', alignItems: 'center', gap: 7 }}
>
<span aria-hidden="true" style={{ width: 24, height: 14, borderRadius: 999, background: channel.enabled ? 'var(--ol-blue)' : 'var(--ol-toggle-off-bg)', position: 'relative' }}>
<span style={{ position: 'absolute', top: 2, left: channel.enabled ? 12 : 2, width: 10, height: 10, borderRadius: 999, background: 'var(--ol-toggle-knob)' }} />
</span>
{t('settings.channels.enabled')}
</button>
<Btn size="sm" variant="soft" icon="chevRight" onClick={() => setEditingId(channel.id)}>{t('settings.channels.edit')}</Btn>
</div>
<div
className={conservative ? 'ol-conservative-stack' : undefined}
style={{
display: 'flex',
flexDirection: conservative ? 'column' : 'row',
alignItems: conservative ? 'flex-start' : 'center',
gap: 8,
flexWrap: readable ? 'wrap' : 'nowrap',
width: preferenceStack ? '100%' : undefined,
}}
>
<VerifyButton
channel={channel}
testing={Boolean(testingIds[channel.id])}
justPassedMs={justPassed[channel.id]}
onRun={() => void runTest(channel)}
t={t}
/>
<Toggle on={channel.enabled} onToggle={() => void onToggle(channel)} />
<button
onClick={() => setEditingId(channel.id)}
title={t('settings.channels.edit')}
aria-label={t('settings.channels.edit')}
style={{ ...iconBtn, ...(conservative ? { width: 'auto', padding: '0 10px', gap: 6 } : {}) }}
>
<Icon name="chevRight" size={13} />
{conservative && <span style={{ fontSize: 12 }}>{t('settings.channels.edit')}</span>}
</button>
</div>
</div>
);
})}
</div>
<button
onClick={() => void startCreate()}
disabled={creatingBusy}
style={{ ...addBtn, marginTop: channels.length ? 10 : 0 }}
>
+ {t('settings.channels.add')}
</button>
{editingChannel && (
<ChannelModal
kind={kind}
@@ -583,78 +539,37 @@ export function ChannelList({
onUserMutation={markDraftTouched}
/>
)}
</Card>
</section>
);
}
/**
* 卡片上的验证按钮 —— **按钮自己就是结果容器**,不给结果另找地方摆文字。
*
* 通过时只显示延迟数字:它既说明"通了",又带信息量(一眼看出哪张快);
* 再写一句"验证通过"是废话还占地方。失败则必须给出能指导行动的短标签
* (401 改 key / 429 等会儿 / 超时查网络)。
*
* 宽度固定:让 `验证` → `284ms` → `✗ 401` 的文字变化不会把开关和箭头挤来挤去。
*/
function VerifyButton({
channel,
testing,
justPassedMs,
onRun,
t,
}: {
/** Selection and the last manual test are separate facts. The action keeps a
* stable label; result, elapsed time and age remain readable alongside it. */
function ChannelTestResult({ channel, testing, t }: {
channel: Channel;
testing: boolean;
justPassedMs?: number;
onRun: () => void;
t: ReturnType<typeof useTranslation>['t'];
}) {
const last = channel.lastTest;
const stale =
last != null && Math.floor(Date.now() / 1000) - last.at > STALE_TEST_SECONDS;
let label: string;
let color = 'var(--ol-ink-3)';
if (testing) {
label = '···';
color = 'var(--ol-ink-4)';
} else if (justPassedMs != null) {
label = `✓ ${justPassedMs}ms`;
color = 'var(--ol-ok)';
} else if (!last) {
label = t('settings.channels.verify');
} else if (last.ok) {
label = last.latencyMs != null ? `${last.latencyMs}ms` : '✓';
// 旧结果褪成浅灰:不保证现在还有效。
color = stale ? 'var(--ol-ink-4)' : 'var(--ol-ink-3)';
} else {
label = `✗ ${shortErrorLabel(last.error, t)}`;
color = 'var(--ol-warn)';
}
const stale = last != null && Math.floor(Date.now() / 1000) - last.at > STALE_TEST_SECONDS;
const passed = last?.ok;
const elapsed = last?.latencyMs;
return (
<button
onClick={onRun}
disabled={testing}
title={t('settings.channels.verifyHint')}
style={{
width: 76,
height: 28,
flexShrink: 0,
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 7,
background: 'var(--ol-surface)',
color,
cursor: 'default',
fontSize: 11.5,
fontWeight: 500,
opacity: stale && !testing ? 0.75 : 1,
overflow: 'hidden',
whiteSpace: 'nowrap',
}}
>
{label}
</button>
<div role="status" style={{ display: 'flex', alignItems: 'baseline', flexWrap: 'wrap', gap: '3px 8px', marginTop: 7, fontSize: 11.5, lineHeight: 1.6, color: 'var(--ol-ink-3)' }}>
<span>{t('settings.channels.lastCheck')}</span>
{testing ? <span>{t('settings.channels.verifying')}</span> : !last ? (
<span>{t('settings.channels.notVerified')}</span>
) : (
<>
<span style={{ color: stale ? 'var(--ol-ink-3)' : passed ? 'var(--ol-ok)' : 'var(--ol-warn)' }}>
{passed ? t('settings.channels.passed') : t('settings.channels.failed', { reason: shortErrorLabel(last?.error ?? null, t) })}
</span>
{passed && elapsed != null && <span>{t('settings.channels.elapsed', { ms: elapsed })}</span>}
{last && <time dateTime={new Date(last.at * 1000).toISOString()} title={new Date(last.at * 1000).toLocaleString()}>{relativeTime(last.at, t)}</time>}
{stale && <span>{t('settings.channels.staleResult')}</span>}
</>
)}
</div>
);
}
@@ -726,6 +641,54 @@ function ChannelModal({
const [name, setName] = useState(channel.name);
const [providerType, setProviderType] = useState(channel.providerType);
const [confirmDelete, setConfirmDelete] = useState(false);
const dialogRef = useRef<HTMLDivElement>(null);
const nameId = useId();
useEffect(() => {
const opener = document.activeElement;
const dialog = dialogRef.current;
const parentDialog = opener instanceof HTMLElement ? opener.closest<HTMLElement>('[role="dialog"]') : null;
const background = parentDialog !== dialog ? parentDialog : null;
const wasInert = background?.inert ?? false;
(dialog?.querySelector<HTMLElement>('[role="combobox"], input:not([disabled])') ?? dialog)?.focus();
// Keep body-portaled provider menus accessible while disabling the covered
// settings surface. aria-modal would hide those existing sibling portals.
if (background) background.inert = true;
return () => {
if (background) background.inert = wasInert;
if (opener instanceof HTMLElement && opener.isConnected) opener.focus();
};
}, []);
const onDialogKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const dialog = dialogRef.current;
if (!dialog || event.defaultPrevented) return;
if (event.target instanceof Element && event.target.closest('[role="dialog"]') !== dialog) return;
if (event.key === 'Escape') {
// SelectLite handles its open menu first. Never close both layers at once.
if (dialog.querySelector('[role="combobox"][aria-expanded="true"]')) return;
event.preventDefault();
event.stopPropagation();
onClose();
} else if (event.key === 'Tab') {
const controls = Array.from(dialog.querySelectorAll<HTMLElement>(
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])',
)).filter(element => element.tabIndex >= 0 && element.getClientRects().length > 0);
const first = controls[0];
const last = controls[controls.length - 1];
if (!first) {
event.preventDefault();
dialog.focus();
} else if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
};
const saveName = async () => {
if (name.trim() === channel.name.trim()) return;
@@ -788,119 +751,76 @@ function ChannelModal({
const isLocalEngine = descriptor?.authRequirement === 'none';
return (
<Modal onClose={onClose} width={mobile ? 'min(560px, 100%)' : 'min(600px, 100%)'}>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--ol-ink)', marginBottom: 14 }}>
{t(isDraft ? 'settings.channels.createTitle' : 'settings.channels.editTitle')}
</div>
<label style={fieldLabel}>{t('settings.channels.providerLabel')}</label>
<SelectLite
value={providerType}
onChange={next => void changeProvider(next)}
options={presets.map(p => ({
value: p.id,
label: t(`settings.providers.presets.${p.nameKey}`),
}))}
ariaLabel={t('settings.channels.providerLabel')}
style={{ ...inputStyle, width: '100%', marginBottom: 12 }}
/>
<label style={fieldLabel}>{t('settings.channels.nameLabel')}</label>
<input
value={name}
onChange={e => {
onUserMutation();
setName(e.target.value);
}}
onBlur={() => void saveName()}
placeholder={t('settings.channels.namePlaceholder')}
style={{ ...inputStyle, width: '100%', marginBottom: 14 }}
/>
{/* key 决定:换供应商时整组凭据字段重挂载,读的是新厂商对应的槽位。 */}
<ChannelCredentialFields
key={`${channel.id}:${providerType}`}
kind={kind}
providerType={providerType}
channelId={channel.id}
descriptor={descriptor}
onTested={() => void onChanged()}
onUserMutation={onUserMutation}
/>
{isLocalEngine && (
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6, marginTop: 6 }}>
{t('settings.channels.localEngineModelHint')}
</div>
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'space-between', marginTop: 20, alignItems: 'center' }}>
{confirmDelete ? (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, color: 'var(--ol-warn)' }}>
{t('settings.channels.deleteConfirm')}
</span>
<button onClick={() => void remove()} style={dangerBtn}>
{t('settings.channels.confirmDelete')}
</button>
<button onClick={() => setConfirmDelete(false)} style={ghostBtn}>{t('common.cancel')}</button>
<Modal onClose={onClose} zIndex={1000} width={mobile ? '100%' : 'min(780px, 100%)'}>
<div ref={dialogRef} className="ol-channel-dialog" role="dialog" aria-label={t(isDraft ? 'settings.channels.createTitle' : 'settings.channels.editTitle')} tabIndex={-1} onKeyDown={onDialogKeyDown} style={{ minWidth: 0, outline: 'none' }}>
<style>{`.ol-channel-dialog :is(input, button, [role="combobox"]):focus-visible { outline: 2px solid var(--ol-blue); outline-offset: 3px; }`}</style>
<header style={{ marginBottom: 24, display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<div>
<h2 style={{ margin: 0, fontSize: 21, fontWeight: 600, color: 'var(--ol-ink)' }}>{t(isDraft ? 'settings.channels.createTitle' : 'settings.channels.editTitle')}</h2>
<p style={{ margin: '8px 0 0', fontSize: 12, lineHeight: 1.6, color: 'var(--ol-ink-3)' }}>{t('settings.channels.autoSaveHint')}</p>
</div>
) : (
<button onClick={() => setConfirmDelete(true)} style={ghostBtn}>
{t('settings.channels.delete')}
</button>
)}
<button onClick={onClose} style={primaryBtn}>{t('common.close')}</button>
<button type="button" onClick={onClose} aria-label={t('common.close')} style={{ display: 'grid', placeItems: 'center', flexShrink: 0, width: 32, height: 32, padding: 0, border: 0, borderRadius: 8, color: 'var(--ol-ink-2)', background: 'var(--ol-surface-2)', cursor: 'pointer' }}><Icon name="close" size={17} /></button>
</header>
<ChannelSectionHeading title={t('settings.channels.connectionTitle')} />
<ChannelFormRow label={t('settings.channels.providerLabel')}>
<SelectLite
value={providerType}
onChange={next => void changeProvider(next)}
options={presets.map(p => ({ value: p.id, label: t(`settings.providers.presets.${p.nameKey}`) }))}
ariaLabel={t('settings.channels.providerLabel')}
style={{ ...inputStyle, width: '100%', maxWidth: '100%', height: 38 }}
/>
</ChannelFormRow>
{/* Provider changes remount the existing, channel-scoped credential fields. */}
<ChannelCredentialFields
key={`${channel.id}:${providerType}`}
kind={kind}
providerType={providerType}
channelId={channel.id}
descriptor={descriptor}
onTested={() => void onChanged()}
onUserMutation={onUserMutation}
/>
{isLocalEngine && <p style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.6 }}>{t('settings.channels.localEngineModelHint')}</p>}
<div style={{ borderTop: '1px solid var(--ol-line)', marginTop: 24, paddingTop: 12 }}>
<ChannelFormRow label={t('settings.channels.nameLabel')} htmlFor={nameId}>
<input
id={nameId}
value={name}
onChange={e => { onUserMutation(); setName(e.target.value); }}
onBlur={() => void saveName()}
placeholder={t('settings.channels.namePlaceholder')}
style={{ ...inputStyle, width: '100%', maxWidth: '100%', height: 38 }}
/>
</ChannelFormRow>
<p style={{ margin: '0 0 4px', fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6 }}>{t('settings.channels.nameHint')}</p>
</div>
<footer style={{ display: 'flex', flexWrap: 'wrap', gap: 12, justifyContent: 'space-between', marginTop: 24, paddingTop: 18, borderTop: '1px solid var(--ol-line)', alignItems: 'center' }}>
{confirmDelete ? (
<div role="group" aria-label={t('settings.channels.delete')} style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', flex: '1 1 260px' }}>
<span style={{ width: '100%', fontSize: 12, color: 'var(--ol-warn)', lineHeight: 1.6 }}>{t('settings.channels.deleteConfirm')}</span>
<button type="button" onClick={() => void remove()} style={dangerBtn}>{t('settings.channels.confirmDelete')}</button>
<button type="button" onClick={() => setConfirmDelete(false)} style={ghostBtn}>{t('common.cancel')}</button>
</div>
) : (
<button type="button" onClick={() => setConfirmDelete(true)} style={{ ...ghostBtn, color: 'var(--ol-warn)', borderColor: 'transparent' }}>{t('settings.channels.delete')}</button>
)}
<Btn variant="blue" onClick={onClose}>{t('common.close')}</Btn>
</footer>
</div>
</Modal>
);
}
const fieldLabel: CSSProperties = {
display: 'block',
fontSize: 12,
fontWeight: 500,
color: 'var(--ol-ink-2)',
marginBottom: 5,
};
const iconBtn: CSSProperties = {
width: 30,
height: 30,
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 8,
background: 'var(--ol-surface)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--ol-ink-3)',
cursor: 'default',
flexShrink: 0,
};
const addBtn: CSSProperties = {
height: 34,
padding: '0 14px',
border: '0.5px dashed var(--ol-line-strong)',
borderRadius: 9,
background: 'transparent',
color: 'var(--ol-ink-3)',
cursor: 'default',
fontSize: 12.5,
fontWeight: 500,
width: '100%',
};
const primaryBtn: CSSProperties = {
height: 32,
padding: '0 14px',
border: '0.5px solid var(--ol-blue)',
borderRadius: 8,
background: 'var(--ol-blue)',
color: '#fff',
cursor: 'default',
fontSize: 12.5,
fontWeight: 500,
const emptyStyle: CSSProperties = {
padding: '28px 20px', textAlign: 'center', fontSize: 13,
color: 'var(--ol-ink-3)', lineHeight: 1.7,
borderTop: '1px solid var(--ol-line)', borderBottom: '1px solid var(--ol-line)',
};
const ghostBtn: CSSProperties = {
@@ -908,9 +828,9 @@ const ghostBtn: CSSProperties = {
padding: '0 14px',
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 8,
background: 'var(--ol-surface)',
background: 'var(--ol-control-solid)',
color: 'var(--ol-ink-2)',
cursor: 'default',
cursor: 'pointer',
fontSize: 12.5,
fontWeight: 500,
};
@@ -9,14 +9,11 @@ import { useEffect, useState } from 'react';
import type { PlatformCapabilities } from '../../lib/types';
import { useTranslation } from 'react-i18next';
import { LocalAsr } from '../LocalAsr';
import { detectOS } from '../../components/WindowChrome';
import { getPlatformCapabilities } from '../../lib/platform';
import { Card } from '../_atoms';
import '../LocalAsr/local-asr.css';
export function LocalModelSection() {
const { t } = useTranslation();
const os = detectOS();
const isWin = os === 'win';
const [platformCaps, setPlatformCaps] = useState<PlatformCapabilities | null>(null);
useEffect(() => {
@@ -26,39 +23,19 @@ export function LocalModelSection() {
const platformSupported = platformCaps?.supportsLocalAsr === true;
return (
<Card>
{/* 标题 + 右上角 inline 警告小字(实验性标记保留)。
Windows:标题区整体灰显 —— 本地 ASR 在 Win 上走 Foundry / sherpa 独立路径。 */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
<div style={{ minWidth: 0, opacity: isWin ? 0.45 : 1 }}>
<div style={{ fontSize: 14, fontWeight: 600, letterSpacing: '-0.01em' }}>{t('settings.advanced.localAsrTitle')}</div>
<section className="ol-local-model-section">
<header className="ol-local-model-heading">
<div>
<h2>{t('modal.serviceViews.models')}</h2>
<p>{t('localAsr.performanceWarning')}</p>
</div>
<div style={{
fontSize: 11,
color: '#A04500',
fontWeight: 500,
lineHeight: 1.4,
textAlign: 'right',
flexShrink: 0,
maxWidth: '52%',
paddingTop: 2,
opacity: isWin ? 0.45 : 1,
}}>
⚠️ {t('settings.advanced.localAsrWarningShort')}
</div>
</div>
{!platformSupported ? (
<div style={{ fontSize: 12.5, color: 'var(--ol-ink-3)', lineHeight: 1.6, padding: '8px 0' }}>
{t('settings.advanced.platformNotSupported')}
</div>
) : (
/* 模型下载 / 管理看板(模型选择 · 下载 · 删除 · 测试 · 镜像源收纳)——
较大的内嵌框。启用入口在「AI 提供商 → ASR 语音转写」选择本地供应商。 */
<div style={{ marginTop: 16, borderTop: '0.5px solid var(--ol-line)', paddingTop: 16 }}>
<LocalAsr embedded />
</div>
)}
</Card>
<span className="ol-model-status">{t('localAsr.qwenExperimentalBadge')}</span>
</header>
{platformCaps === null ? (
<p className="ol-model-muted" role="status">{t('common.loading')}</p>
) : !platformSupported ? (
<p className="ol-model-muted">{t('settings.advanced.platformNotSupported')}</p>
) : <LocalAsr embedded />}
</section>
);
}
@@ -1,7 +1,7 @@
// 服务 → AI 提供商:LLM 润色模型 + ASR 语音转写两张卡片。
// 自 Settings.tsx 整体迁出,逻辑零改动;i18n key 全部保持 `settings.providers.*`。
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
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';
@@ -53,6 +53,29 @@ 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();
const conservative = useConservativeLayout();
return (
<div style={{ display: 'grid', gridTemplateColumns: stack || conservative ? 'minmax(0, 1fr)' : '140px minmax(0, 1fr)', gap: '8px 20px', alignItems: 'start', padding: '10px 0' }}>
<label htmlFor={htmlFor} style={{ paddingTop: stack || conservative ? 0 : 9, color: 'var(--ol-ink-2)', fontSize: 12.5, lineHeight: 1.6 }}>{label}</label>
<div style={{ minWidth: 0, width: '100%' }}>{children}</div>
</div>
);
}
export function ChannelSectionHeading({ title, description }: { title: string; description?: string }) {
return (
<div style={{ marginBottom: 6 }}>
<h3 style={{ margin: 0, fontSize: 14, color: 'var(--ol-ink)', fontWeight: 600 }}>{title}</h3>
{description && <p style={{ margin: '6px 0 0', color: 'var(--ol-ink-3)', fontSize: 12, lineHeight: 1.65 }}>{description}</p>}
</div>
);
}
const channelSectionStyle: CSSProperties = { borderTop: '1px solid var(--ol-line)', marginTop: 24, paddingTop: 20 };
function LlmThinkingToggle({ enabled, onToggle }: { enabled: boolean; onToggle: (next: boolean) => void }) {
const { t } = useTranslation();
const baseLayoutStack = useLayoutStack();
@@ -188,14 +211,6 @@ export function ChannelCredentialFields({
defaultValue={defaultEndpoint || undefined} onUserMutation={onUserMutation} />
{providerType === 'custom' && (
<>
<CredentialField
key={`${channelId}:temperature`}
label={t('settings.providers.temperatureLabel')}
account="ark.temperature"
placeholder={t('settings.providers.temperaturePlaceholder')}
mono
onUserMutation={onUserMutation}
/>
<CredentialField
key={`${channelId}:extra_headers`}
label={t('settings.providers.extraHeadersLabel')}
@@ -209,6 +224,7 @@ export function ChannelCredentialFields({
)}
</>
)}
<div style={channelSectionStyle}><ChannelSectionHeading title={t('settings.channels.modelTitle')} /></div>
<CredentialField key={`${channelId}:model:${llmModelRevision}`} label={t('settings.providers.modelLabel')}
account="ark.model_id" provider={channelId}
placeholder={defaultModel || 'model-name'} mono
@@ -221,6 +237,16 @@ export function ChannelCredentialFields({
/>
)}
/>
{providerType === 'custom' && (
<CredentialField
key={`${channelId}:temperature`}
label={t('settings.providers.temperatureLabel')}
account="ark.temperature"
placeholder={t('settings.providers.temperaturePlaceholder')}
mono
onUserMutation={onUserMutation}
/>
)}
<ProviderTools kind="llm" modelAccount="ark.model_id" provider={channelId}
onModelSelected={() => setLlmModelRevision(v => v + 1)} onTested={onTested}
onUserMutation={onUserMutation} />
@@ -234,7 +260,7 @@ export function ChannelCredentialFields({
if (descriptor?.authRequirement === 'volcengine') {
return (
<>
<SettingRow label={t('settings.providers.volcengineAuthModeLabel')}>
<ChannelFormRow label={t('settings.providers.volcengineAuthModeLabel')}>
<SelectLite
value={volcengineAuthMode}
onChange={async (v) => {
@@ -257,9 +283,9 @@ export function ChannelCredentialFields({
{ value: 'api_key', label: t('settings.providers.volcengineAuthModeApiKey') },
]}
ariaLabel={t('settings.providers.volcengineAuthModeLabel')}
style={{ ...inputStyle, width: '100%', maxWidth: layoutStack ? '100%' : 260 }}
style={{ ...inputStyle, width: '100%', maxWidth: '100%', height: 38 }}
/>
</SettingRow>
</ChannelFormRow>
{/* 两种模式使用各自独立的凭据槽位:旧版 Access Token(volcengine.access_key)
与方舟 API Key(volcengine.api_key)互不预填,切换模式不会残留混淆。 */}
{volcengineAuthMode === 'app_id_token' ? (
@@ -273,6 +299,7 @@ export function ChannelCredentialFields({
<CredentialField key={`${channelId}:api_key`} label={t('settings.providers.volcengineApiKeyLabel')}
account="volcengine.api_key" provider={channelId} mono mask onUserMutation={onUserMutation} />
)}
<div style={channelSectionStyle}><ChannelSectionHeading title={t('settings.channels.modelTitle')} /></div>
<CredentialField
key={`${channelId}:resource_id`}
label={t('settings.providers.volcengineResourceIdLabel')}
@@ -329,6 +356,7 @@ export function ChannelCredentialFields({
account="asr.endpoint" provider={channelId}
placeholder={defaultEndpoint || 'https://your-endpoint/v1'}
defaultValue={defaultEndpoint || undefined} onUserMutation={onUserMutation} />
<div style={channelSectionStyle}><ChannelSectionHeading title={t('settings.channels.modelTitle')} /></div>
<CredentialField key={`${channelId}:model:${asrModelRevision}`} label={t('settings.providers.modelLabel')}
account="asr.model" provider={channelId}
placeholder={defaultModel || 'model-name'}
@@ -367,13 +395,13 @@ export function ChannelCredentialFields({
{t('settings.providers.zenmuxVocabularyNote')}
</div>
)}
{(providerType === 'openai-compatible' || providerType === 'zenmux') && (
<AsrAdvancedOptions provider={channelId} onUserMutation={onUserMutation} />
)}
{/* 统一百炼「拉取模型」只写 model,不覆盖用户选择的区域或工作空间 endpoint。 */}
<ProviderTools kind="asr" modelAccount="asr.model" provider={channelId}
onModelSelected={() => setAsrModelRevision(v => v + 1)} onTested={onTested}
onUserMutation={onUserMutation} />
{(providerType === 'openai-compatible' || providerType === 'zenmux') && (
<AsrAdvancedOptions provider={channelId} onUserMutation={onUserMutation} />
)}
</>
);
}
@@ -580,6 +608,7 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested
const [selectedModel, setSelectedModel] = useState('');
const [status, setStatus] = useState<ProviderToolStatus>('idle');
const [message, setMessage] = useState('');
const [operation, setOperation] = useState<'validate' | 'models'>('validate');
const setResult = (next: ProviderToolStatus, nextMessage: string) => {
setStatus(next);
@@ -601,6 +630,7 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested
const validate = async () => {
onUserMutation?.();
setOperation('validate');
setModels([]);
setSelectedModel('');
setResult('loading', t('settings.providers.validating'));
@@ -632,6 +662,7 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested
const loadModels = async () => {
onUserMutation?.();
setOperation('models');
setResult('loading', t('settings.providers.loadingModels'));
try {
const result = await listProviderModels(kind, provider);
@@ -650,6 +681,7 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested
const applyModel = async (model: string) => {
onUserMutation?.();
setOperation('models');
setResult('loading', t('common.saving'));
try {
await setCredential(modelAccount, model, provider);
@@ -661,33 +693,39 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested
}
};
const resultMessage = message && (
<div role="status" style={{ fontSize: 12, color: status === 'error' ? 'var(--ol-warn)' : status === 'success' ? 'var(--ol-ok)' : 'var(--ol-ink-3)', lineHeight: 1.65, overflowWrap: 'anywhere', marginTop: 10 }}>
{message}
</div>
);
return (
<SettingRow label={t('settings.providers.toolsLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%', maxWidth: layoutStack ? '100%' : 420 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap', width: '100%' }}>
<button onClick={validate} style={miniBtnStyle} disabled={status === 'loading'}>{t('settings.providers.validate')}</button>
{showFetchModels && (
<button onClick={loadModels} style={miniBtnStyle} disabled={status === 'loading'}>{t('settings.providers.fetchModels')}</button>
)}
{showFetchModels && models.length > 0 && (
<SelectLite
value={selectedModel}
onChange={applyModel}
disabled={status === 'loading'}
options={models.map(model => ({ value: model, label: model }))}
placeholder={t('settings.providers.selectModel')}
ariaLabel={t('settings.providers.selectModel')}
style={{ flex: layoutStack ? '1 1 100%' : '1 1 180px', maxWidth: layoutStack ? '100%' : 220, minWidth: 0 }}
/>
)}
</div>
{message && (
<span style={{ fontSize: 11, color: status === 'error' ? 'var(--ol-warn)' : status === 'empty' ? 'var(--ol-ink-4)' : 'var(--ol-ok)', lineHeight: 1.4 }}>
{message}
</span>
)}
</div>
</SettingRow>
<>
{showFetchModels && (
<ChannelFormRow label={t('settings.channels.availableModels')}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 10, minWidth: 0 }}>
<button type="button" onClick={loadModels} style={miniBtnStyle} disabled={status === 'loading'}>{t('settings.providers.fetchModels')}</button>
{models.length > 0 && (
<SelectLite
value={selectedModel}
onChange={applyModel}
disabled={status === 'loading'}
options={models.map(model => ({ value: model, label: model }))}
placeholder={t('settings.providers.selectModel')}
ariaLabel={t('settings.providers.selectModel')}
style={{ width: '100%', minWidth: 0, height: 38 }}
/>
)}
</div>
{operation === 'models' && resultMessage}
</ChannelFormRow>
)}
<section style={channelSectionStyle}>
<ChannelSectionHeading title={t('settings.channels.validationTitle')} description={t('settings.channels.validationHint')} />
<button type="button" onClick={validate} style={{ ...miniBtnStyle, marginTop: 12, color: 'var(--ol-blue)', borderColor: 'var(--ol-blue)' }} disabled={status === 'loading'}>{t('settings.channels.verify')}</button>
{operation === 'validate' && resultMessage}
</section>
</>
);
}
@@ -739,6 +777,7 @@ 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();
@@ -879,9 +918,9 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de
&& value.trim().toLowerCase().startsWith('http://');
return (
<SettingRow label={label}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 5, width: '100%', maxWidth: layoutStack ? '100%' : 420 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', width: '100%', flexWrap: layoutStack ? 'wrap' : 'nowrap' }}>
<ChannelFormRow label={label} htmlFor={options && !customModelMode ? undefined : fieldId}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 5, width: '100%', minWidth: 0 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', width: '100%', flexWrap: 'nowrap' }}>
{options && !customModelMode ? (
<SelectLite
value={value}
@@ -906,17 +945,18 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de
placeholder={loaded ? placeholder : t('common.loading')}
disabled={disabled}
ariaLabel={label}
style={{ flex: layoutStack ? '1 1 180px' : 1, minWidth: 0, maxWidth: '100%', fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit' }}
style={{ flex: 1, height: 38, minWidth: 0, maxWidth: '100%', fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit' }}
/>
) : (
<input
id={fieldId}
type={inputType}
value={value}
placeholder={loaded ? placeholder : t('common.loading')}
onChange={handleChange}
onBlur={onBlur}
disabled={disabled}
style={{ ...inputStyle, flex: layoutStack ? '1 1 180px' : 1, minWidth: 0, maxWidth: '100%', fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit' }}
style={{ ...inputStyle, flex: 1, height: 38, minWidth: 0, maxWidth: '100%', fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit' }}
/>
)}
{options && customModelMode && (
@@ -934,7 +974,6 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de
<Icon name="check" size={13} />
</button>
)}
{trailing}
{mask && (
<button
onClick={() => setRevealed(r => !r)}
@@ -968,22 +1007,23 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de
</span>
)}
</div>
{trailing && <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 6 }}>{trailing}</div>}
{showInsecureEndpointWarning && (
<span style={{ fontSize: 11, color: 'var(--ol-warn)', lineHeight: 1.45 }}>
{t('settings.providers.endpointHttpWarning')}
</span>
)}
</div>
</SettingRow>
</ChannelFormRow>
);
}
const miniBtnStyle: CSSProperties = {
height: 32, padding: '0 12px',
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 8, background: 'var(--ol-surface)',
borderRadius: 8, background: 'var(--ol-control-solid)',
boxShadow: '0 1px 2px rgba(0,0,0,0.04)',
color: 'var(--ol-ink-2)', cursor: 'default', flexShrink: 0,
color: 'var(--ol-ink-2)', cursor: 'pointer', flexShrink: 0,
fontSize: 12.5, fontWeight: 500, letterSpacing: '0.01em',
transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), box-shadow 0.16s var(--ol-motion-quick)',
};
@@ -991,10 +1031,10 @@ const miniBtnStyle: CSSProperties = {
const iconBtnStyle: CSSProperties = {
width: 32, height: 32,
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 8, background: 'var(--ol-surface)',
borderRadius: 8, background: 'var(--ol-control-solid)',
boxShadow: '0 1px 2px rgba(0,0,0,0.04)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--ol-ink-3)', cursor: 'default', flexShrink: 0,
color: 'var(--ol-ink-3)', cursor: 'pointer', flexShrink: 0,
transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), transform 0.12s var(--ol-motion-quick)',
};
@@ -0,0 +1,34 @@
import { searchSettingsSections, visibleSettingsSections, availableServiceViews, resolveServiceView } from './navigation';
const assert = {
deepEqual(actual: unknown, expected: unknown, message: string) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error(message);
},
equal(actual: unknown, expected: unknown, message: string) {
if (actual !== expected) throw new Error(message);
},
};
const sections = [
{ id: 'general' as const, icon: 'mic', title: '录音与输入', description: '麦克风与手机输入', keywords: 'microphone remote PIN' },
{ id: 'services' as const, icon: 'cloud', title: 'AI 服务与模型', description: '配置识别和润色', keywords: 'API 本地模型 ASR LLM' },
];
assert.deepEqual(searchSettingsSections(sections, ' API ').map(item => item.id), ['services'], 'search normalizes width, case and surrounding whitespace');
assert.deepEqual(searchSettingsSections(sections, '润色').map(item => item.id), ['services'], 'search includes the explanation');
assert.deepEqual(searchSettingsSections(sections, '输入 PIN').map(item => item.id), ['general'], 'each search term must match within the same category');
assert.deepEqual(searchSettingsSections(sections, '麦克风 LLM'), [], 'terms from different categories do not create a false result');
assert.deepEqual(searchSettingsSections(sections, '不存在'), [], 'unknown query has no results');
assert.deepEqual(searchSettingsSections(sections, ' '), sections, 'clearing a query restores all categories');
assert.equal(visibleSettingsSections(false).some(item => item.id === 'shortcuts'), false, 'mobile cannot reach an empty desktop-only category');
assert.equal(visibleSettingsSections(true).some(item => item.id === 'shortcuts'), true, 'desktop retains shortcuts');
console.log('settings navigation tests passed');
const omniViews = availableServiceViews(true, true);
assert.deepEqual(omniViews, ['omni', 'models', 'connections'], 'multimodal exposes its real service configuration instead of empty LLM/ASR pages');
assert.equal(resolveServiceView('llm', omniViews), 'omni', 'switching to Omni mode keeps the service editor reachable');
assert.equal(resolveServiceView('models', omniViews), 'models', 'pipeline changes do not redirect a user managing local models');
const phoneViews = availableServiceViews(false, false);
assert.equal(phoneViews.includes('models'), false, 'unsupported local model management is not exposed');
assert.equal(resolveServiceView('models', phoneViews), 'llm', 'a no-longer-available page falls back to a working editor');
assert.equal(resolveServiceView('omni', phoneViews), 'llm', 'leaving Omni returns to a traditional service');
@@ -0,0 +1,49 @@
export type SettingsSectionId = 'general' | 'shortcuts' | 'appearance' | 'services' | 'privacy' | 'advanced' | 'about';
export interface SettingsNavigationItem {
id: SettingsSectionId;
icon: string;
}
export const SETTINGS_SECTIONS: SettingsNavigationItem[] = [
{ id: 'general', icon: 'mic' },
{ id: 'shortcuts', icon: 'bolt' },
{ id: 'services', icon: 'cloud' },
{ id: 'appearance', icon: 'settings' },
{ id: 'privacy', icon: 'shield' },
{ id: 'advanced', icon: 'sparkle' },
{ id: 'about', icon: 'info' },
];
export function visibleSettingsSections(supportsDesktopHotkey: boolean): SettingsNavigationItem[] {
return SETTINGS_SECTIONS.filter(item => item.id !== 'shortcuts' || supportsDesktopHotkey);
}
export interface SearchableSettingsSection extends SettingsNavigationItem {
title: string;
description: string;
keywords: string;
}
export function searchSettingsSections<T extends SearchableSettingsSection>(sections: T[], query: string): T[] {
const normalize = (text: string) => text.normalize('NFKC').toLocaleLowerCase();
const terms = normalize(query).trim().split(/\s+/).filter(Boolean);
return sections.filter(item => {
const text = normalize(`${item.title} ${item.description} ${item.keywords}`);
return terms.every(term => text.includes(term));
});
}
export type ServiceViewId = 'llm' | 'asr' | 'omni' | 'models' | 'connections';
export function availableServiceViews(multimodal: boolean, localModels: boolean): ServiceViewId[] {
return [
...(multimodal ? ['omni' as const] : ['llm' as const, 'asr' as const]),
...(localModels ? ['models' as const] : []),
'connections',
];
}
export function resolveServiceView(requested: ServiceViewId, available: ServiceViewId[]): ServiceViewId {
return available.includes(requested) ? requested : available[0];
}
@@ -0,0 +1,26 @@
import { createInstance } from 'i18next';
import { zhCN } from '../../i18n/zh-CN';
import { zhTW } from '../../i18n/zh-TW';
import { en } from '../../i18n/en';
import { ja } from '../../i18n/ja';
import { ko } from '../../i18n/ko';
import { SETTINGS_SECTIONS, searchSettingsSections } from './navigation';
// Resources must resolve where the dialog actually reads them, not in a nested
// feature's unrelated "modal" object. Type-shape parity alone cannot detect this.
for (const [locale, translation] of Object.entries({ 'zh-CN': zhCN, 'zh-TW': zhTW, en, ja, ko })) {
const i18n = createInstance();
await i18n.init({ lng: locale, fallbackLng: false, resources: { [locale]: { translation } } });
for (const key of ['modal.searchPlaceholder', 'modal.searchResults', 'modal.clearSearch', 'modal.noResults', 'startup.failed', 'startup.retry', ...['label', 'llm', 'asr', 'omni', 'models', 'connections'].map(view => `modal.serviceViews.${view}`)]) {
if (!i18n.exists(key)) throw new Error(`${locale}: missing visible label ${key}`);
}
const sections = SETTINGS_SECTIONS.map(item => {
for (const prefix of ['sections', 'descriptions', 'searchKeywords']) {
if (!i18n.exists(`modal.${prefix}.${item.id}`)) throw new Error(`${locale}: missing ${prefix} for ${item.id}`);
}
return { ...item, title: i18n.t(`modal.sections.${item.id}`), description: i18n.t(`modal.descriptions.${item.id}`), keywords: i18n.t(`modal.searchKeywords.${item.id}`) };
});
if (searchSettingsSections(sections, 'API')[0]?.id !== 'services') throw new Error(`${locale}: API search cannot reach service configuration`);
if (!i18n.t('modal.searchCount', { count: 2 }).includes('2')) throw new Error(`${locale}: result count is not rendered`);
}
console.log('settings localization and search routing passed in five languages');
+4 -11
View File
@@ -58,8 +58,7 @@ interface SettingRowProps {
controlWidth?: number | string
}
// 页面瘦身后描述小字不再常驻展示;desc 改为悬停在标签文字上时以 Tooltip 弹出,
// 布局保持紧凑的同时不牺牲可理解性。
// 设置的用途和后果直接可读,触屏和键盘用户无需依赖悬停。
export function SettingRow({
label,
desc,
@@ -87,15 +86,9 @@ export function SettingRow({
alignItems: "center",
}}
>
{/* display:flex 让 Tooltip 锚点收缩到文字宽度,提示贴着文字弹出。 */}
<div style={{ minWidth: 0, alignSelf: "center", display: "flex" }}>
{desc ? (
<Tooltip content={desc} wrap placement="bottom" focusable>
<span style={{ ...labelStyle, ...hintableTextStyle }}>{label}</span>
</Tooltip>
) : (
<div style={labelStyle}>{label}</div>
)}
<div style={{ minWidth: 0, alignSelf: "center" }}>
<div style={labelStyle}>{label}</div>
{desc && <div style={{ fontSize: 12, color: "var(--ol-ink-3)", lineHeight: 1.55, marginTop: 5, overflowWrap: "anywhere" }}>{desc}</div>}
</div>
<div
className="ol-flex-row"
+37 -13
View File
@@ -2,7 +2,7 @@
// 真正的逻辑都在各 *Section 文件里,这里只负责"哪些 section 归到哪个 tab"。
import { useTranslation } from 'react-i18next';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { RecordingInputSection } from './RecordingInputSection';
import { RemoteInputSection } from './RemoteInputSection';
import { ShortcutsSection } from './ShortcutsSection';
@@ -26,6 +26,8 @@ import { AboutSection } from './AboutSection';
import { detectOS } from '../../components/WindowChrome';
import { getPlatformCapabilities } from '../../lib/platform';
import type { PlatformCapabilities } from '../../lib/types';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
import { availableServiceViews, resolveServiceView, type ServiceViewId } from './navigation';
// 各 tab 共用的平台能力查询(决定桌面/移动、是否支持热键与自动更新等 gating)。
function usePlatformCaps(): PlatformCapabilities | null {
@@ -38,38 +40,60 @@ function usePlatformCaps(): PlatformCapabilities | null {
return platformCaps;
}
// 通用:录音与输入 · 远程输入 · 快捷键 · 主题 · 语言。
// 录音与输入:从录音到落字,以及手机输入。
export function GeneralTab() {
const platformCaps = usePlatformCaps();
const showDesktopShortcuts = platformCaps?.supportsDesktopHotkey === true;
const showRemoteInput = platformCaps?.platform === 'desktop';
return (
<>
<RecordingInputSection />
<LayoutSection />
{showRemoteInput && <RemoteInputSection />}
<SelectionWorkspaceSection />
{showDesktopShortcuts && <ShortcutsSection />}
<ThemeSection />
<LanguageSection />
</>
);
}
export function ShortcutsTab() {
const platformCaps = usePlatformCaps();
if (!platformCaps?.supportsDesktopHotkey) return null;
return <><ShortcutsSection /><SelectionWorkspaceSection /></>;
}
export function AppearanceTab() {
return <><ThemeSection /><LayoutSection /><LanguageSection /></>;
}
// 服务:AI 提供商 · 本地模型 · 扩展市场。
// 本地模型是「语音识别由谁提供」的一种答案,和云端提供商属同一决策,
// 不再藏进「高级」。
export function ServicesTab() {
const { t } = useTranslation();
const { prefs } = useHotkeySettings();
const platformCaps = usePlatformCaps();
const showLocalModel = platformCaps?.platform === 'desktop';
const showLocalModel = platformCaps?.supportsLocalAsr === true;
const multimodal = prefs?.multimodalPipelineEnabled === true && prefs.pipelineMode === 'multimodal';
const [view, setView] = useState<ServiceViewId>('llm');
const views = availableServiceViews(multimodal, showLocalModel);
const selectedView = resolveServiceView(view, views);
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
contentRef.current?.closest('.ol-thinscroll')?.scrollTo({ top: 0 });
}, [selectedView]);
return (
<>
<ProvidersSection />
<NetworkSection />
{showLocalModel && <LocalModelSection />}
<MarketplaceSection />
<div role="group" aria-label={t('modal.serviceViews.label')} className="ol-service-views ol-thinscroll">
{views.map(id => <button key={id} type="button" aria-pressed={selectedView === id} onClick={() => setView(id)}>{t(`modal.serviceViews.${id}`)}</button>)}
</div>
<div key={selectedView} ref={contentRef} className="ol-service-content">
{selectedView === 'llm' && <ProvidersSection kind="llm" />}
{selectedView === 'asr' && <ProvidersSection kind="asr" />}
{selectedView === 'omni' && <ProvidersSection />}
{selectedView === 'models' && <LocalModelSection />}
{selectedView === 'connections' && <><NetworkSection /><MarketplaceSection /></>}
{(selectedView === 'llm' || selectedView === 'asr') && <p className="ol-service-storage-note">{t('settings.providers.credentialStorageNotice')}</p>}
</div>
</>
);
}
+42
View File
@@ -296,6 +296,43 @@ html[data-ol-conservative-layout="true"] .ol-conservative-scope .ol-select-lite-
background: var(--ol-settings-content-bg);
}
.ol-service-views {
position: sticky;
top: 0;
z-index: 2;
background: var(--ol-settings-content-bg);
display: flex;
gap: 6px;
flex-shrink: 0;
overflow-x: auto;
border-bottom: 1px solid var(--ol-line);
margin-bottom: 4px;
}
.ol-service-views > button {
flex-shrink: 0;
padding: 12px 14px;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
font: inherit;
font-size: 13px;
font-weight: 500;
color: var(--ol-ink-3);
cursor: pointer;
}
.ol-service-views > button[aria-pressed="true"] {
color: var(--ol-blue);
border-bottom-color: var(--ol-blue);
font-weight: 650;
}
.ol-service-views > button:hover { color: var(--ol-ink); }
.ol-service-views > button:focus-visible { outline: 2px solid var(--ol-blue); outline-offset: -3px; }
.ol-service-content { display: flex; flex-direction: column; gap: 20px; min-width: 0; }
.ol-service-storage-note { margin: 0; font-size: 12px; line-height: 1.6; color: var(--ol-ink-3); }
.ol-settings-surface[data-ol-mobile="true"] {
border-radius: var(--ol-panel-radius) var(--ol-panel-radius) 0 0;
}
@@ -370,6 +407,11 @@ html[data-ol-platform="android"] .ol-capsule-pill {
}
}
.ol-page-header-actions > div {
max-width: 100%;
flex-wrap: wrap;
}
/* 降级(macOS 现行 WKWebView 尚无 scroll-driven animations):静态双边渐隐,
不随滚动位置变化;单层 mask 无需分层合成,把 composite 重置回默认,
规避老 WebKit 单层 + source-in 的兼容怪癖。限制同上:不可滚动的容器
+4 -4
View File
@@ -125,10 +125,10 @@
--ol-sidebar-settings-active-bg: linear-gradient(180deg, rgba(255,255,255,0.82), rgba(255,255,255,0.48));
--ol-nav-hover-bg: rgba(0, 0, 0, 0.04);
--ol-settings-rail-bg: linear-gradient(180deg, rgba(252,252,253,0.96), rgba(250,250,250,0.96));
--ol-settings-rail-bg: #fafafa;
--ol-settings-rail-border: rgba(255,255,255,0.52);
--ol-settings-links-border: rgba(255,255,255,0.46);
--ol-settings-content-bg: linear-gradient(180deg, rgba(255,255,255,0.96), rgba(252,252,253,0.96));
--ol-settings-content-bg: #ffffff;
--ol-settings-close-bg: rgba(255,255,255,0.38);
--ol-settings-close-hover-bg: rgba(255,255,255,0.72);
@@ -292,10 +292,10 @@
--ol-sidebar-settings-active-bg: linear-gradient(180deg, rgba(63, 63, 70, 0.86), rgba(39, 39, 42, 0.86));
--ol-nav-hover-bg: rgba(116,183,255,0.10);
--ol-settings-rail-bg: linear-gradient(180deg, rgba(20, 20, 23, 0.98), rgba(13, 13, 15, 0.98));
--ol-settings-rail-bg: #141417;
--ol-settings-rail-border: rgba(226,232,240,0.09);
--ol-settings-links-border: rgba(226,232,240,0.08);
--ol-settings-content-bg: linear-gradient(180deg, rgba(24, 24, 27, 0.98), rgba(20, 20, 23, 0.98));
--ol-settings-content-bg: #18181b;
--ol-settings-close-bg: rgba(255,255,255,0.05);
--ol-settings-close-hover-bg: rgba(116,183,255,0.14);