From 30e580fc37b439eb4dec4037c38cf262e5ffcfbb Mon Sep 17 00:00:00 2001 From: sim Date: Mon, 7 Sep 2026 12:51:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Core=202.0=20=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E3=80=81=E8=AE=BE=E7=BD=AE=E4=B8=8E=E5=B8=83=E5=B1=80=E6=95=B4?= =?UTF-8?q?=E7=90=86=EF=BC=8CLinux=20=E5=BC=95=E5=AF=BC=E4=B8=8E=E8=A7=86?= =?UTF-8?q?=E8=A7=89=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 主页前置下一步与真实服务状态,服务卡直达对应设置;概览与热力图排布调整 - 设置按任务分类并支持搜索、焦点返回与 Escape 语义;旧 section ID 保持稳定 - AI 分区拆分语言模型/语音识别/本地模型/连接;渠道列表与表单重构,设置实底 - 本地模型管理与下载目录重排,补空态、主操作与窄屏布局 - Core 生成 provider descriptor 公开目录供浏览器预览懒加载,原生 IPC 合同不变 - Linux egui 按开始/听写/AI 服务/模型/工具/手机输入/历史/设置组织页面并加环境引导 - 五语言文案与文档(体验梳理、架构、计划收口)同步 --- docs/2.0-ux-review.md | 85 ++ docs/architecture.md | 49 + docs/index.md | 13 + docs/knowledge/2026-09-06-linux-setup.md | 11 + docs/linux-egui-handoff/02-gap-register.md | 12 +- docs/linux-egui-handoff/04-ui-domains.md | 68 +- docs/plans/active/2026-09-06-core-2-ux.md | 67 ++ .../examples/export_provider_descriptors.rs | 24 + openless-all/app/linux-egui/src/main.rs | 997 ++++++++++++++-- openless-all/app/linux-egui/src/ui_state.rs | 119 ++ openless-all/app/src/App.tsx | 13 +- .../app/src/components/CoreStartupScreen.tsx | 25 + .../app/src/components/FloatingShell.tsx | 23 +- .../app/src/components/SettingsModal.tsx | 433 +++---- .../app/src/components/ShortcutRecorder.tsx | 12 +- .../app/src/components/ui/SelectLite.tsx | 8 + openless-all/app/src/i18n/en.ts | 153 ++- openless-all/app/src/i18n/ja.ts | 153 ++- openless-all/app/src/i18n/ko.ts | 153 ++- openless-all/app/src/i18n/zh-CN.ts | 153 ++- openless-all/app/src/i18n/zh-TW.ts | 153 ++- .../ipc/provider-descriptors.generated.json | 450 ++++++++ .../app/src/lib/ipc/providers.test.ts | 15 + openless-all/app/src/lib/ipc/providers.ts | 10 +- .../app/src/lib/overviewSetup.test.ts | 65 ++ openless-all/app/src/lib/overviewSetup.ts | 54 + openless-all/app/src/pages/History.tsx | 10 +- .../app/src/pages/LocalAsr/components.tsx | 1028 +++++------------ openless-all/app/src/pages/LocalAsr/index.tsx | 155 +-- .../app/src/pages/LocalAsr/local-asr.css | 108 ++ openless-all/app/src/pages/Overview.tsx | 259 +++-- openless-all/app/src/pages/SelectionAsk.tsx | 2 +- openless-all/app/src/pages/Translation.tsx | 4 +- openless-all/app/src/pages/_atoms.tsx | 9 +- .../app/src/pages/settings/ChannelList.tsx | 488 ++++---- .../src/pages/settings/LocalModelSection.tsx | 51 +- .../src/pages/settings/ProvidersSection.tsx | 144 ++- .../app/src/pages/settings/navigation.test.ts | 34 + .../app/src/pages/settings/navigation.ts | 49 + .../pages/settings/navigationLocale.test.ts | 26 + .../app/src/pages/settings/shared.tsx | 15 +- openless-all/app/src/pages/settings/tabs.tsx | 50 +- openless-all/app/src/styles/global.css | 42 + openless-all/app/src/styles/tokens.css | 8 +- 44 files changed, 3955 insertions(+), 1845 deletions(-) create mode 100644 docs/2.0-ux-review.md create mode 100644 docs/architecture.md create mode 100644 docs/index.md create mode 100644 docs/knowledge/2026-09-06-linux-setup.md create mode 100644 docs/plans/active/2026-09-06-core-2-ux.md create mode 100644 openless-all/app/crates/openless-core/examples/export_provider_descriptors.rs create mode 100644 openless-all/app/linux-egui/src/ui_state.rs create mode 100644 openless-all/app/src/components/CoreStartupScreen.tsx create mode 100644 openless-all/app/src/lib/ipc/provider-descriptors.generated.json create mode 100644 openless-all/app/src/lib/ipc/providers.test.ts create mode 100644 openless-all/app/src/lib/overviewSetup.test.ts create mode 100644 openless-all/app/src/lib/overviewSetup.ts create mode 100644 openless-all/app/src/pages/LocalAsr/local-asr.css create mode 100644 openless-all/app/src/pages/settings/navigation.test.ts create mode 100644 openless-all/app/src/pages/settings/navigation.ts create mode 100644 openless-all/app/src/pages/settings/navigationLocale.test.ts diff --git a/docs/2.0-ux-review.md b/docs/2.0-ux-review.md new file mode 100644 index 00000000..7ac6eaf6 --- /dev/null +++ b/docs/2.0-ux-review.md @@ -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` 工作区,未提交或推送。 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..83c7db8e --- /dev/null +++ b/docs/architecture.md @@ -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),不要将列出的命令视作已经执行。 diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..7b16c3c4 --- /dev/null +++ b/docs/index.md @@ -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)。这些是带日期的实现与测试记录,不替代当前范围或新版本的设备验收。 diff --git a/docs/knowledge/2026-09-06-linux-setup.md b/docs/knowledge/2026-09-06-linux-setup.md new file mode 100644 index 00000000..adc9d47b --- /dev/null +++ b/docs/knowledge/2026-09-06-linux-setup.md @@ -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可访问。 diff --git a/docs/linux-egui-handoff/02-gap-register.md b/docs/linux-egui-handoff/02-gap-register.md index ed26a185..3a65a361 100644 --- a/docs/linux-egui-handoff/02-gap-register.md +++ b/docs/linux-egui-handoff/02-gap-register.md @@ -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-准备引导),不在此重复维护。 diff --git a/docs/linux-egui-handoff/04-ui-domains.md b/docs/linux-egui-handoff/04-ui-domains.md index 27258c85..a481ede8 100644 --- a/docs/linux-egui-handoff/04-ui-domains.md +++ b/docs/linux-egui-handoff/04-ui-domains.md @@ -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)。 diff --git a/docs/plans/active/2026-09-06-core-2-ux.md b/docs/plans/active/2026-09-06-core-2-ux.md new file mode 100644 index 00000000..a32e669b --- /dev/null +++ b/docs/plans/active/2026-09-06-core-2-ux.md @@ -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编译/真机与真实服务验收仍在已有交接清单内;本任务没有宣称完成这些产品发布门。 diff --git a/openless-all/app/crates/openless-core/examples/export_provider_descriptors.rs b/openless-all/app/crates/openless-core/examples/export_provider_descriptors.rs new file mode 100644 index 00000000..ee90a44c --- /dev/null +++ b/openless-all/app/crates/openless-core/examples/export_provider_descriptors.rs @@ -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(()) +} diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index ce7002bd..35cb4b65 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -1,3 +1,6 @@ +#[cfg(any(target_os = "linux", test))] +mod ui_state; + #[cfg(not(target_os = "linux"))] fn main() { eprintln!("openless-linux-egui is only available on Linux"); @@ -5,6 +8,7 @@ fn main() { #[cfg(target_os = "linux")] mod linux_app { + use super::ui_state::{Navigation, Page}; use std::future::Future; use std::sync::mpsc; use std::sync::Arc; @@ -20,11 +24,12 @@ mod linux_app { use openless_linux_egui::{ drain_events, ensure_fcitx5_plugin_installed, EventDrainOutcome, Fcitx5HotkeyListener, FcitxPluginInstallPlan, FcitxPluginStatus, LinuxBackendBuilder, LinuxCapabilitySnapshot, - LinuxLaunchIntent, LinuxNativeRuntime, LinuxPackageKind, LinuxResourceLayout, - SingleInstanceBroker, SingleInstanceRole, + LinuxDesktopSession, LinuxLaunchIntent, LinuxNativeRuntime, LinuxPackageKind, + LinuxResourceLayout, SingleInstanceBroker, SingleInstanceRole, }; enum UiResult { + Environment(LinuxCapabilitySnapshot), Message(String), Models(Result, String>), Remote(Result<(openless_core::RemoteInputStatus, String), String>), @@ -92,6 +97,12 @@ mod linux_app { } pub struct OpenLessEguiApp { + navigation: Navigation, + environment: Option, + environment_refreshing: bool, + plugin_check: Option>, + less_computer_running: bool, + remote_error: Option, tokio: Arc, native: Option, subscription: Option, @@ -141,6 +152,12 @@ mod linux_app { let preferences = backend.get_preferences(); let subscription = backend.subscribe(); let app = Self { + navigation: Navigation::default(), + environment: None, + environment_refreshing: false, + plugin_check: None, + less_computer_running: false, + remote_error: None, tokio, native: Some(native), subscription: Some(subscription), @@ -182,6 +199,12 @@ mod linux_app { app } Err(error) => Self { + navigation: Navigation::default(), + environment: None, + environment_refreshing: false, + plugin_check: None, + less_computer_running: false, + remote_error: None, tokio, native: None, subscription: None, @@ -371,6 +394,7 @@ mod linux_app { let session_id = event.session_id; match event.kind { BackendEventKind::DictationStateChanged(state) => { + self.navigation.notify(Page::Dictation); if state.phase == DictationPhase::Starting { self.transcript_state = TranscriptAccumulator::default(); self.transcript.clear(); @@ -389,6 +413,7 @@ mod linux_app { self.transcript = delta.text; } BackendEventKind::DictationCompleted(result) => { + self.navigation.notify(Page::Dictation); self.transcript = result.polished_text; self.status = format!("听写完成:{:?}", result.inserted); } @@ -408,6 +433,13 @@ mod linux_app { } } BackendEventKind::LessComputerEvent(event) => { + // Voice capture has its own session, preceding a chat User + // turn. Keep a navigation notice without assigning it to + // the current chat or inventing microphone readiness. + if matches!(&event.kind, LessComputerEventKind::VoiceState { .. }) { + self.navigation.notify(Page::Agent); + return; + } // Less Computer events may complete after a newer turn has // already started. Session ownership, not arrival time, // decides whether a delta/terminal may mutate this view. @@ -416,6 +448,7 @@ mod linux_app { // continuation. `fresh` describes conversation history, // never whether this turn is allowed to receive output. self.less_computer_session = session_id; + self.less_computer_running = true; self.pending_approval = None; if *fresh { self.less_computer_output.clear(); @@ -427,11 +460,13 @@ mod linux_app { } else if session_id != self.less_computer_session { return; } + self.navigation.notify(Page::Agent); match event.kind { // Linux已有独立录音显示;新typed反馈供接手Host/UI团队继续接入。 LessComputerEventKind::VoiceState { .. } => {} LessComputerEventKind::User { .. } => {} LessComputerEventKind::Started => { + self.less_computer_running = true; self.status = "Less Computer 正在运行".to_string(); } LessComputerEventKind::Delta { text } => { @@ -444,6 +479,7 @@ mod linux_app { self.status = "Less Computer 已压缩上下文".to_string(); } LessComputerEventKind::Completed { text, .. } => { + self.less_computer_running = false; // A terminal is authoritative even for final-only // providers or after a missed partial event. self.less_computer_output @@ -457,16 +493,19 @@ mod linux_app { self.status = "Less Computer 等待审批".to_string(); } LessComputerEventKind::Error { message } => { + self.less_computer_running = false; self.pending_approval = None; self.status = message; } LessComputerEventKind::Cancelled => { + self.less_computer_running = false; self.pending_approval = None; self.status = "Less Computer 已取消".to_string(); } } } BackendEventKind::LocalAsrDownloadProgress(progress) => { + self.navigation.notify(Page::Models); self.status = format!( "模型 {}:{:?} {}/{}", progress.model_id, @@ -497,6 +536,7 @@ mod linux_app { .as_mut() .filter(|current| current.session_id == state.session_id) { + self.navigation.notify(Page::Qa); // Core deltas deliberately omit messages. Preserve // the conversation and append only this turn's text; // the following Answer replaces it with Core history. @@ -517,10 +557,12 @@ mod linux_app { .as_ref() .is_none_or(|current| current.session_id == state.session_id) { + self.navigation.notify(Page::Qa); self.qa_state = Some(state); } } BackendEventKind::SelectionStateChanged(snapshot) => { + self.navigation.notify(Page::Selection); if snapshot.phase == SelectionPhase::Preview { self.selection_draft = snapshot.preview_text.clone().unwrap_or_default(); self.selection_preview_visible = true; @@ -528,7 +570,10 @@ mod linux_app { self.selection = Some(snapshot); } BackendEventKind::RemoteInputStatusChanged(_) - | BackendEventKind::RemoteInputFailed(_) => self.load_remote_status(), + | BackendEventKind::RemoteInputFailed(_) => { + self.navigation.notify(Page::Remote); + self.load_remote_status(); + } _ => {} } } @@ -562,7 +607,11 @@ mod linux_app { // back through sequenced Core events handled above. for action in actions { match action { - HostAction::ShowMain | HostAction::ShowLessComputer => { + HostAction::ShowMain => { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + } + HostAction::ShowLessComputer => { + self.navigation.open(Page::Agent); ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); } HostAction::FocusMain => { @@ -578,6 +627,7 @@ mod linux_app { self.status = "请手动重启 OpenLess".to_string(); } HostAction::ShowSelectionPreview => { + self.navigation.open(Page::Selection); self.selection_preview_visible = true; ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); } @@ -585,6 +635,7 @@ mod linux_app { self.selection_preview_visible = false; } HostAction::ShowQa => { + self.navigation.open(Page::Qa); self.qa_visible = true; ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); } @@ -619,6 +670,7 @@ mod linux_app { self.less_computer_output.clear(); self.less_computer_turn_start = 0; self.less_computer_session = None; + self.less_computer_running = false; self.pending_approval = None; self.qa_state = None; self.qa_visible = false; @@ -639,14 +691,25 @@ mod linux_app { } while let Ok(result) = self.rx.try_recv() { match result { + UiResult::Environment(environment) => { + self.environment = Some(environment); + self.environment_refreshing = false; + } UiResult::Message(message) => self.status = message, UiResult::Models(Ok(models)) => self.models = ModelsState::Loaded(models), UiResult::Models(Err(error)) => { self.models = ModelsState::Failed(error.clone()); self.status = error; } - UiResult::Remote(Ok(remote)) => self.remote_access = Some(remote), - UiResult::Remote(Err(error)) => self.status = error, + UiResult::Remote(Ok(remote)) => { + self.remote_error = None; + self.remote_access = Some(remote); + } + UiResult::Remote(Err(error)) => { + self.remote_access = None; + self.remote_error = Some(error.clone()); + self.status = error; + } UiResult::Providers(Ok(panel)) => { if panel.kind != self.provider_kind { continue; @@ -833,8 +896,21 @@ mod linux_app { } } }); + ui.label(if self.less_computer_output.is_empty() { + "尚无 Agent 输出" + } else { + &self.less_computer_output + }); + } + + fn agent_approval_ui(&mut self, ui: &mut egui::Ui) { if let Some((token, command)) = self.pending_approval.clone() { - ui.label(format!("请求执行:{command}")); + egui::ScrollArea::vertical() + .id_salt("approval_command") + .max_height(72.0) + .show(ui, |ui| { + ui.label(format!("请求执行:{command}")); + }); ui.horizontal(|ui| { for (label, approved) in [("允许", true), ("拒绝", false)] { if ui.button(label).clicked() { @@ -854,18 +930,22 @@ mod linux_app { } }); } - ui.label(if self.less_computer_output.is_empty() { - "尚无 Agent 输出" - } else { - &self.less_computer_output - }); } fn qa_ui(&mut self, ui: &mut egui::Ui) { + ui.heading("问答"); if !self.qa_visible { + ui.label("打开问答后可文字提问或语音提问。切换页面会保留当前会话;关闭会话使用下方的关闭操作。"); + if ui.button("打开问答").clicked() { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.services().qa.show().await?; + Ok("问答已打开".to_string()) + }); + } + } return; } - ui.heading("问答"); if let Some(state) = &self.qa_state { if let Some(messages) = &state.messages { for message in messages { @@ -917,15 +997,33 @@ mod linux_app { }); } } + if ui.button("取消本轮").clicked() { + if let Some(backend) = self.backend() { + let session_id = self + .qa_state + .as_ref() + .and_then(|state| state.session_id.as_deref()) + .and_then(|id| uuid::Uuid::parse_str(id).ok()) + .map(openless_core::SessionId::from_uuid); + self.spawn(async move { + backend.services().qa.cancel(session_id).await?; + Ok("问答本轮已取消".to_string()) + }); + } + } }); } fn selection_ui(&mut self, ui: &mut egui::Ui) { + ui.heading("选区润色"); let Some(selection) = self.selection.clone() else { + ui.label("先在目标应用中选中文字,再使用已配置的选区润色快捷键。预览会在此显示,确认前可以编辑或取消。"); + ui.small("此入口是现有 Selection polish;Selection Voice 的完整意图路由尚未接入。"); return; }; + ui.label(format!("当前状态:{:?}", selection.phase)); if self.selection_preview_visible && selection.phase == SelectionPhase::Preview { - ui.heading("选区预览"); + ui.strong("选区预览"); ui.text_edit_multiline(&mut self.selection_draft); ui.horizontal(|ui| { if ui.button("确认替换").clicked() { @@ -1384,8 +1482,9 @@ mod linux_app { } } - fn settings_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("Provider 与设置"); + fn services_ui(&mut self, ui: &mut egui::Ui) { + ui.heading("AI 服务"); + ui.label("选择 ASR 语音识别、LLM 文本处理或 Omni 服务,再编辑并校验渠道。已配置不代表网络请求已通过。"); if let Some(snapshot) = &self.snapshot { let credentials = &snapshot.credentials; ui.label(format!( @@ -1408,72 +1507,446 @@ mod linux_app { )); } self.provider_management_ui(ui); - ui.separator(); - let mut remote_update = None; + } + + fn save_preferences(&mut self) { + let (Some(native), Some(snapshot), Some(preferences)) = + (&self.native, &self.snapshot, &self.preferences) + else { + return; + }; + match native + .host() + .save_settings(preferences.clone(), snapshot.preferences_revision) + { + Ok(_) => { + self.status = "设置已保存".to_string(); + let config = openless_core::RemoteInputConfig { + enabled: preferences.remote_input_enabled, + port: preferences.remote_input_port, + }; + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.services().remote_input.configure(config).await?; + Ok("远程输入状态已更新".to_string()) + }); + } + } + Err(error) => self.status = error.to_string(), + } + } + + fn settings_actions_ui(&mut self, ui: &mut egui::Ui) { + ui.horizontal_wrapped(|ui| { + if ui.button("保存设置").clicked() { + self.save_preferences(); + } + if ui.button("放弃修改并重新读取").clicked() { + if let Some(backend) = self.backend() { + self.preferences = Some(backend.get_preferences()); + self.snapshot = Some(backend.snapshot()); + self.status = "已重新读取设置".to_string(); + } + } + }); + ui.small( + "环境与设置、手机输入共用设置草稿;保存会一起应用。保存冲突时可重新读取后再修改。", + ); + } + + fn settings_ui(&mut self, ui: &mut egui::Ui) { + ui.heading("环境与设置"); + ui.strong("现有功能设置"); if let Some(preferences) = self.preferences.as_mut() { ui.checkbox(&mut preferences.streaming_insert, "流式插入"); + ui.small("将转写逐步发送到原输入目标,实际结果以听写与历史反馈为准。"); ui.checkbox(&mut preferences.coding_agent_enabled, "启用 Less Computer"); + ui.small("使用已有 Agent 配置与 CLI;进程执行仍遵循 Core 的审批规则。"); + self.settings_actions_ui(ui); + } + ui.horizontal_wrapped(|ui| { + if ui.button("配置 AI 服务").clicked() { + self.navigation.open(Page::Services); + } + if ui.button("设置手机输入").clicked() { + self.navigation.open(Page::Remote); + } + }); + ui.small( + "托盘、自启、自动更新、系统静音与额外全局热键尚未完整接入,此页没有对应开关。", + ); + ui.separator(); + self.environment_ui(ui); + } + + fn remote_ui(&mut self, ui: &mut egui::Ui) { + ui.heading("手机输入"); + ui.label( + "先启用并保存,再让手机连接同一局域网,打开本机提供的 HTTPS 地址并输入配对码。", + ); + ui.label("首次连接需要确认并信任本服务的证书;服务运行不代表手机已连接。"); + if let Some(preferences) = self.preferences.as_mut() { ui.checkbox(&mut preferences.remote_input_enabled, "启用远程输入"); ui.add( egui::DragValue::new(&mut preferences.remote_input_port) .range(1..=u16::MAX) .prefix("端口 "), ); - if ui.button("保存设置").clicked() { - if let (Some(native), Some(snapshot)) = (&self.native, &self.snapshot) { - match native - .host() - .save_settings(preferences.clone(), snapshot.preferences_revision) - { - Ok(_) => { - self.status = "设置已保存".to_string(); - remote_update = Some(openless_core::RemoteInputConfig { - enabled: preferences.remote_input_enabled, - port: preferences.remote_input_port, - }); - } - Err(error) => self.status = error.to_string(), - } - } - } + self.settings_actions_ui(ui); } - if let (Some(config), Some(backend)) = (remote_update, self.backend()) { - self.spawn(async move { - backend.services().remote_input.configure(config).await?; - Ok("远程输入状态已更新".to_string()) - }); + ui.separator(); + if ui.button("刷新连接状态").clicked() { + self.load_remote_status(); + } + if let Some(error) = &self.remote_error { + ui.colored_label( + egui::Color32::YELLOW, + format!("暂时无法读取连接状态:{error}"), + ); + ui.label("检查桌面密钥环、网络和端口后刷新;旧地址与配对码已隐藏。"); + } else if self.remote_access.is_none() { + ui.label("尚未取得手机输入状态。"); } if let Some((remote, pin)) = &self.remote_access { ui.label(if remote.running { - "远程输入:运行中" + "远程输入服务:运行中" } else if remote.starting { - "远程输入:启动中" + "远程输入服务:启动中" } else { - "远程输入:已停止" + "远程输入服务:已停止" }); - if remote.enabled { + ui.label(format!("当前连接数:{}", remote.connection_count)); + if remote.active_session_id.is_some() { + ui.label("手机语音会话进行中,可使用顶部的语音取消。"); + } + if remote.urls_stale { + ui.colored_label( + egui::Color32::YELLOW, + "网络地址已过期,请检查网络后刷新状态。", + ); + } + if remote.enabled && remote.running && !remote.urls_stale { ui.monospace(format!("PIN:{pin}")); for url in &remote.urls { ui.monospace(url); } - if ui.button("重置配对码").clicked() { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend - .services() - .remote_input - .regenerate_pairing_pin() - .await?; - Ok("远程输入配对码已重置".to_string()) - }); - } + if remote.urls.is_empty() { + ui.label("服务已启动,但尚未提供可用地址;请检查本机局域网连接。"); + } + } + if remote.enabled && ui.button("重置配对码").clicked() { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend + .services() + .remote_input + .regenerate_pairing_pin() + .await?; + Ok("远程输入配对码已重置".to_string()) + }); } } } } + fn environment_ui(&mut self, ui: &mut egui::Ui) { + ui.strong("Linux 环境准备"); + ui.label(if self.native.is_some() { + "Core 已连接。下面的环境检查不代表录音、落字或服务调用已经实测成功。" + } else { + "Core 未连接。可查看准备步骤;修复启动问题后,请退出并重新启动 OpenLess。" + }); + if let Some(environment) = &self.environment { + ui.label(match environment.session { + LinuxDesktopSession::X11 => "桌面会话:检测到 X11 环境", + LinuxDesktopSession::Wayland => "桌面会话:检测到 Wayland 环境", + LinuxDesktopSession::Headless => "桌面会话:未检测到 DISPLAY / WAYLAND_DISPLAY", + }); + ui.label(if environment.fcitx5_ready { + "fcitx5:D-Bus 探测有响应,插件加载、快捷键与目标应用落字仍需实际操作确认。" + } else { + "fcitx5:D-Bus 探测未通过,可能是会话总线、服务或插件未就绪。" + }); + ui.label(match environment.permissions.microphone { + openless_core::PermissionState::Unsupported => { + "麦克风:当前探测环境不支持;请进入图形桌面会话。" + } + _ => "麦克风:尚未验证录音。请在系统声音设置选择输入设备,再进行一次短听写。", + }); + } else { + ui.label("尚未取得桌面环境探测结果。"); + } + ui.label(match &self.plugin_check { + Some(Ok(FcitxPluginStatus::Ready)) => { + "本次启动插件检查:找到插件文件;文件存在不代表 fcitx5 已加载它。" + } + Some(Ok(FcitxPluginStatus::Updated)) => { + "本次启动插件检查:插件文件已安装或更新,需要重载配置并重新启动 fcitx5。" + } + Some(Ok(FcitxPluginStatus::Missing)) => { + "本次启动插件检查:未找到插件文件,请重新安装含 OpenLess 插件的软件包。" + } + Some(Err(_)) => "本次启动插件检查:检查失败,请查看下方具体原因。", + None => "本次启动插件检查:未执行。", + }); + if let Some(Err(error)) = &self.plugin_check { + ui.colored_label(egui::Color32::YELLOW, error); + } + if ui + .add_enabled( + !self.environment_refreshing, + egui::Button::new(if self.environment_refreshing { + "正在检测…" + } else { + "重新检测会话与 D-Bus" + }), + ) + .clicked() + { + self.environment_refreshing = true; + let tx = self.tx.clone(); + self.tokio.spawn_blocking(move || { + let environment = LinuxCapabilitySnapshot::detect(false, package_kind()); + let _ = tx.send(UiResult::Environment(environment)); + }); + } + ui.small("重新检测只更新上面的会话与 D-Bus 信息,不安装插件,也不重新连接 Core。本次启动检查结果保留到退出。"); + egui::CollapsingHeader::new("准备步骤与官方指南") + .default_open(self.native.is_none()) + .show(ui, |ui| { + ui.separator(); + ui.strong("1 · 准备输入法与桌面会话"); + ui.label("在当前图形桌面安装并启用 fcitx5,再安装含 OpenLess 插件的当前软件包。先在普通编辑器中确认输入法可以输入。"); + ui.label("在终端运行以下诊断,查看输入法环境与插件加载信息:"); + command_ui(ui, "fcitx5-diagnose"); + ui.label("插件安装或更新后可先重载配置;若插件仍未加载,退出并重新登录桌面,再启动 OpenLess:"); + command_ui(ui, "fcitx5-remote -r"); + ui.horizontal_wrapped(|ui| { + ui.hyperlink_to( + "Fcitx 5 官方设置指南", + "https://fcitx-im.org/wiki/Setup_Fcitx_5", + ); + ui.hyperlink_to( + "Wayland 桌面配置差异", + "https://fcitx-im.org/wiki/Using_Fcitx_5_on_Wayland", + ); + }); + ui.small("Wayland 的输入法配置取决于桌面和应用工具包,请按官方对应章节配置;检测到 Wayland 不代表所有目标应用都支持替换。X11 的 overlay 能力标记也不代表本应用已接入录音浮层。"); + ui.separator(); + ui.strong("2 · 准备密钥环与识别服务"); + ui.label("Secret Service:当前没有独立的服务连接或解锁状态检测;渠道显示“已配置”也不能证明密钥环现在可读写。"); + ui.label("打开桌面的密码/密钥环管理器,确认当前登录会话的密钥环已解锁。然后到 AI 服务选择渠道,填写所需凭据、保存并校验;若返回锁定或访问失败,解锁后重试。"); + ui.hyperlink_to( + "Secret Service 官方规范", + "https://specifications.freedesktop.org/secret-service/latest/", + ); + ui.small("API 密钥输入只用于写入,不回显已有密钥。本地识别可在本地模型页下载并激活 Generic Qwen。"); + ui.separator(); + ui.strong("3 · 做一次短听写"); + ui.label("在系统声音设置确认输入设备有电平。配置识别服务后,在目标编辑器聚焦输入框,用已有听写快捷键录制一句话并结束,检查转写和落字结果。问答、选区润色与 Agent 分别从导航进入。"); + ui.small("请分别验证你使用的 X11/Wayland、GTK/Qt/浏览器/终端。托盘、自启和应用内自动更新仍未完整接入。"); + }); + } + + fn start_ui(&mut self, ui: &mut egui::Ui) { + ui.heading("从一次听写开始"); + ui.label("先准备 Linux 输入环境,再选择识别服务。切换页面不会停止正在进行的任务。"); + if let Some(error) = &self.startup_error { + ui.colored_label(egui::Color32::YELLOW, format!("启动未完成:{error}")); + } + if let Some(snapshot) = &self.snapshot { + ui.label(if snapshot.running { + "Core:运行中" + } else { + "Core:未运行" + }); + let credentials = &snapshot.credentials; + match credentials.pipeline_mode { + openless_core::shared_types::PipelineMode::Multimodal => { + ui.label("当前管线:多模态(Omni)"); + ui.label(if credentials.omni_configured { + "Omni:已配置。" + } else { + "Omni:尚未配置,请到 AI 服务配置 Omni。" + }); + } + openless_core::shared_types::PipelineMode::Traditional => { + ui.label("当前管线:传统(ASR + LLM)"); + ui.label(if credentials.asr_configured { + "ASR 语音识别:已配置。" + } else { + "ASR 语音识别:尚未配置,请配置 AI 服务或激活本地模型。" + }); + ui.label(if credentials.llm_configured { + "LLM 润色:已配置。" + } else { + "LLM 润色:尚未配置。" + }); + } + } + ui.small("已配置不代表校验通过;请到 AI 服务验证连接。"); + } + ui.horizontal_wrapped(|ui| { + for (page, label) in [ + (Page::Settings, "1. 准备环境"), + (Page::Services, "2. 配置 AI 服务"), + (Page::Models, "使用本地模型"), + (Page::Dictation, "3. 打开听写"), + ] { + if ui + .add_enabled( + self.native.is_some() || page == Page::Settings, + egui::Button::new(label), + ) + .clicked() + { + self.navigation.open(page); + } + } + }); + ui.separator(); + if self.native.is_none() { + self.environment_ui(ui); + } else { + ui.strong("继续其他任务"); + ui.horizontal_wrapped(|ui| { + for page in [ + Page::Qa, + Page::Selection, + Page::Agent, + Page::Remote, + Page::History, + ] { + if ui.button(page.label()).clicked() { + self.navigation.open(page); + } + } + }); + ui.label("问答支持文字与语音;选区润色保留确认、取消与撤销;Less Computer 的工具执行继续使用原有审批。"); + ui.small( + "Linux 当前提供已有 Core / Host 能力的入口,完整原生支持与发布验收仍在继续。", + ); + } + } + + fn page_activity(&self, page: Page) -> Option<&'static str> { + match page { + Page::Dictation + if self.snapshot.as_ref().is_some_and(|snapshot| { + matches!( + snapshot.dictation.phase, + DictationPhase::Starting + | DictationPhase::Recording + | DictationPhase::Transcribing + | DictationPhase::Polishing + | DictationPhase::Inserting + ) + }) => + { + Some("进行中") + } + Page::Qa if self.qa_visible => Some("会话"), + Page::Selection + if self.selection_preview_visible + && self.selection.as_ref().is_some_and(|selection| { + selection.phase == SelectionPhase::Preview + }) => + { + Some("待确认") + } + Page::Agent if self.pending_approval.is_some() => Some("待审批"), + Page::Agent if self.less_computer_running => Some("进行中"), + _ if self.navigation.has_update(page) => Some("有更新"), + _ => None, + } + } + + fn navigation_button(&mut self, ui: &mut egui::Ui, page: Page) { + let label = match self.page_activity(page) { + Some(activity) => format!("{} · {activity}", page.label()), + None => page.label().to_string(), + }; + if ui + .selectable_label(self.navigation.page == page, label) + .clicked() + { + self.navigation.open(page); + } + } + + fn activity_ui(&mut self, ui: &mut egui::Ui) { + ui.horizontal_wrapped(|ui| { + for page in Page::ALL { + if let Some(activity) = self.page_activity(page) { + if ui + .link(format!("{} · {activity} →", page.label())) + .clicked() + { + self.navigation.open(page); + } + } + } + if self.native.is_some() && ui.button("取消当前语音 · Esc").clicked() { + self.cancel_voice(); + } + if self.qa_visible && ui.button("取消问答").clicked() { + if let Some(backend) = self.backend() { + let session_id = self + .qa_state + .as_ref() + .and_then(|state| state.session_id.as_deref()) + .and_then(|id| uuid::Uuid::parse_str(id).ok()) + .map(openless_core::SessionId::from_uuid); + self.spawn(async move { + backend.services().qa.cancel(session_id).await?; + Ok("问答本轮已取消".to_string()) + }); + } + } + if let Some(session_id) = self + .selection + .as_ref() + .filter(|selection| selection.phase == SelectionPhase::Preview) + .and_then(|selection| selection.session_id) + { + if ui.button("取消选区预览").clicked() { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend + .services() + .selection + .cancel(Some(session_id)) + .await?; + Ok("选区替换已取消".to_string()) + }); + } + } + } + if (self.less_computer_running || self.pending_approval.is_some()) + && ui.button("取消 Agent").clicked() + { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.cancel_less_computer(None).await?; + Ok("Less Computer 已取消".to_string()) + }); + } + } + }); + } + + fn cancel_voice(&self) { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.cancel_active_voice_session(None).await?; + Ok("语音会话已取消".to_string()) + }); + } + } + fn history_ui(&mut self, ui: &mut egui::Ui) { ui.heading("历史"); + ui.label("最近 20 条,只读。插入、复制回退与已发送粘贴分别显示实际结果。"); let Some(backend) = self.backend() else { return; }; @@ -1505,50 +1978,96 @@ mod linux_app { impl eframe::App for OpenLessEguiApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Poll every frame before routing pages. Hidden pages retain their + // drafts, session ownership, event replay and async completion paths. self.poll(ctx); if ctx.input(|input| input.key_pressed(egui::Key::Escape)) { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.cancel_active_voice_session(None).await?; - Ok("语音会话已取消".to_string()) - }); - } + self.cancel_voice(); } egui::TopBottomPanel::top("status").show(ctx, |ui| { ui.horizontal(|ui| { ui.strong("OpenLess 2.0"); ui.separator(); - ui.label(&self.status); + ui.add(egui::Label::new(&self.status).truncate()) + .on_hover_text(&self.status); }); - }); - egui::CentralPanel::default().show(ctx, |ui| { - if let Some(error) = &self.startup_error { - ui.heading("启动失败"); - ui.colored_label(egui::Color32::RED, error); - return; + self.activity_ui(ui); + if self.pending_approval.is_some() { + ui.strong("Less Computer 等待审批"); + self.agent_approval_ui(ui); } - egui::ScrollArea::vertical().show(ui, |ui| { - self.dictation_ui(ui); - ui.separator(); - self.qa_ui(ui); - if self.qa_visible { - ui.separator(); - } - self.selection_ui(ui); - ui.separator(); - self.less_computer_ui(ui); - ui.separator(); - self.models_ui(ui); - ui.separator(); - self.settings_ui(ui); - ui.separator(); - self.history_ui(ui); + }); + if ctx.screen_rect().width() < 760.0 { + egui::TopBottomPanel::top("compact_navigation").show(ctx, |ui| { + ui.horizontal_wrapped(|ui| { + for page in Page::ALL { + self.navigation_button(ui, page); + } + }); }); + } else { + egui::SidePanel::left("navigation") + .resizable(false) + .default_width(176.0) + .show(ctx, |ui| { + egui::ScrollArea::vertical() + .id_salt("navigation_scroll") + .show(ui, |ui| { + ui.strong("工作空间"); + ui.add_space(8.0); + for page in Page::ALL { + if page == Page::Services { + ui.separator(); + ui.strong("准备与管理"); + } + self.navigation_button(ui, page); + } + }); + }); + } + egui::CentralPanel::default().show(ctx, |ui| { + let page = self.navigation.page; + egui::ScrollArea::vertical() + .id_salt(("page", page)) + .show(ui, |ui| { + if self.native.is_none() && !matches!(page, Page::Start | Page::Settings) { + ui.heading(page.label()); + ui.label("Core 尚未连接,请先完成 Linux 环境准备并重新启动应用。"); + if let Some(error) = &self.startup_error { + ui.colored_label(egui::Color32::YELLOW, error); + } + if ui.button("查看环境准备步骤").clicked() { + self.navigation.open(Page::Settings); + } + return; + } + match page { + Page::Start => self.start_ui(ui), + Page::Dictation => self.dictation_ui(ui), + Page::Qa => self.qa_ui(ui), + Page::Selection => self.selection_ui(ui), + Page::Agent => self.less_computer_ui(ui), + Page::Services => self.services_ui(ui), + Page::Models => self.models_ui(ui), + Page::Remote => self.remote_ui(ui), + Page::History => self.history_ui(ui), + Page::Settings => self.settings_ui(ui), + } + }); }); ctx.request_repaint_after(Duration::from_millis(50)); } } + fn command_ui(ui: &mut egui::Ui, command: &str) { + ui.horizontal_wrapped(|ui| { + ui.monospace(command); + if ui.button("复制命令").clicked() { + ui.ctx().copy_text(command.to_string()); + } + }); + } + impl Drop for OpenLessEguiApp { fn drop(&mut self) { if let Some(native) = self.native.take() { @@ -2046,7 +2565,7 @@ mod linux_app { }) } - fn ensure_fcitx5_ready(config: &BackendConfig) -> Result<(), String> { + fn ensure_fcitx5_ready(config: &BackendConfig) -> Result { let home = config .home_dir .as_deref() @@ -2054,16 +2573,7 @@ mod linux_app { let layout = LinuxResourceLayout::detect(None).map_err(|error| error.to_string())?; let plan = FcitxPluginInstallPlan::for_layout(&layout, home).map_err(|error| error.to_string())?; - match ensure_fcitx5_plugin_installed(&plan).map_err(|error| error.to_string())? { - FcitxPluginStatus::Ready => Ok(()), - FcitxPluginStatus::Updated => Err( - "fcitx5 插件已安装或更新;请先重载 fcitx5(fcitx5-remote -r)再重启 OpenLess" - .to_string(), - ), - FcitxPluginStatus::Missing => { - Err("未找到 OpenLess fcitx5 插件;请重新安装当前软件包".to_string()) - } - } + ensure_fcitx5_plugin_installed(&plan).map_err(|error| error.to_string()) } pub fn run() -> Result<(), String> { @@ -2083,12 +2593,23 @@ mod linux_app { SingleInstanceRole::Primary(broker) => broker, SingleInstanceRole::Forwarded => return Ok(()), }; + let plugin_check = ensure_fcitx5_ready(&config); + let environment = LinuxCapabilitySnapshot::detect(false, package_kind()); let native = (|| { // AppImage may need to materialize its bundled plugin into the // per-user fcitx5 search path. Do that before opening the DBus // listener: otherwise the first run can wait forever for signals // from a plugin fcitx5 has never loaded. - ensure_fcitx5_ready(&config)?; + match &plugin_check { + Ok(FcitxPluginStatus::Ready) => {} + Ok(FcitxPluginStatus::Updated) => return Err( + "fcitx5 插件已安装或更新;请重载配置(fcitx5-remote -r),重新启动 fcitx5 或重新登录桌面,再启动 OpenLess".to_string() + ), + Ok(FcitxPluginStatus::Missing) => return Err( + "未找到 OpenLess fcitx5 插件;请重新安装当前软件包".to_string() + ), + Err(error) => return Err(error.clone()), + } let hotkeys = Fcitx5HotkeyListener::start().map_err(|error| error.to_string())?; let backend = { // Construction captures the existing executor for cpal/native @@ -2109,13 +2630,20 @@ mod linux_app { .map_err(|error| error.to_string()) })(); let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default().with_inner_size([960.0, 720.0]), + viewport: egui::ViewportBuilder::default() + .with_inner_size([1040.0, 760.0]) + .with_min_inner_size([420.0, 400.0]), ..Default::default() }; eframe::run_native( "OpenLess", options, - Box::new(move |_| Ok(Box::new(OpenLessEguiApp::new(tokio, native)))), + Box::new(move |_| { + let mut app = OpenLessEguiApp::new(tokio, native); + app.environment = Some(environment); + app.plugin_check = Some(plugin_check); + Ok(Box::new(app)) + }), ) .map_err(|error| error.to_string()) } @@ -2124,6 +2652,279 @@ mod linux_app { mod tests { use super::*; + fn disconnected_app() -> OpenLessEguiApp { + OpenLessEguiApp::new( + Arc::new(tokio::runtime::Runtime::new().unwrap()), + Err("fixture: plugin unavailable".into()), + ) + } + + fn rendered_text(mut draw: impl FnMut(&mut egui::Ui)) -> String { + let ctx = egui::Context::default(); + let output = ctx.run( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(720.0, 1800.0), + )), + ..Default::default() + }, + |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + draw(ui); + }); + }, + ); + output + .shapes + .into_iter() + .filter_map(|shape| match shape.shape { + egui::epaint::Shape::Text(text) => Some(text.galley.job.text.clone()), + _ => None, + }) + .collect::>() + .join("\n") + } + + #[test] + fn start_page_pipeline_multimodal_uses_only_omni_configuration() { + use openless_core::shared_types::PipelineMode; + + let mut app = disconnected_app(); + // A pending settings draft must not override Core's effective mode. + app.preferences = Some(UserPreferences { + multimodal_pipeline_enabled: false, + pipeline_mode: PipelineMode::Traditional, + ..Default::default() + }); + for omni_configured in [true, false] { + for (asr_configured, llm_configured) in + [(false, false), (true, false), (false, true), (true, true)] + { + app.snapshot = Some(BackendSnapshot { + credentials: openless_core::shared_types::CredentialsStatus { + pipeline_mode: PipelineMode::Multimodal, + omni_configured, + asr_configured, + llm_configured, + ..Default::default() + }, + ..Default::default() + }); + let text = rendered_text(|ui| app.start_ui(ui)); + let expected = if omni_configured { + "Omni:已配置" + } else { + "Omni:尚未配置" + }; + assert!(text.contains(expected), "missing {expected}: {text}"); + assert!(!text.contains("语音识别:尚未配置"), "{text}"); + assert!(!text.contains("ASR 语音识别:"), "{text}"); + assert!(!text.contains("LLM 润色:"), "{text}"); + assert!(text.contains("已配置不代表校验通过"), "{text}"); + } + } + } + + #[test] + fn start_page_pipeline_traditional_reports_asr_and_llm_independently() { + use openless_core::shared_types::PipelineMode; + + let mut app = disconnected_app(); + // Conversely, a multimodal draft must not hide the effective + // traditional pipeline's missing ASR or LLM configuration. + app.preferences = Some(UserPreferences { + multimodal_pipeline_enabled: true, + pipeline_mode: PipelineMode::Multimodal, + ..Default::default() + }); + for omni_configured in [true, false] { + for (asr_configured, llm_configured) in + [(false, false), (true, false), (false, true), (true, true)] + { + app.snapshot = Some(BackendSnapshot { + credentials: openless_core::shared_types::CredentialsStatus { + pipeline_mode: PipelineMode::Traditional, + omni_configured, + asr_configured, + llm_configured, + ..Default::default() + }, + ..Default::default() + }); + let text = rendered_text(|ui| app.start_ui(ui)); + for expected in [ + if asr_configured { + "ASR 语音识别:已配置" + } else { + "ASR 语音识别:尚未配置" + }, + if llm_configured { + "LLM 润色:已配置" + } else { + "LLM 润色:尚未配置" + }, + ] { + assert!(text.contains(expected), "missing {expected}: {text}"); + } + assert!(!text.contains("Omni:"), "{text}"); + assert!(text.contains("已配置不代表校验通过"), "{text}"); + } + } + } + + #[test] + fn background_approval_survives_navigation_and_stale_terminals() { + let mut app = disconnected_app(); + let session = openless_core::SessionId::new(); + for (sequence, kind) in [ + ( + 1, + LessComputerEventKind::User { + text: "task".into(), + fresh: true, + }, + ), + ( + 2, + LessComputerEventKind::Approval { + token: "approval".into(), + command: "echo test".into(), + reason: "fixture".into(), + }, + ), + ] { + app.apply_event(BackendEvent { + sequence, + session_id: Some(session), + kind: BackendEventKind::LessComputerEvent(openless_core::LessComputerEvent { + seq: None, + kind, + }), + }); + } + for page in Page::ALL { + app.navigation.open(page); + assert_eq!(app.page_activity(Page::Agent), Some("待审批")); + let text = rendered_text(|ui| { + app.activity_ui(ui); + app.agent_approval_ui(ui); + }); + for control in ["允许", "拒绝", "取消 Agent"] { + assert!( + text.contains(control), + "missing {control} on {page:?}: {text}" + ); + } + } + app.apply_event(BackendEvent { + sequence: 3, + session_id: Some(openless_core::SessionId::new()), + kind: BackendEventKind::LessComputerEvent(openless_core::LessComputerEvent { + seq: None, + kind: LessComputerEventKind::Cancelled, + }), + }); + assert_eq!( + app.pending_approval, + Some(("approval".into(), "echo test".into())) + ); + assert!(app.less_computer_running); + } + + #[test] + fn qa_and_selection_events_on_settings_keep_drafts_and_action_notices() { + let mut app = disconnected_app(); + app.navigation.open(Page::Settings); + app.qa_input = "unsent question".into(); + let qa_session = openless_core::SessionId::new(); + let mut thinking = QaStateEvent::simple(QaStateKind::Thinking); + thinking.session_id = Some(qa_session.to_string()); + app.apply_event(BackendEvent { + sequence: 1, + session_id: Some(qa_session), + kind: BackendEventKind::QaState(thinking), + }); + let selection_session = openless_core::SessionId::new(); + app.apply_event(BackendEvent { + sequence: 2, + session_id: Some(selection_session), + kind: BackendEventKind::SelectionStateChanged(SelectionSnapshot { + phase: SelectionPhase::Preview, + session_id: Some(selection_session), + preview_text: Some("editable preview".into()), + ..Default::default() + }), + }); + assert_eq!(app.navigation.page, Page::Settings); + assert!(app.navigation.has_update(Page::Qa)); + assert_eq!(app.page_activity(Page::Selection), Some("待确认")); + app.selection_draft = "user edited preview".into(); + app.navigation.open(Page::Qa); + app.navigation.open(Page::Selection); + app.navigation.open(Page::Models); + assert_eq!(app.qa_input, "unsent question"); + assert_eq!(app.selection_draft, "user edited preview"); + assert_eq!( + app.selection.as_ref().unwrap().session_id, + Some(selection_session) + ); + let text = rendered_text(|ui| app.activity_ui(ui)); + assert!(text.contains("取消选区预览"), "{text}"); + } + + #[test] + fn startup_failure_keeps_preparation_steps_without_claiming_connection() { + let mut app = disconnected_app(); + app.environment = Some(LinuxCapabilitySnapshot::from_environment( + Some("wayland-0"), + None, + false, + false, + LinuxPackageKind::AppImage, + )); + app.plugin_check = Some(Ok(FcitxPluginStatus::Updated)); + let text = rendered_text(|ui| app.start_ui(ui)); + for expected in [ + "Core 未连接", + "Wayland", + "D-Bus 探测未通过", + "尚未验证录音", + "fcitx5-diagnose", + "fcitx5-remote -r", + "Secret Service", + ] { + assert!(text.contains(expected), "missing {expected}: {text}"); + } + assert!(!text.contains("Core 已连接")); + assert!(!text.contains("Core:运行中")); + } + + #[test] + fn stopped_or_stale_remote_status_never_shows_pairing_secrets_or_old_urls() { + let mut app = disconnected_app(); + for (running, urls_stale) in [(false, false), (true, true)] { + app.remote_access = Some(( + openless_core::RemoteInputStatus { + enabled: true, + running, + starting: false, + port: 8443, + urls: vec!["https://old.example.invalid".into()], + urls_stale, + locale: "en".into(), + connection_count: 0, + active_session_id: None, + }, + "fixture-pin".into(), + )); + let text = rendered_text(|ui| app.remote_ui(ui)); + assert!(!text.contains("fixture-pin"), "{text}"); + assert!(!text.contains("https://old.example.invalid"), "{text}"); + assert!(text.contains("当前连接数:0"), "{text}"); + } + } + #[test] fn continuation_turn_keeps_receiving_output_and_approval() { let mut app = OpenLessEguiApp::new( diff --git a/openless-all/app/linux-egui/src/ui_state.rs b/openless-all/app/linux-egui/src/ui_state.rs new file mode 100644 index 00000000..c22ced65 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui_state.rs @@ -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)); + } + } + } +} diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index 55629f36..f3d954b2 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -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
OpenLess Core 无法启动或版本不兼容。 {error}
; + return ; } if (!ready) { - return
正在检查 OpenLess Core 兼容性…
; + return ; } return ; } @@ -342,14 +343,10 @@ function ReadyApp({ isCapsule, isQa, isSelectionPolishPreview, isSelectionVoiceI }, [os]); if (gate === 'checking') { - return
正在检查 OpenLess Core 兼容性…
; + return ; } if (gate === 'incompatible') { - return ( -
- OpenLess Core 无法启动或版本不兼容。{startupError ? ` ${startupError}` : ''} -
- ); + return ; } return ( diff --git a/openless-all/app/src/components/CoreStartupScreen.tsx b/openless-all/app/src/components/CoreStartupScreen.tsx new file mode 100644 index 00000000..b8de2ba9 --- /dev/null +++ b/openless-all/app/src/components/CoreStartupScreen.tsx @@ -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
+
+
+ {!compact && } + {t(error ? 'startup.failed' : 'startup.loading')} +
+ {!compact && <> +

{t(error ? 'startup.recovery' : 'startup.loadingDesc')}

+ {error && <> + +
+ {t('startup.details')} +
{error}
+
+ } + } +
+
; +} diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index 522a371c..53eb1695 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -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 中 // 通过 渲染。这里之前的 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(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 的 渲染, + // 模型管理 UI 现在通过 Settings → Services 的 渲染, // 用户在 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
{mobile && ( +
{ if (element) element.inert = settingsOpen; }} style={{ display: 'contents' }}> openSettings()} settingsActive={settingsOpen} /> +
)} {/* Main shell — flush with the frosted backplate (no separate float). */}
{displayTab === 'overview' ? ( - setCurrentTab('history')} /> + setCurrentTab('history')} onOpenSettings={openSettings} /> ) : (
(initialSettingsSection ?? 'general'); + const [query, setQuery] = useState(''); + const [platformCaps, setPlatformCaps] = useState(getCachedPlatformCapabilities); const savedToast = useSavedToastListener(); + const surfaceRef = useRef(null); + const scrollRef = useRef(null); + const searchRef = useRef(null); + const headingRef = useRef(null); + const closeRef = useRef(null); + const previousFocusRef = useRef(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>([]); - 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) => { + // 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( + '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 (
- + 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 }} + >
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 ? ( -
- -
- {TAB_ITEMS.map(it => { - const active = section === it.id; - return ( - - ); - })} -
+ 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' }} + > +
+
{t('shell.footer.settings')}
+ {!mobile && {t('modal.autoSaveHint')}} + +
+ + 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 && }
- ) : ( -
+
+
- - {/* 外链组 */} -
- {LINK_ITEMS.map(it => ( - - ))} -
- - )} - - {/* ─── 内容区 ────────────────────────────────────────────── */} -
- - {!mobile && ( - - )} - - {!mobile && ( -

- {t(`modal.sections.${section}`)} -

- )} - -
- {/* key=section 让切 tab 时整块重挂载,ol-tab-fade 轻微淡入。 */} -
- {section === 'general' && } - {section === 'services' && } - {section === 'privacy' && } - {section === 'advanced' && } - {section === 'about' && } + ))} + + {!mobile && } + +
+ +
+

{searching ? t('modal.searchResults') : t(`modal.sections.${section}`)}

+

{searching ? t('modal.searchCount', { count: results.length }) : t(`modal.descriptions.${section}`)}

- {mobile && ( -
- {LINK_ITEMS.map(it => ( - - ))} +
+ {searching &&
+ {results.map(item => )} + {results.length === 0 &&

{t('modal.noResults')}

} +
} +
+ {section === 'general' && } + {section === 'shortcuts' && } + {section === 'appearance' && } + {section === 'services' && } + {section === 'privacy' && } + {section === 'advanced' && } + {section === 'about' && }
- )} + {mobile && !searching && } +
@@ -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
{LINKS.map(link => )}
; } -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' }; diff --git a/openless-all/app/src/components/ShortcutRecorder.tsx b/openless-all/app/src/components/ShortcutRecorder.tsx index 49794925..373f40ac 100644 --- a/openless-all/app/src/components/ShortcutRecorder.tsx +++ b/openless-all/app/src/components/ShortcutRecorder.tsx @@ -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('[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]); diff --git a/openless-all/app/src/components/ui/SelectLite.tsx b/openless-all/app/src/components/ui/SelectLite.tsx index 84f73c65..fe2507d4 100644 --- a/openless-all/app/src/components/ui/SelectLite.tsx +++ b/openless-all/app/src/components/ui/SelectLite.tsx @@ -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(-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(
(); + 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'); diff --git a/openless-all/app/src/lib/ipc/providers.ts b/openless-all/app/src/lib/ipc/providers.ts index eef2a54b..e36b416d 100644 --- a/openless-all/app/src/lib/ipc/providers.ts +++ b/openless-all/app/src/lib/ipc/providers.ts @@ -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 { +export async function listProviderDescriptors(kind: ProviderKind): Promise { + 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 }, () => []) } diff --git a/openless-all/app/src/lib/overviewSetup.test.ts b/openless-all/app/src/lib/overviewSetup.test.ts new file mode 100644 index 00000000..7709fafe --- /dev/null +++ b/openless-all/app/src/lib/overviewSetup.test.ts @@ -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'); diff --git a/openless-all/app/src/lib/overviewSetup.ts b/openless-all/app/src/lib/overviewSetup.ts new file mode 100644 index 00000000..ad308424 --- /dev/null +++ b/openless-all/app/src/lib/overviewSetup.ts @@ -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 }; +} diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index 55a38a2e..ced8a6f8 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -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。 // 注意这里存的是**全部**包(含已禁用):历史条目可能出自后来被禁用的包,显示名字要能查到; diff --git a/openless-all/app/src/pages/LocalAsr/components.tsx b/openless-all/app/src/pages/LocalAsr/components.tsx index e5d591cf..4352a56c 100644 --- a/openless-all/app/src/pages/LocalAsr/components.tsx +++ b/openless-all/app/src/pages/LocalAsr/components.tsx @@ -1,8 +1,8 @@ // Presentational sub-components for the Local ASR page, extracted from -// LocalAsr/index.tsx (behavior-preserving move). All are props-driven and -// stateless beyond local render memoization. +// LocalAsr/index.tsx. Native state/actions stay in the parent; the catalog owns +// only its engine filter, narrow-layout navigation and keyboard focus. -import { useMemo } from "react" +import { useEffect, useId, useMemo, useRef, useState } from "react" import { createPortal } from "react-dom" import { useTranslation } from "react-i18next" import { @@ -16,6 +16,8 @@ import { Btn, Card, Collapsible, Pill } from "../_atoms" import { Icon } from "../../components/Icon" import { formatBytes } from "./helpers" import type { RemoteSize } from "./types" +import { useLayoutStack } from "../../lib/useMobileLayout" +import "./local-asr.css" export function FoundryPrepareProgressBlock({ progress, @@ -169,77 +171,16 @@ export function DownloadProgressBlock({ const { t } = useTranslation() const downloadedBytes = progress?.bytesDownloaded ?? 0 const totalBytes = progress?.bytesTotal ?? remoteSize?.totalBytes ?? 0 - const ratio = totalBytes > 0 ? Math.min(1, downloadedBytes / totalBytes) : 0 + const percent = totalBytes > 0 ? Math.max(0, Math.min(100, downloadedBytes / totalBytes * 100)) : undefined const failed = progress?.phase === "failed" return ( -
-
- - {t("localAsr.foundryPrepareModel")} - - - {failed - ? t("localAsr.failed") - : `${Math.round(ratio * 100)}%`} - -
-
-
-
-
- {failed - ? `${t("localAsr.failed")}: ${progress?.error ?? ""}` - : `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` + - (progress?.file ? ` · ${progress.file}` : "")} -
- {cancelRequested && ( -
- {t("localAsr.foundryCancelRequested")} -
- )} +
+
{t("localAsr.downloading")}{failed ? t("localAsr.failed") : percent == null ? t("common.loading") : `${Math.round(percent)}%`}
+ + {formatBytes(downloadedBytes)} / {totalBytes > 0 ? formatBytes(totalBytes) : t("localAsr.sizeUnknown")} + {progress?.file && {progress.file}} + {failed && progress?.error && {progress.error}} + {cancelRequested && {t("localAsr.foundryCancelRequested")}}
) } @@ -499,12 +440,10 @@ export function TestResultBlock({ style={{ marginTop: 12, padding: "10px 12px", - background: hasError - ? "rgba(255, 220, 220, 0.5)" - : "rgba(0, 0, 0, 0.04)", + background: "var(--ol-surface-2)", borderRadius: 8, fontSize: 12.5, - color: hasError ? "#9b2c2c" : "var(--ol-ink-2)", + color: hasError ? "var(--ol-err)" : "var(--ol-ink-2)", lineHeight: 1.6, }} > @@ -577,232 +516,109 @@ export interface SidebarModelEntry { isActive: boolean /** 引擎标识,决定右侧动作按钮分派。 */ engine: "qwen3" | "whisper" | "sherpa" | "foundry" + downloadError?: string | null } -/** 左侧模型选择栏:竖排条目,选中高亮;底部预留「下载新模型」按钮位。 */ -export function ModelSidebar({ - entries, - selectedId, - onSelect, - onOpenDownload, - downloadDisabled, -}: { - entries: SidebarModelEntry[] - selectedId: string | null - onSelect: (id: string) => void - onOpenDownload: () => void - downloadDisabled: boolean +const ENGINE_LABELS: Record = { + qwen3: "Qwen3-ASR", + whisper: "Whisper", + sherpa: "sherpa-onnx", + foundry: "Foundry Local", +} + +function ModelStatus({ entry }: { entry: SidebarModelEntry }) { + const { t } = useTranslation() + const label = entry.isDownloading + ? t("localAsr.downloading") + : entry.downloadError + ? t("localAsr.failed") + : entry.isActive + ? t("localAsr.activePill") + : entry.isDownloaded + ? t("localAsr.downloadedBadge") + : t("localAsr.notDownloadedBadge") + return ( + + {entry.isDownloaded && !entry.isDownloading && } + {label} + + ) +} + +/** Names get their own line; engine, size and state remain readable in every language. */ +function ModelChoice({ entry, selected, onSelect }: { + entry: SidebarModelEntry + selected: boolean + onSelect: () => void }) { const { t } = useTranslation() return ( -
-
- {t("localAsr.sidebarTitle")} -
- {entries.map((entry) => { - const selected = entry.id === selectedId - return ( - - ) - })} - {entries.length === 0 && ( -
- {t("localAsr.modelSelectEmpty")} -
+ + + ) +} + +export function ModelSidebar({ entries, selectedId, onSelect }: { + entries: SidebarModelEntry[] + selectedId: string | null + onSelect: (id: string) => void +}) { + const { t } = useTranslation() + return ( +
+
+ {t("localAsr.sidebarTitle")} + {entries.length} +
+
+ {entries.map(entry => ( + onSelect(entry.id)} /> + ))} +
) } -function IconCheck() { +function ModelFacts({ entry, fileCount, mirrorLabel }: { + entry: SidebarModelEntry + fileCount: number | null + mirrorLabel?: string +}) { + const { t } = useTranslation() return ( - +
+
{t("localAsr.engineLabel")}
{ENGINE_LABELS[entry.engine]}
+
{t("localAsr.sizeLabel")}
{entry.remoteBytes ? formatBytes(entry.remoteBytes) : t("localAsr.sizeUnknown")}
+ {fileCount != null && fileCount > 0 &&
{t("localAsr.files")}
{fileCount}
} + {mirrorLabel &&
{t("localAsr.mirrorLabel")}
{mirrorLabel}
} + {entry.repo &&
{t("localAsr.detailRepo")}
{entry.repo}
} +
) } -/** 下载量/收藏数展示:千分位分隔(12345 → "12,345")。 */ -function formatCount(n: number): string { - return n.toLocaleString("en-US") -} - -/** 右侧详情看板:选中模型的信息(HF 抓取的尺寸/文件数)+ 操作按钮。 */ +/** Installed details never infer download activity from a terminal progress event. */ export function ModelDetailPanel({ - entry, - fileCount, - mirrorLabel, - downloading, - progressPercent, - busy, - onDownload, - onCancel, - onDelete, - onReveal, - onTest, - showTest, - testResult, - testing, + entry, fileCount, mirrorLabel, progress, busy, + onDownload, onCancel, onDelete, onReveal, onTest, showTest, testResult, testing, }: { entry: SidebarModelEntry | null fileCount: number | null mirrorLabel?: string - downloading: boolean - progressPercent: number | null + progress?: LocalAsrDownloadProgress busy: boolean onDownload: () => void onCancel: () => void @@ -814,191 +630,35 @@ export function ModelDetailPanel({ testing: boolean }) { const { t } = useTranslation() - if (!entry) { - return ( -
- {t("localAsr.detailEmpty")} -
- ) - } + if (!entry) return

{t("localAsr.detailEmpty")}

return ( -
- {/* 顶部行:模型名在左上,Hugging Face 仓库 / 镜像源在右上。 */} -
-
-
- {entry.name} -
-
- {entry.remoteBytes != null && entry.remoteBytes > 0 && ( - - {formatBytes(entry.remoteBytes)} - - )} - {fileCount != null && fileCount > 0 && ( - - {fileCount} {t("localAsr.detailFiles")} - - )} - {entry.isDownloaded && ( - - ✓ {t("localAsr.detailDownloaded")} - - )} -
-
-
- {entry.repo && ( - - {entry.repo} - - )} - {mirrorLabel && ( - - {mirrorLabel} - - )} -
+
+
+ {t("localAsr.detailsTitle")} +

{entry.name}

+
- - {downloading && progressPercent != null && ( -
-
-
-
-
- {Math.round(progressPercent)}% -
-
- )} - + + {entry.isDownloading && } + {entry.downloadError &&
{entry.downloadError}
} + {entry.isDownloaded && showTest &&

{t("localAsr.testActivateHint")}

} {testResult && } - - {/* 底部操作行:下载 / 加载并测试 / 打开目录 / 删除,全部并排。 - 「加载并测试」加载即作为当前模型使用(激活 = 在 ASR 语音转写里 - 选本地模型供应商,不再单独设「设为默认」)。 */} -
- {!entry.isDownloaded && ( - - {downloading ? t("localAsr.downloading") : t("localAsr.download")} - - )} - {downloading && ( - - {t("common.cancel")} +
+ {!entry.isDownloaded && !entry.isDownloading && ( + + {entry.downloadError ? t("common.retry") : t("localAsr.download")} )} + {entry.isDownloading && {t("localAsr.cancel")}} {entry.isDownloaded && showTest && ( - + {testing ? t("localAsr.testRunning") : t("localAsr.test")} )} {entry.isDownloaded && ( <> - - {t("localAsr.revealDir")} - - - {t("localAsr.delete")} - + {t("localAsr.revealDir")} + {t("localAsr.delete")} )}
@@ -1006,25 +666,10 @@ export function ModelDetailPanel({ ) } -/** 下载弹框:全页式(与设置弹窗同尺寸、顶部锚定),左侧模型选择(竖排, - * 已下载标勾)+ 右侧详情,右上角 ✕ 关闭,底部开始下载。 - * - * 必须 createPortal 到 document.body:WindowChrome 根节点带常驻 transform / - * will-change(ol-window-enter 动画 fill-mode: both 保留终帧 transform), - * 会创建 containing block —— 直接渲染的话 `position: fixed` 会相对设置弹窗 - * 而不是视口定位,遮罩只盖住设置面板(内容发灰)、弹框被裁掉一半且点不到 - * (与 Modal.tsx 的 GitHub 登录弹窗同一 bug 根因)。portal 出去后 fixed - * 相对视口,铺满整窗、始终置顶。 */ +/** Portal escapes the transformed window shell. The catalog and details scroll independently. */ export function DownloadDialog({ - entries, - selectedId, - onSelect, - sizeOf, - fileCountOf, - hfCardOf, - busy, - onStart, - onClose, + entries, selectedId, onSelect, sizeOf, fileCountOf, hfCardOf, + busy, loading, error, onRetryCard, onRetryCatalog, onStart, onClose, }: { entries: SidebarModelEntry[] selectedId: string | null @@ -1037,307 +682,162 @@ export function DownloadDialog({ | { status: "ok"; card: HfModelCard } | null busy: boolean + loading: boolean + error: string | null + onRetryCatalog: () => void + onRetryCard: (id: string) => void onStart: () => void onClose: () => void }) { const { t } = useTranslation() - // 默认选中第一项:看板可能什么都没选中(零下载用户),弹窗不能停在 - // 「未选择」空态——高亮、右侧详情与「开始下载」都跟随该解析值。 - const resolvedId = entries.some((e) => e.id === selectedId) - ? selectedId - : (entries[0]?.id ?? null) - const selected = entries.find((e) => e.id === resolvedId) ?? null + const stackLayout = useLayoutStack(760) + const [showDetails, setShowDetails] = useState(false) + const [engine, setEngine] = useState("all") + const titleId = useId() + const descriptionId = useId() + const dialogRef = useRef(null) + const detailRef = useRef(null) + const lastChoiceRef = useRef(null) + const engines = [...new Set(entries.map(entry => entry.engine))] + const visibleEntries = engine === "all" ? entries : entries.filter(entry => entry.engine === engine) + const selected = visibleEntries.find(entry => entry.id === selectedId) + ?? visibleEntries.find(entry => !entry.isDownloaded) + ?? visibleEntries[0] ?? null const hfCard = selected ? hfCardOf(selected.id) : null + + useEffect(() => { + const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null + dialogRef.current?.focus({ preventScroll: true }) + return () => { if (previous?.isConnected) previous.focus({ preventScroll: true }) } + }, []) + + useEffect(() => { + if (stackLayout && showDetails) detailRef.current?.focus({ preventScroll: true }) + }, [stackLayout, showDetails]) + + const selectModel = (id: string) => { + lastChoiceRef.current = id + onSelect(id) + setShowDetails(true) + } + + const showCatalog = () => { + setShowDetails(false) + window.requestAnimationFrame(() => { + const buttons = dialogRef.current?.querySelectorAll(".ol-model-choice") + const index = visibleEntries.findIndex(entry => entry.id === lastChoiceRef.current) + buttons?.[Math.max(0, index)]?.focus({ preventScroll: true }) + }) + } + return createPortal( -
{ - // busy = 真实下载中(index 传 anyDownloadInFlight):下载中点击 - // 遮罩不关闭——否则用户点遮罩下的设置项时弹窗会「像按了叉一样 - // 消失」,误以为是设置页闪退。只能走右上角 ✕ 关闭。 - if (e.target === e.currentTarget && !busy) onClose() - }} - > -
- {/* 标题行:左标题 + 右 ✕ 关闭 */} -
-
- {t("localAsr.downloadDialogTitle")} +
{ + if (event.target === event.currentTarget && !busy) onClose() + }}> +
{ + if (event.key === "Escape") { + event.stopPropagation() + onClose() + } + if (event.key !== "Tab") return + const focusable = Array.from(event.currentTarget.querySelectorAll('button:not(:disabled), [href], input, select, [tabindex="0"]')).filter(element => element.getClientRects().length > 0) + const first = focusable[0] + const last = focusable[focusable.length - 1] + const active = document.activeElement + if (event.shiftKey && (active === first || !focusable.includes(active as HTMLElement))) { + event.preventDefault() + last?.focus() + } else if (!event.shiftKey && (active === last || !focusable.includes(active as HTMLElement))) { + event.preventDefault() + first?.focus() + } + }}> +
+
+

{t("localAsr.downloadDialogTitle")}

+

{t("localAsr.downloadDialogDesc")}

- -
-
- {/* 左侧:模型选择(竖排)——与设置页左栏 rail 同风格, - 宽度对齐设置页(200px),让弹窗看起来和设置页是一体的 */} -
-
- {t("localAsr.sidebarTitle")} -
- {entries.map((entry) => ( - - ))} - {entries.length === 0 && ( -
- {t("localAsr.modelSelectEmpty")} + + +
+ {(!stackLayout || !showDetails || !selected) && ( +
+
+
{t("localAsr.catalogTitle")}{visibleEntries.length}
+ {engines.length > 1 && ( +
+ {(["all", ...engines] as const).map(value => ( + + ))} +
+ )}
- )} -
- {/* 右侧:模型信息 + HF 模型卡片(下载量/收藏/简介) */} -
-
- {selected ? ( -
-
- {selected.name} +
+ {visibleEntries.map(entry => selectModel(entry.id)} />)} + {visibleEntries.length === 0 && ( +
+ +

{loading ? t("common.loading") : error || t("localAsr.catalogEmpty")}

+ {t("localAsr.reloadCatalog")}
- {selected.repo && ( -
- {t("localAsr.detailRepo")}: {selected.repo} + )} +
+
+ )} + {selected && (!stackLayout || showDetails) && ( +
+
+ {stackLayout && } + {selected ? ( + <> +
+ {ENGINE_LABELS[selected.engine]} +

{selected.name}

+
- )} -
- {(() => { - const bytes = sizeOf(selected.id) - const files = fileCountOf(selected.id) - return ( - <> - {bytes != null && bytes > 0 && ( - - {formatBytes(bytes)} - - )} - {files != null && files > 0 && ( - - {files} {t("localAsr.detailFiles")} - - )} - - ) - })()} - {selected.isDownloaded && ( - - ✓ {t("localAsr.detailDownloaded")} - - )} -
- {hfCard?.status === "loading" && ( -
- {t("common.loading")} -
- )} - {hfCard?.status === "error" && ( -
- {t("localAsr.hfCardFailed")}: {hfCard.message} -
- )} - {hfCard?.status === "ok" && ( -
-
- - {t("localAsr.hfDownloads")}: {formatCount(hfCard.card.downloads)} - - - {t("localAsr.hfLikes")}: {formatCount(hfCard.card.likes)} - + + {selected.downloadError &&
{selected.downloadError}
} + {hfCard?.status === "loading" &&

{t("common.loading")}

} + {hfCard?.status === "error" && ( +
+

{t("localAsr.hfCardFailed")}

+
{t("localAsr.errorDetails")}

{hfCard.message}

+ onRetryCard(selected.id)}>{t("common.retry")}
- {hfCard.card.description ? ( - <> -
- {t("localAsr.hfDescription")} -
-
- {hfCard.card.description} -
- - ) : ( -
- {t("localAsr.hfNoDescription")} -
- )} -
- )} - {selected.isDownloaded && ( -
- {t("localAsr.downloadDialogAlreadyHave")} -
- )} + )} + {hfCard?.status === "ok" && ( +
+

{t("localAsr.hfDescription")}

+

{hfCard.card.description || t("localAsr.hfNoDescription")}

+
+
{t("localAsr.hfDownloads")}
{hfCard.card.downloads.toLocaleString()}
+
{t("localAsr.hfLikes")}
{hfCard.card.likes.toLocaleString()}
+
+
+ )} + {selected.isDownloaded &&

{t("localAsr.downloadDialogAlreadyHave")}

} + + ) :

{t("localAsr.detailEmpty")}

} +
+
+ {selected?.isDownloading ? t("localAsr.downloading") : t("localAsr.downloadProgressHint")} +
+ {t("common.close")} + + {selected?.isDownloaded ? t("localAsr.downloadedBadge") : selected?.downloadError ? t("common.retry") : t("localAsr.startDownload")} +
- ) : ( -
- {t("localAsr.detailEmpty")} -
- )} -
-
- - {t("common.cancel")} - - - {t("localAsr.startDownload")} - -
-
+ + + )}
, diff --git a/openless-all/app/src/pages/LocalAsr/index.tsx b/openless-all/app/src/pages/LocalAsr/index.tsx index 389e1b24..43068476 100644 --- a/openless-all/app/src/pages/LocalAsr/index.tsx +++ b/openless-all/app/src/pages/LocalAsr/index.tsx @@ -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(null) // 等待 native capability 查询完成,避免 Intel Mac 先闪现 MLX 渠道。 @@ -193,6 +196,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { const [error, setError] = useState(null) const [busyModelId, setBusyModelId] = useState(null) const [storageBusy, setStorageBusy] = useState(false) + const [catalogRefreshing, setCatalogRefreshing] = useState(false) const [foundryStatus, setFoundryStatus] = useState(null) const [foundryCatalog, setFoundryCatalog] = useState< @@ -244,6 +248,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { >({}) const [engineStatus, setEngineStatus] = useState(null) + const catalogReloadRequestedRef = useRef(false) const downloadDialogOpenRef = useRef(downloadDialogOpen) const refreshGenerationRef = useRef(0) const refreshTimer = useRef(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 ( @@ -1986,7 +2010,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
-
- {t("localAsr.modelSelectTitle")} + {visibleError && ( +
{visibleError}
+ )} + {!IS_WINDOWS &&
+ {sidebarEntries.length > 0 ? <> +
+
+

{t("localAsr.modelSelectTitle")}

+

{t("localAsr.modelSelectDesc")}

+
+ setDownloadDialog(true)}> + {t("localAsr.downloadNewModel")} +
-
+
setDownloadDialog(true)} - downloadDisabled={ - busyModelId !== null || - sherpaBusy !== null || - anyDownloadInFlight - } - />
+ /> +
selectedEntry && dispatchEntryAction(selectedEntry, "download") } @@ -2110,24 +2119,36 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { />
- } + : ( +
+ +

{modelLoadPending ? t("common.loading") : t("localAsr.libraryEmptyTitle")}

+

{t("localAsr.libraryEmptyDesc")}

+
+ setDownloadDialog(true)}>{t("localAsr.downloadNewModel")} + void reloadModels()}>{t("localAsr.reloadCatalog")} +
+
+ )} +
} {/* ─── 收纳:下载与存储设置(镜像源 · 模型存储位置 · 内存引擎)——默认收起, 需要手动点开。日常的下载 / 管理 / 测试不依赖这些低频配置。 ─── */} -
+
{IS_QWEN_PLATFORM && ( <> - +
@@ -2173,10 +2194,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
- +
{/* 运行时设置卡:内存中的引擎状态 + 多久释放 + 立即释放 */} {engineAvailable && ( - +
-
+
)} )} - +
- +
{/* ─── 下载弹框:左侧模型选择 + 右侧详情,最下方开始下载。 ─── */} @@ -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 = {}) { )} {/* ─── 分组:下载与管理(各引擎的模型获取/准备/下载) ─── */} - + {IS_WINDOWS && {t("localAsr.groupDownload")} - + } {IS_WINDOWS && ( @@ -3286,7 +3315,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
)} - {error && ( - -
- {error} -
-
- )} ) } diff --git a/openless-all/app/src/pages/LocalAsr/local-asr.css b/openless-all/app/src/pages/LocalAsr/local-asr.css new file mode 100644 index 00000000..d089cdf6 --- /dev/null +++ b/openless-all/app/src/pages/LocalAsr/local-asr.css @@ -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; } diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 95ba9168..3c76a86b 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -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 { 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 = { 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([]); const [historyError, setHistoryError] = useState(false); const [credsError, setCredsError] = useState(false); - const [creds, setCreds] = useState({ - 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(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 ( - <> - + // Own a natural-height column: the shell scrolls when the window is short. +
+ {t('overview.refresh')}} + /> -
- - + +
+
+
{t('overview.guide.nextStep')}
+

{t(`overview.guide.${setup.step}Title`)}

+

{guideDescription}

+
+ {setup.action && ( + setup.action === 'refresh' ? refreshCredentials() : setup.action && openSettings(setup.action)} + > + {t(`overview.actions.${setup.action}`)} + + )} +
+
+ {setup.action !== 'general' && openSettings('general')}>{t('overview.actions.general')}} + {desktop && setup.action !== 'shortcuts' && openSettings('shortcuts')}>{t('overview.actions.shortcuts')}} + {setup.action !== 'privacy' && openSettings('privacy')}>{t('overview.actions.privacy')}} +
+

{t('overview.guide.permissionsHint')}

+
+ +
+

{t('overview.servicesTitle')}

+ openSettings('services')}>{t('overview.actions.services')} +
+
+ {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 ( + openSettings('services') : undefined} + /> + ); + })} + {setup.providers.length === 0 && ( + +
{t(credsLoading ? 'overview.statusLoading' : 'overview.credentialsLoadError')}
+
+ )}
-
+

{t('overview.statsTitle')}

+ +
0 ? t('overview.metricAvgTrend') : t('overview.metricNoData')} /> - +
- {/* 年度活动热力图(8starlabs Heatmap 规格):过去一年每日听写次数。 - 数据来自独立的 activity 计数存储,与历史保留策略解耦; - 可在设置 → 通用 → 外观 里单独关闭。 - 移动端(useMobileLayout)不渲染,避免窄屏横向溢出(issue #861)。 */} - {!mobile && - prefs?.showOverviewActivityHeatmap !== false && - activity && - activity.length > 0 && ( - - )} - - {/* 底部一行 = flex:1 撑满剩余高度(父 wrapper 是 display:flex/column)。 - 只有「最近识别」内部允许滚动;其他卡片按内容自然高度,不破裂底部圆角。 - issue #243 follow-up:去掉外层 overflow 后底部圆角被裁的视觉问题。 */} -
- {/* overflow:hidden:窗口过小时这一行 flex:1 会被压到比内容还矮,柱状图(固定高) - 原本会溢出卡片圆角外(issue #782)。裁进卡片内,与右侧「最近识别」卡片一致。 */} + {/* Both cards keep their content height; overflow belongs to the shell. */} +
- +
{t('overview.recentTitle')} - {t('overview.recentAll')} + {t('overview.recentAll')}
-
+
{historyError ? (
{t('overview.recentLoadFailed')} @@ -294,7 +333,7 @@ export function Overview({ onOpenHistory }: OverviewProps) { <> {history.length === 0 && (
- {t('overview.recentEmpty', { trigger: prefs ? formatComboLabel(prefs.dictationHotkey) : '' })} + {t('overview.recentEmptyHint')}
)} {history.slice(0, 5).map(s => ( @@ -305,53 +344,57 @@ export function Overview({ onOpenHistory }: OverviewProps) {
- + {/* Independent activity storage survives history clearing; keep the existing visibility preference. */} + {!mobile && prefs?.showOverviewActivityHeatmap !== false && activity && activity.length > 0 && ( + + )} +
); } 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 ( - -
- + +
+
+ +
+
+
+ {t(`overview.${kind}Kind`)} + {status === 'configured' && ( + + + {t('overview.statusConfigured')} + + )} + {status === 'notConfigured' && ( + {t('overview.statusNotConfigured')} + )} +
+
{name}
+
-
-
- {kind} - {status === 'configured' && ( - - - {t('overview.statusConfigured')} - - )} - {status === 'notConfigured' && ( - {t('overview.statusNotConfigured')} - )} - {status === 'error' && ( - {t('overview.statusUnknown')} - )} -
-
{name}
-
- {status === 'error' ? t('overview.credentialsLoadError') : subname} -
+
+ {t(`overview.providerHelp.${kind}`)} + + {t(status === 'configured' ? 'overview.manageProvider' : 'overview.configureProvider')} +
); @@ -385,9 +428,7 @@ function ActivityHeatmapCard({ activity }: { activity: ActivityDay[] }) { }; }, [activity, i18n.language]); return ( - // marginBottom 与上方 provider / metrics 两行的 18px 节奏保持一致:否则「年度活动」 - // 会直接贴住下面「近 7 天 / 最近识别」那一行,中间没有间隔(issue #781)。 - + {t('overview.activityTitle')} @@ -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 ( - +
{label}
-
{value}
+
{value}
{trend || ' '}
); @@ -493,6 +533,7 @@ function PeriodMetricsCard({ onPeriodChange, onMetricChange, loadError, + onRetry, }: { series: ReturnType; 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 ( - + {/* flexWrap:卡片在 1fr 列里较窄,两组切换器放不下时换行而不是压扁按钮。 */}
{loadError ? ( -
+
{t('overview.activityLoadError')} + {t('overview.historyRetry')}
) : ( <> diff --git a/openless-all/app/src/pages/SelectionAsk.tsx b/openless-all/app/src/pages/SelectionAsk.tsx index 980fad77..7c36933a 100644 --- a/openless-all/app/src/pages/SelectionAsk.tsx +++ b/openless-all/app/src/pages/SelectionAsk.tsx @@ -87,7 +87,7 @@ export function SelectionAsk() { return ( <> - + diff --git a/openless-all/app/src/pages/Translation.tsx b/openless-all/app/src/pages/Translation.tsx index 49d59509..1376701d 100644 --- a/openless-all/app/src/pages/Translation.tsx +++ b/openless-all/app/src/pages/Translation.tsx @@ -89,7 +89,7 @@ export function Translation() { if (!prefs) { return ( <> - + {error ? (
@@ -160,7 +160,7 @@ export function Translation() { return ( <> - +
{error && ( diff --git a/openless-all/app/src/pages/_atoms.tsx b/openless-all/app/src/pages/_atoms.tsx index 3f173bff..38536590 100644 --- a/openless-all/app/src/pages/_atoms.tsx +++ b/openless-all/app/src/pages/_atoms.tsx @@ -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, }}> -
+
{kicker && (
{kicker}
)} @@ -41,11 +42,11 @@ export function PageHeader({ kicker, title, desc, right, titleRight }: PageHeade
{right && ( preferenceStack ? ( -
+
{right}
) : ( - right +
{right}
) )}
diff --git a/openless-all/app/src/pages/settings/ChannelList.tsx b/openless-all/app/src/pages/settings/ChannelList.tsx index c4f8cda8..b0251bbc 100644 --- a/openless-all/app/src/pages/settings/ChannelList.tsx +++ b/openless-all/app/src/pages/settings/ChannelList.tsx @@ -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>({}); - /** 刚验通过的短暂高亮(id → 延迟 ms),几秒后落回常驻的灰色数字。 */ - const [justPassed, setJustPassed] = useState>({}); 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 ( - -
- - {t(kind === 'llm' ? 'settings.providers.llmTitle' : 'settings.providers.asrTitle')} - -
-
- {t('settings.channels.orderHint')} +
+
+
+

{t(`settings.channels.${kind}Title`)}

+

{t('settings.channels.orderHint')}

+
+ void startCreate()}> + {creatingBusy ? t('common.loading') : t('settings.channels.add')} +
+ {!loaded &&
{t('common.loading')}
} {loaded && channels.length === 0 && ( -
- {t('settings.channels.empty')} +
+ +
{t('settings.channels.empty')}
)} -
+
{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 (
-
- 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', - }} - > - ⠿ - -
- {label} - {/* minHeight 保证「未命名 + 没验证过」的卡片不会比别的矮一截, - 列表高度参差看起来像坏了。 */} -
- {/* 未命名时主标题已经是厂商名,副行再来一遍就成了重复的两行同名。 */} - {channel.name.trim() && {presetLabel(kind, channel.providerType, t, descriptors)}} - {model && {model}} - {/* 验证结果什么时候来的 —— 让"这条结论会过期"这件事可见。 */} - {channel.lastTest && {relativeTime(channel.lastTest.at, t)}} +
+ 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' }} + >⠿ +
+
+ {label} + {isActive && {t('settings.channels.current')}} + {!channel.enabled && {t('settings.channels.disabled')}} +
+
+ {channel.name.trim() && {providerLabel} · } + + {model || t(localEngine ? 'settings.channels.localModelManaged' : 'settings.channels.modelNotSet')} + +
+
+
+ void runTest(channel)}> + {t(testingIds[channel.id] ? 'settings.channels.verifying' : 'settings.channels.verify')} + + + setEditingId(channel.id)}>{t('settings.channels.edit')}
-
- void runTest(channel)} - t={t} - /> - void onToggle(channel)} /> -
-
); })}
- - {editingChannel && ( )} - +
); } -/** - * 卡片上的验证按钮 —— **按钮自己就是结果容器**,不给结果另找地方摆文字。 - * - * 通过时只显示延迟数字:它既说明"通了",又带信息量(一眼看出哪张快); - * 再写一句"验证通过"是废话还占地方。失败则必须给出能指导行动的短标签 - * (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['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 ( - +
+ {t('settings.channels.lastCheck')} + {testing ? {t('settings.channels.verifying')} : !last ? ( + {t('settings.channels.notVerified')} + ) : ( + <> + + {passed ? t('settings.channels.passed') : t('settings.channels.failed', { reason: shortErrorLabel(last?.error ?? null, t) })} + + {passed && elapsed != null && {t('settings.channels.elapsed', { ms: elapsed })}} + {last && } + {stale && {t('settings.channels.staleResult')}} + + )} +
); } @@ -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(null); + const nameId = useId(); + + useEffect(() => { + const opener = document.activeElement; + const dialog = dialogRef.current; + const parentDialog = opener instanceof HTMLElement ? opener.closest('[role="dialog"]') : null; + const background = parentDialog !== dialog ? parentDialog : null; + const wasInert = background?.inert ?? false; + (dialog?.querySelector('[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) => { + 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( + '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 ( - -
- {t(isDraft ? 'settings.channels.createTitle' : 'settings.channels.editTitle')} -
- - - 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 }} - /> - - - { - onUserMutation(); - setName(e.target.value); - }} - onBlur={() => void saveName()} - placeholder={t('settings.channels.namePlaceholder')} - style={{ ...inputStyle, width: '100%', marginBottom: 14 }} - /> - - {/* key 决定:换供应商时整组凭据字段重挂载,读的是新厂商对应的槽位。 */} - void onChanged()} - onUserMutation={onUserMutation} - /> - - {isLocalEngine && ( -
- {t('settings.channels.localEngineModelHint')} -
- )} - -
- {confirmDelete ? ( -
- - {t('settings.channels.deleteConfirm')} - - - + +
+ +
+
+

{t(isDraft ? 'settings.channels.createTitle' : 'settings.channels.editTitle')}

+

{t('settings.channels.autoSaveHint')}

- ) : ( - - )} - + +
+ + + + 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 }} + /> + + + {/* Provider changes remount the existing, channel-scoped credential fields. */} + void onChanged()} + onUserMutation={onUserMutation} + /> + + {isLocalEngine &&

{t('settings.channels.localEngineModelHint')}

} + +
+ + { onUserMutation(); setName(e.target.value); }} + onBlur={() => void saveName()} + placeholder={t('settings.channels.namePlaceholder')} + style={{ ...inputStyle, width: '100%', maxWidth: '100%', height: 38 }} + /> + +

{t('settings.channels.nameHint')}

+
+ +
+ {confirmDelete ? ( +
+ {t('settings.channels.deleteConfirm')} + + +
+ ) : ( + + )} + {t('common.close')} +
); } -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, }; diff --git a/openless-all/app/src/pages/settings/LocalModelSection.tsx b/openless-all/app/src/pages/settings/LocalModelSection.tsx index 68c8efe7..d922165b 100644 --- a/openless-all/app/src/pages/settings/LocalModelSection.tsx +++ b/openless-all/app/src/pages/settings/LocalModelSection.tsx @@ -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(null); useEffect(() => { @@ -26,39 +23,19 @@ export function LocalModelSection() { const platformSupported = platformCaps?.supportsLocalAsr === true; return ( - - {/* 标题 + 右上角 inline 警告小字(实验性标记保留)。 - Windows:标题区整体灰显 —— 本地 ASR 在 Win 上走 Foundry / sherpa 独立路径。 */} -
-
-
{t('settings.advanced.localAsrTitle')}
+
+
+
+

{t('modal.serviceViews.models')}

+

{t('localAsr.performanceWarning')}

-
- ⚠️ {t('settings.advanced.localAsrWarningShort')} -
-
- - {!platformSupported ? ( -
- {t('settings.advanced.platformNotSupported')} -
- ) : ( - /* 模型下载 / 管理看板(模型选择 · 下载 · 删除 · 测试 · 镜像源收纳)—— - 较大的内嵌框。启用入口在「AI 提供商 → ASR 语音转写」选择本地供应商。 */ -
- -
- )} - + {t('localAsr.qwenExperimentalBadge')} + + {platformCaps === null ? ( +

{t('common.loading')}

+ ) : !platformSupported ? ( +

{t('settings.advanced.platformNotSupported')}

+ ) : } + ); } diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index 4a032e5f..885595b6 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -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 ( +
+ +
{children}
+
+ ); +} + +export function ChannelSectionHeading({ title, description }: { title: string; description?: string }) { + return ( +
+

{title}

+ {description &&

{description}

} +
+ ); +} + +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' && ( <> - )} +
)} /> + {providerType === 'custom' && ( + + )} setLlmModelRevision(v => v + 1)} onTested={onTested} onUserMutation={onUserMutation} /> @@ -234,7 +260,7 @@ export function ChannelCredentialFields({ if (descriptor?.authRequirement === 'volcengine') { return ( <> - + { @@ -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 }} /> - + {/* 两种模式使用各自独立的凭据槽位:旧版 Access Token(volcengine.access_key) 与方舟 API Key(volcengine.api_key)互不预填,切换模式不会残留混淆。 */} {volcengineAuthMode === 'app_id_token' ? ( @@ -273,6 +299,7 @@ export function ChannelCredentialFields({ )} +
+
)} + {(providerType === 'openai-compatible' || providerType === 'zenmux') && ( + + )} {/* 统一百炼「拉取模型」只写 model,不覆盖用户选择的区域或工作空间 endpoint。 */} setAsrModelRevision(v => v + 1)} onTested={onTested} onUserMutation={onUserMutation} /> - {(providerType === 'openai-compatible' || providerType === 'zenmux') && ( - - )} ); } @@ -580,6 +608,7 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested const [selectedModel, setSelectedModel] = useState(''); const [status, setStatus] = useState('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 && ( +
+ {message} +
+ ); + return ( - -
-
- - {showFetchModels && ( - - )} - {showFetchModels && models.length > 0 && ( - ({ 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 }} - /> - )} -
- {message && ( - - {message} - - )} -
-
+ <> + {showFetchModels && ( + +
+ + {models.length > 0 && ( + ({ value: model, label: model }))} + placeholder={t('settings.providers.selectModel')} + ariaLabel={t('settings.providers.selectModel')} + style={{ width: '100%', minWidth: 0, height: 38 }} + /> + )} +
+ {operation === 'models' && resultMessage} +
+ )} +
+ + + {operation === 'validate' && resultMessage} +
+ ); } @@ -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 ( - -
-
+ +
+
{options && !customModelMode ? ( ) : ( )} {options && customModelMode && ( @@ -934,7 +974,6 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de )} - {trailing} {mask && (
+ {trailing &&
{trailing}
} {showInsecureEndpointWarning && ( {t('settings.providers.endpointHttpWarning')} )}
- +
); } 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)', }; diff --git a/openless-all/app/src/pages/settings/navigation.test.ts b/openless-all/app/src/pages/settings/navigation.test.ts new file mode 100644 index 00000000..c8242875 --- /dev/null +++ b/openless-all/app/src/pages/settings/navigation.test.ts @@ -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'); diff --git a/openless-all/app/src/pages/settings/navigation.ts b/openless-all/app/src/pages/settings/navigation.ts new file mode 100644 index 00000000..78a941a2 --- /dev/null +++ b/openless-all/app/src/pages/settings/navigation.ts @@ -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(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]; +} diff --git a/openless-all/app/src/pages/settings/navigationLocale.test.ts b/openless-all/app/src/pages/settings/navigationLocale.test.ts new file mode 100644 index 00000000..67b62e5b --- /dev/null +++ b/openless-all/app/src/pages/settings/navigationLocale.test.ts @@ -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'); diff --git a/openless-all/app/src/pages/settings/shared.tsx b/openless-all/app/src/pages/settings/shared.tsx index 747accfd..10ebc4a5 100644 --- a/openless-all/app/src/pages/settings/shared.tsx +++ b/openless-all/app/src/pages/settings/shared.tsx @@ -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 锚点收缩到文字宽度,提示贴着文字弹出。 */} -
- {desc ? ( - - {label} - - ) : ( -
{label}
- )} +
+
{label}
+ {desc &&
{desc}
}
- {showRemoteInput && } - - {showDesktopShortcuts && } - - ); } +export function ShortcutsTab() { + const platformCaps = usePlatformCaps(); + if (!platformCaps?.supportsDesktopHotkey) return null; + return <>; +} + +export function AppearanceTab() { + return <>; +} + // 服务: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('llm'); + const views = availableServiceViews(multimodal, showLocalModel); + const selectedView = resolveServiceView(view, views); + const contentRef = useRef(null); + + useEffect(() => { + contentRef.current?.closest('.ol-thinscroll')?.scrollTo({ top: 0 }); + }, [selectedView]); return ( <> - - - {showLocalModel && } - +
+ {views.map(id => )} +
+
+ {selectedView === 'llm' && } + {selectedView === 'asr' && } + {selectedView === 'omni' && } + {selectedView === 'models' && } + {selectedView === 'connections' && <>} + {(selectedView === 'llm' || selectedView === 'asr') &&

{t('settings.providers.credentialStorageNotice')}

} +
); } diff --git a/openless-all/app/src/styles/global.css b/openless-all/app/src/styles/global.css index 2cbfe7b8..4a23cefb 100644 --- a/openless-all/app/src/styles/global.css +++ b/openless-all/app/src/styles/global.css @@ -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 的兼容怪癖。限制同上:不可滚动的容器 diff --git a/openless-all/app/src/styles/tokens.css b/openless-all/app/src/styles/tokens.css index a6265f99..63135c7f 100755 --- a/openless-all/app/src/styles/tokens.css +++ b/openless-all/app/src/styles/tokens.css @@ -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);