diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 8192bc96..05079cf4 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -505,6 +505,11 @@ jobs: - 以 `-tauri` 结尾的 tag 是**正式版**(自动推送给所有 in-app 检查更新的用户)。 - 以 `-beta-tauri` 结尾的 tag 是 **Beta 版**(GitHub 标 pre-release,**不**通过 in-app updater 推送给正式版用户;只对在「设置 → 关于 → 加入 Beta 渠道」开关切到 Beta 的用户可见,且需要手动从此页面下载安装)。 + ### 行为变更提示 + + - 流式输入默认开启;不兼容场景会自动回落到一次性插入。可在「设置 → 高级」关闭。 + - 流式输入成功后默认把最终文本同步到剪贴板,方便再次粘贴;可在「设置 → 高级」关闭。 + append_body: true # Matrix jobs all upload assets to the same release. Generate notes once # so macOS, Windows, and Linux jobs do not duplicate the release body. diff --git a/docs/style-pack-marketplace.md b/docs/style-pack-marketplace.md new file mode 100644 index 00000000..eba69293 --- /dev/null +++ b/docs/style-pack-marketplace.md @@ -0,0 +1,299 @@ +# Style Pack Marketplace — 规划文档 + +**状态**:规划中(API 已预留 stub,未实装) +**起草日期**:2026-05-14 +**owner**:待定 + +## 1. 目标 + +把现在「ZIP 包本地导入 / 导出」的体验扩展成一个公开的风格包市场: + +- 用户可以把自己调好的风格包**上传**到云端,附带名称、描述、作者署名、标签、效果示例 +- 其他用户可以**浏览 / 搜索 / 下载**别人的风格包,一键安装到本地 +- 后期支持**版本升级提醒**、**收藏 / 评分**等基础社交属性 + +非目标(v1 不做): +- 付费 / 抽成 +- 风格包内嵌外部 prompt 注入 / 跨域 fetch(安全考虑,风格包始终是纯文本 prompt) +- 多人协作编辑 / fork + +## 2. 架构概览 + +``` +┌──────────────────┐ HTTPS ┌─────────────────────┐ +│ OpenLess client │ ◄──────────────────► │ marketplace API │ +│ (Tauri 2) │ JSON over TLS │ (TBD: Cloudflare │ +│ │ │ Workers / D1 / │ +│ Rust IPC → │ │ R2 for blobs) │ +│ reqwest client │ │ │ +└──────────────────┘ └─────────────────────┘ + │ │ + │ local cache (~/Library/Application │ + │ Support/OpenLess/market_cache/) │ + ▼ ▼ + StylePackStore Postgres / D1 + (existing local listings + R2 blobs + persistence layer) +``` + +**关键约束**: +- 客户端只能上传 / 下载 ZIP **bundle**(不直接传 JSON),保持跟现有 ZIP import/export 同构 +- 服务端 ZIP 验证:解压后必须能反序列化成 `StylePack`、`prompt.chars().count() <= 50_000`、没有可执行附件 +- 风格包 ID 上传后由服务端分配(`{author_slug}-{name_slug}-{version}`),跟本地 ID 解耦 +- 客户端始终拿 ZIP 走现有 `import_style_pack_from_zip` 路径入库 —— 不另开一条「从市场直接写 Pack」的代码路径,避免双入口 + +## 3. HTTP API 规约 + +Base URL(待定):`https://api.openless.app/v1/marketplace/` + +所有响应统一信封: +```json +{ + "ok": true, + "data": | null, + "error": null | { "code": "ERR_XXX", "message": "..." } +} +``` + +### 3.1 GET `/packs` — 列表 / 搜索 + +Query: +| 参数 | 类型 | 默认 | 说明 | +|---|---|---|---| +| `q` | string | `""` | 关键词(名称 / 描述 / 标签) | +| `tag` | string | `""` | 单标签筛选 | +| `sort` | `recent` \| `popular` \| `name` | `recent` | 排序 | +| `cursor` | string | `null` | 分页游标 | +| `limit` | int (1-100) | `20` | 每页条数 | + +Response data: +```typescript +{ + packs: MarketPackListing[]; + next_cursor: string | null; +} +``` + +`MarketPackListing`: +```typescript +{ + id: string; // server-assigned, e.g. "alice-formal-v2.1" + name: string; + description: string; + author: string; + version: string; // semver + tags: string[]; + base_mode: "raw" | "light" | "structured" | "professional"; + recommended_model: string | null; + compatible_app_version: string | null; + downloads: number; + rating_avg: number | null; + rating_count: number; + updated_at: string; // ISO8601 + zip_size_bytes: number; + zip_sha256: string; // 客户端下载后校验 +} +``` + +### 3.2 GET `/packs/{id}` — 详情 + +Response data:`MarketPackListing` + 额外字段: +```typescript +{ + ...listing, + examples: StylePackExample[]; // 解压 ZIP 前的预览 + changelog: string | null; + homepage_url: string | null; +} +``` + +### 3.3 GET `/packs/{id}/download` — 下载 ZIP + +Response:`application/zip` 二进制流,带 `X-Pack-SHA256` header 用于校验。 + +服务端通过 redirect 直接指向 R2 / S3 预签 URL,避免代理流量。 + +### 3.4 POST `/packs` — 上传(需鉴权) + +Headers:`Authorization: Bearer ` +Body:`multipart/form-data` with field `pack=@xxx.zip` + +Response data:`MarketPackListing`(含新分配 id) + +错误码: +- `ERR_INVALID_ZIP` — ZIP 解压失败 / 不是合法 StylePack JSON +- `ERR_PROMPT_TOO_LARGE` — prompt 字数超 50k +- `ERR_DUPLICATE_VERSION` — 同 author+name+version 已存在 +- `ERR_RATE_LIMITED` — 触发限频 + +### 3.5 DELETE `/packs/{id}` — 撤回(需鉴权 + 必须是上传者) + +### 3.6 POST `/packs/{id}/rate` — 评分(需鉴权) + +Body:`{ score: 1..5, comment?: string }` + +## 4. IPC 契约(Rust ↔ TS) + +在 `src-tauri/src/commands.rs` 新增以下 stub(暂返回 `Err("not implemented yet")`,等服务端落地后实装): + +```rust +// 列表 / 搜索 +#[tauri::command] +pub async fn market_list_packs( + query: Option, + tag: Option, + sort: Option, + cursor: Option, + limit: Option, +) -> Result; + +// 详情 +#[tauri::command] +pub async fn market_get_pack(id: String) -> Result; + +// 下载 + 自动调用现有的 import_style_pack_from_zip 入库 +#[tauri::command] +pub async fn market_download_pack( + coord: CoordinatorState<'_>, + app: AppHandle, + id: String, +) -> Result; + +// 上传(dirty 字段 = 已编辑、未保存) +#[tauri::command] +pub async fn market_upload_pack( + coord: CoordinatorState<'_>, + pack_id: String, + api_key: String, +) -> Result; + +// 撤回 +#[tauri::command] +pub async fn market_delete_pack(id: String, api_key: String) -> Result<(), String>; + +// 评分 +#[tauri::command] +pub async fn market_rate_pack( + id: String, + api_key: String, + score: u8, + comment: Option, +) -> Result<(), String>; +``` + +DTO(在 `types.rs` 新增): +```rust +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct MarketPackListing { + pub id: String, + pub name: String, + pub description: String, + pub author: String, + pub version: String, + pub tags: Vec, + pub base_mode: PolishMode, + pub recommended_model: Option, + pub compatible_app_version: Option, + pub downloads: u64, + pub rating_avg: Option, + pub rating_count: u32, + pub updated_at: String, + pub zip_size_bytes: u64, + pub zip_sha256: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct MarketPackDetail { + #[serde(flatten)] + pub listing: MarketPackListing, + pub examples: Vec, + pub changelog: Option, + pub homepage_url: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct MarketListResponse { + pub packs: Vec, + pub next_cursor: Option, +} +``` + +TS wrappers(`src/lib/ipc.ts`): +```typescript +export interface MarketPackListing { /* same shape */ } +export interface MarketPackDetail extends MarketPackListing { /* + examples, changelog, homepage_url */ } +export interface MarketListResponse { packs: MarketPackListing[]; next_cursor: string | null; } + +export function marketListPacks(opts: { + query?: string; tag?: string; sort?: 'recent' | 'popular' | 'name'; + cursor?: string; limit?: number; +}): Promise; +export function marketGetPack(id: string): Promise; +export function marketDownloadPack(id: string): Promise; +export function marketUploadPack(packId: string, apiKey: string): Promise; +export function marketDeletePack(id: string, apiKey: string): Promise; +export function marketRatePack(id: string, apiKey: string, score: number, comment?: string): Promise; +``` + +## 5. 鉴权模型 + +**v1 简化方案**: +- 用户在设置页输入个人 API key(服务端发放) +- API key 存到 OS Keychain,账户名 `com.openless.app.market_api_key` +- 客户端在 Header 加 `Authorization: Bearer ` +- 服务端校验 + 限频(每小时 60 次写、600 次读) + +**v2 升级路径**(暂不做): +- OAuth via GitHub / Google +- 上传时自动签名 ZIP,下载端校验签名 + +## 6. 缓存与版本检查 + +本地缓存目录:`/market_cache/` +- `listings.json` — 上次拉的 listings(带 ETag) +- `packs/{id}.zip` — 已下载的 ZIP(按需保留,30 天自动清理) + +版本升级提示: +- 启动时(带 dev-cap 24h 节流)调用 `/packs?ids=<已安装的 market_id...>` 拉对比 +- 本地包记录 `installed_market_id` 和 `installed_market_version` 字段,新建 `StylePack` 时填,本地从 ZIP 安装也填 +- 发现新版本 → 在 Style 页该包卡片角标显示 `New version: 2.3.0 →` + +## 7. 客户端 UI 入口(v1 不做,先留位) + +- Style 页头部加一个 tab:`本地 / 市场` +- 市场页:搜索栏 + tag 过滤 + 卡片列表 + 详情抽屉 +- 上传:编辑某个本地包时,"导出 ZIP" 按钮旁边出现 "上传到市场"(需要先在设置里填 API key) + +## 8. 安全 / 滥用对策 + +- ZIP 解压走 streaming,限制最大解压后大小 5 MB +- prompt 字段过滤明显的 prompt injection / 越狱(关键词预扫描 + 异步内容审核) +- 每用户每天上传上限 10 包,单包大小 ≤ 2 MB +- 上传后挂 24h 公开延迟(防恶意刷榜) + +## 9. 实装 TODO(按优先级) + +- [ ] 服务端选型(CF Workers + D1 + R2 vs Supabase vs 自托管 FastAPI) +- [ ] 服务端实装 + 部署环境(dev / staging / prod) +- [ ] 客户端 `types.rs` 加 DTO +- [ ] `commands.rs` 加 6 个 stub(**已完成**,返回 `not implemented yet`) +- [ ] `lib/ipc.ts` 加 wrapper(**已完成**) +- [ ] 实装 `market_download_pack`(先做单条路径打通:URL → 下载 → 走现有 import_style_pack_from_zip) +- [ ] 加凭据存储(Keychain 复用现有 `CredentialsVault`) +- [ ] UI:本地 / 市场 tab +- [ ] UI:搜索 + 卡片 +- [ ] UI:详情面板 +- [ ] UI:上传流程 +- [ ] 升级提醒 badge +- [ ] 缓存清理 + ETag + +## 10. 决策 / 风险记录 + +| 项 | 决策 | Why | +|---|---|---| +| ZIP 而非 JSON 上传 | 用 ZIP | 跟现有 import/export 同构;prompt 长文 + examples 用 ZIP 包压缩 | +| 服务端分配 ID | 是 | 防本地 ID 碰撞、用户重命名包不影响订阅 | +| 上传立刻可见 vs 审核 | 24h 公开延迟 | 防刷榜 + 给审核留空间 | +| API key vs OAuth | 先 API key | 简化 v1;登录态可 v2 升级 | +| 客户端缓存策略 | listings ETag + 已下载 ZIP 30 天 | 平衡流量和体验 | +| 国际化 / 跨境 | API 全英文 + 客户端 i18n | 服务端不存翻译,名称/描述支持任意 UTF-8 | diff --git a/issue-420-wayland-plan.md b/issue-420-wayland-plan.md new file mode 100644 index 00000000..1bcd729d --- /dev/null +++ b/issue-420-wayland-plan.md @@ -0,0 +1,317 @@ +# #420 Wayland 支持方案说明 + +> 适用范围:`/home/chris233/openless` +> 关联 issue:[#420](https://github.com/Open-Less/openless/issues/420) +> 目标:给 OpenLess 在 Linux / Wayland 下补一条可靠、与当前仓库决策一致的实现路径,而不是继续把 X11 思路硬套过去。 + +## 1. 当前问题拆分 + +#420 现在实际上混了三类问题: + +1. **Wayland 下全局快捷键不可用** + - 这是因为 Wayland 安全模型不允许普通应用像 X11 那样监听全局键盘事件。 + - 当前仓库已经把 CLI + single-instance 路径做成 Wayland 下的正式可交付方案;portal 仍属于后续研究方向,而不是现阶段已落定的主实现。 + +2. **Wayland 下文本输出不可靠** + - 流式输出路径:`unicode_keystroke.rs` 在 Linux 仍走 `enigo.text(...)`。 + - 一次性输出路径:`insertion.rs` 仍走 `clipboard + simulate_paste(enigo)`。 + - 这两条路径本质都还是 X11 风格假设,在 Wayland 下可能“调用成功但没真正落字”。 + +3. **Wayland 下设置页快捷键录制 / UI 黑屏闪烁** + - 这更像 WebKitGTK / 合成器 / 输入录制 UI 的独立问题。 + - 不应继续和“Wayland 全局快捷键”或“Wayland 文本输出”混成一个修复面。 + +## 2. 关键判断 + +### 2.1 Wayland 有多层可行路径,但不能把尚未验证的 portal 能力写成既定主路线 + +必须分开看: + +- **全局快捷键触发**: + - 从协议方向看,portal / compositor 能力值得研究; + - 但从**当前仓库已落地实现**与跨桌面可交付性看,正式支持路径已经是 `CLI + single-instance 转发`; + - `xdg-desktop-portal` 的 `GlobalShortcuts` 现阶段更适合作为 research track,而不是直接写成产品承诺。 +- **文本插入**:没有 X11 那种“应用可随意向其他应用发键”的通用能力。 + - 剪贴板有现实可行路。 + - 自动输入只能走 **受权限控制** 的 portal / libei / compositor 能力。 + - 不存在一个对所有 Wayland 桌面都等价、无感、无授权的统一注入接口。 + +### 2.2 现阶段最高优先级不是“自动输入一步到位”,而是“用户文本不能丢” + +当前最危险的问题不是“Wayland 下体验不够自动化”,而是: + +- 日志显示成功 +- OpenLess 认为已经插入 +- 用户实际输入框里没有字 + +这个行为会直接破坏产品的核心承诺:**用户的话不能丢**。 + +## 3. 建议总方案 + +按三个阶段推进,而不是一口气追求全自动。 + +--- + +## Phase 1:先止血,确保文本不丢 + +### 目标 + +在 Wayland 下,即使没有自动输入能力,也必须保证: + +- 听写结果至少可靠进入剪贴板 +- UI / 日志明确告诉用户当前走的是哪条 fallback +- 不再出现“代码认为成功,屏幕实际没字”的假成功状态 + +### 建议改动 + +#### 3.1 禁用 Wayland 下的“streaming insert 成功语义” + +当前逻辑里,Linux 流式路径一旦 `type_unicode_chunk()` 返回成功,就会: + +- 累积 `typed_text` +- 标记 `already_streamed=true` +- 跳过后续 inserter + +这在 Wayland 下不可靠。 + +**建议:** +- 检测 `Linux + Wayland` 时,不让 `enigo.text(...)` 的返回值直接成为“已成功插入”的依据。 +- Wayland 下默认不要走 `already_streamed=true` 的成功短路。 + +#### 3.2 Wayland 下默认降级为 copy-only + +当前非流式路径是: + +- 写入剪贴板 +- 再用 `simulate_paste()` 发粘贴快捷键 + +Wayland 下第二步不可靠。 + +**建议:** +- 检测到 Wayland 时,默认走 **copy-only fallback**。 +- 把文本留在剪贴板里,不要立即 restore。 +- 明确给用户提示:`已复制到剪贴板,请手动粘贴`。 + +#### 3.3 把状态文案改成真话 + +需要避免如下误导: + +- “已插入”但实际上没插入 +- “已尝试粘贴”但用户无从判断文本是否已落到目标应用 + +**建议:** +- Wayland fallback 时统一使用明确状态: + - `已复制到剪贴板,请手动粘贴` + - `Wayland 当前未启用自动输入` + - `剪贴板写入失败` + +### Phase 1 接受标准 + +- Wayland 下听写后,文本不会 silently disappear。 +- 即使自动输入失败,用户也总能从剪贴板找回文本。 +- 日志和 UI 状态与真实行为一致。 + +--- + +## Phase 2:巩固当前 Wayland 触发路径 + +### 目标 + +把 Wayland 下已经落地的 `CLI + single-instance` 方案补齐到真正稳定、清晰、可交付,而不是在文档里把尚未验证的 portal 能力提前写成主路线。 + +### 建议改动 + +#### 3.4 明确把 CLI 路径当作当前正式支持方案 + +当前仓库已采用的路径是: + +1. 启动时检测 Wayland session +2. 不安装 `rdev` 全局监听 +3. 通过桌面环境快捷键执行: + - `openless --toggle-dictation` + - `openless --toggle-qa` + - `openless --cancel-dictation` +4. 由 `tauri-plugin-single-instance` 把第二实例 argv 转发给主实例 coordinator + +这里要做的不是推翻,而是补齐: + +- Settings / README / Linux 指南里统一说明这是当前正式支持方式; +- 保证 GNOME / KDE / Hyprland / sway 等示例文案一致; +- 保证“有快捷键可触发”这件事在 Wayland 上可复现、可说明、可排障。 + +#### 3.5 portal 研究保留为后续增强方向 + +`xdg-desktop-portal` `GlobalShortcuts` 可以继续研究,但在仓库明确验证下面几点之前,不应写成主承诺: + +- GNOME / KDE / 其他桌面上的真实可用范围 +- 权限/交互模型是否符合产品心智 +- 回退链路是否比当前 CLI 方案更简单而不是更碎 + +### 为什么这一层应该单独做 + +- 这是当前仓库已经落地的 Wayland 触发方案; +- 它能解决 #420 最核心的“如何触发听写”问题; +- 维护成本和跨桌面稳定性目前都优于贸然切 portal 主路线。 + +### Phase 2 接受标准 + +- Wayland 用户按文档/设置页说明配置后,能稳定触发 Dictation / QA / Cancel。 +- 设置页、README、日志三处对 Wayland 触发方式的表述一致。 +- 不把 `GlobalShortcuts portal` 写成已交付能力;如继续研究,应另开 research issue / PR。 + +--- + +## Phase 3:研究受权限控制的 Wayland 自动输入能力 + +### 目标 + +探索 Wayland 下真正的“自动把文本发到其他应用”能力,但只在 **有 compositor 支持 + 有用户授权** 的情况下启用。 + +### 候选路径 + +#### 3.5 `RemoteDesktop` portal + keyboard events + +优点: +- 有官方 portal 文档 +- 权限模型明确 + +缺点: +- 会话 / 授权交互更重 +- 行为更像“远程控制权限”,不一定适合所有用户心智 + +#### 3.6 `RemoteDesktop` / `InputCapture` + `ConnectToEIS` + `libei` + +优点: +- 这是 Wayland / compositor 体系里更现代的输入模拟路径 +- 比直接赌 `enigo` / XTest 靠谱 + +缺点: +- 实现复杂度高 +- compositor / backend 支持碎片化 +- 仍然不是“全桌面无感通吃”的方案 + +#### 3.7 不建议把主方案押在 `virtual-keyboard-unstable-v1` + +原因: +- 协议本身就标明不适合当通用稳定能力依赖 +- compositor 是否开放给第三方应用不可控 +- 产品层面碎片化风险太高 + +### Phase 3 的产品策略 + +自动输入必须是: + +- **能力探测通过** 才启用 +- **授权成功** 才启用 +- 失败时明确回退到剪贴板方案 + +换句话说: + +> Wayland 自动输入应该是“可选增强能力”,不是默认基本能力。 + +--- + +## 4. 对 #420 的建议拆单 + +建议把后续工作拆成三个 issue / PR 方向: + +### 4.1 `wayland-output-safety` +范围: +- Wayland 下禁用假成功 streaming insert +- Wayland 下默认 copy-only +- 状态文案 / 日志对齐真实行为 + +这是最高优先级。 + +### 4.2 `wayland-trigger-path-hardening` +范围: +- 巩固 `CLI + single-instance` 触发链路 +- Settings / README / Linux 文档统一 +- GNOME / KDE / Hyprland / sway 示例与排障说明对齐 + +这是第二优先级。 + +### 4.3 `wayland-global-shortcuts-portal-research` +范围: +- 评估 `GlobalShortcuts` portal 的真实桌面支持面 +- 验证是否值得从 research 升级为产品能力 +- 只产出调研/原型,不提前改写当前支持承诺 + +这是后续研究方向,不应与当前可交付方案混写。 + +### 4.4 `wayland-hotkey-editor-flicker` +范围: +- 设置页快捷键录制时的闪烁 / 黑屏 +- 只针对 UI / WebKitGTK / 输入录制链路处理 + +这个不要再跟“文本输出”绑一起看。 + +--- + +## 5. 我建议的实际落地顺序 + +### 第一刀(应先做) +- 修 `Wayland 文本输出不可靠` +- 核心目标:**不丢文本** + +### 第二刀 +- 巩固 `CLI + single-instance` 触发链路 +- 核心目标:**让当前 Wayland 方案真正稳定、清晰、可交付** + +### 第三刀 +- 研究 `GlobalShortcuts portal` / `portal + libei` 能力 +- 核心目标:**评估哪些能力值得升级成未来增强项** + +### 第四刀 +- 单独处理设置页闪烁 / 黑屏 + +--- + +## 6. 不建议做的事 + +### 6.1 不建议继续把 `enigo` 返回值当 Wayland 成功依据 + +因为这会继续制造: +- 日志成功 +- UI 成功 +- 用户实际没看到字 + +### 6.2 不建议把未验证的 portal 方案直接写成当前主实现 + +在仓库已经正式落地 CLI 路径的前提下,把 portal 提前写成“既定正路”,会让文档、代码与用户预期再次脱节。 + +### 6.3 不建议把 `virtual-keyboard-unstable-v1` 直接当主实现 + +它更像 compositor 特定能力,不适合直接做成发行版通用路径。 + +--- + +## 7. 结论 + +Wayland 下当然应该走一条“属于 Wayland 的路”,但这条路在当前仓库里应分成两层: + +1. **当前正式触发路径** → `CLI + single-instance` +2. **剪贴板保底** → Wayland-native clipboard / copy-only fallback +3. **未来增强候选** → `GlobalShortcuts portal`、`RemoteDesktop` / `InputCapture` + `libei/EIS`(能力探测 + 用户授权) + +如果只能先做一件事,优先级一定是: + +> **先修文本输出链路,保证用户的话不会丢。** + +--- + +## 8. 参考资料(用于后续实现,不是最终用户文案) + +- XDG Portal GlobalShortcuts + https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html +- XDG Portal RemoteDesktop + https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.RemoteDesktop.html +- XDG Portal InputCapture + https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.InputCapture.html +- XDG Portal Clipboard + https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Clipboard.html +- libei 文档 + https://libinput.pages.freedesktop.org/libei/ +- Wayland core / data transfer model + https://wayland.pages.freedesktop.org/wayland.freedesktop.org/docs/html/ch04.html + https://wayland.freedesktop.org/docs/html/apa.html diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json index 7b65fe09..c0459d72 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "openless-app", - "version": "1.3.1", + "version": "1.3.2-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openless-app", - "version": "1.3.1", + "version": "1.3.2-3", "dependencies": { "@tauri-apps/api": "^2.1.1", "@tauri-apps/plugin-autostart": "^2.5.1", diff --git a/openless-all/app/package.json b/openless-all/app/package.json index ecfda333..c23e6a2c 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -1,7 +1,7 @@ { "name": "openless-app", "private": true, - "version": "1.3.1", + "version": "1.3.2-3", "type": "module", "scripts": { "dev": "vite", diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 9d517a8f..69333729 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -3751,7 +3751,7 @@ dependencies = [ [[package]] name = "openless" -version = "1.3.1" +version = "1.3.2-3" dependencies = [ "anyhow", "arboard", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 245fd29b..5600414e 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openless" -version = "1.3.1" +version = "1.3.2-3" description = "OpenLess — local voice input that types where your cursor is" authors = ["OpenLess"] edition = "2021" diff --git a/openless-all/app/src-tauri/src/asr/volcengine.rs b/openless-all/app/src-tauri/src/asr/volcengine.rs index 7d28fc05..83708fe2 100644 --- a/openless-all/app/src-tauri/src/asr/volcengine.rs +++ b/openless-all/app/src-tauri/src/asr/volcengine.rs @@ -50,6 +50,12 @@ pub enum VolcengineASRError { CredentialsMissing, #[error("connection failed: {0}")] ConnectionFailed(String), + /// WebSocket 握手阶段服务端返回 401 / 403:凭据被拒。 + /// 区分自 `ConnectionFailed`(DNS/TLS/网络层失败)—— 前者通常是 App ID / Access + /// Token / Resource ID 错或账号没开通 bigmodel;后者是网络断 / 防火墙 / DNS。 + /// 文案简短,原因在文档里说明,capsule 不堆长引导。 + #[error("凭据被拒({0})")] + AuthRejected(u16), #[error("authentication failed")] AuthenticationFailed, #[error("no final result")] @@ -155,9 +161,7 @@ impl VolcengineStreamingASR { .map_err(|e| VolcengineASRError::ConnectionFailed(e.to_string()))?, ); - let (ws, _resp) = connect_async(request) - .await - .map_err(|e| VolcengineASRError::ConnectionFailed(e.to_string()))?; + let (ws, _resp) = connect_async(request).await.map_err(classify_connect_error)?; let (write, read) = ws.split(); let (tx, rx) = oneshot::channel(); @@ -637,6 +641,20 @@ fn normalized_result(json: &Value) -> Option<&Value> { None } +/// 把 tokio-tungstenite 的 connect 错误分类:握手收到 HTTP 401 / 403 → `AuthRejected` +/// (凭据被拒,要 user 检查 App ID / Access Token / 账号资源开通状态);其它 → 通用 +/// `ConnectionFailed`(DNS / TLS / 网络层)。让 capsule 文案能跟泛泛 HTTP error 区分。 +fn classify_connect_error(err: tokio_tungstenite::tungstenite::Error) -> VolcengineASRError { + use tokio_tungstenite::tungstenite::Error as WsError; + if let WsError::Http(resp) = &err { + let status = resp.status().as_u16(); + if status == 401 || status == 403 { + return VolcengineASRError::AuthRejected(status); + } + } + VolcengineASRError::ConnectionFailed(err.to_string()) +} + fn hotword_context(entries: &[DictionaryHotword]) -> Option { let mut seen: Vec = Vec::new(); for entry in entries { diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 9859d8ca..618da137 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -436,8 +436,8 @@ pub async fn start_microphone_level_monitor( let level_handler: Arc = Arc::new(move |level| { let _ = level_app.emit("microphone:level", serde_json::json!({ "level": level })); }); - let (recorder, _runtime_errors) = - Recorder::start(microphone_device_name, consumer, level_handler) + let (recorder, _runtime_errors, _archive_active) = + Recorder::start(microphone_device_name, consumer, level_handler, None) .map_err(|e| e.to_string())?; *state.lock() = Some(recorder); Ok(()) @@ -1115,6 +1115,70 @@ pub fn clear_history(coord: CoordinatorState<'_>) -> Result<(), String> { coord.history().clear().map_err(|e| e.to_string()) } +/// 读取某次会话的原始麦克风 wav 字节流。仅当用户开过 +/// `prefs.record_audio_for_debug` 并且这条 session 是开关打开后录的,才会有文件。 +/// 文件名规约:`/recordings/.wav`,与 DictationSession.id 同名。 +/// +/// 路径校验:session_id **必须**严格匹配 UUID-v4 字面(36 字符 = 8-4-4-4-12 + 4 个 `-`, +/// 内容仅 ASCII 十六进制 + `-`)。白名单胜过黑名单——绝对路径前缀、Windows ADS、 +/// 百分号编码、NUL 字节都不在合法字符集里,挡掉所有 Path::join 越界的可能。 +/// session_id 在仓库内由 `Uuid::new_v4()` 生成 (`dictation.rs:1531`),前端只会回传 +/// 自己列出的合法 id,但 IPC = boundary,按 boundary 规则严格校验。 +/// +/// async fs:单条 5 分钟 wav 约 9.6MB,同步 `std::fs::read` 会阻塞 Tauri IPC 主循环。 +/// 改 `tokio::fs::read` 后让出线程给其它 IPC。 +#[tauri::command] +pub async fn read_audio_recording(session_id: String) -> Result, String> { + if !is_valid_session_id(&session_id) { + return Err("invalid session id".into()); + } + let path = + crate::persistence::recording_path_for_session(&session_id).map_err(|e| e.to_string())?; + if !path.exists() { + return Err("recording not found".into()); + } + // TOCTOU 兜底:exists() 通过到 read 之间文件可能被 prune(条数 cap / retention + // 清理 / 用户手动删)。把 NotFound 标准化成跟 exists() 失败同样的错误字符串, + // 前端单条 'recording not found' catch 就能稳定隐藏按钮,不依赖本地化 OS 错误。 + tokio::fs::read(&path).await.map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + "recording not found".into() + } else { + format!("read wav failed: {e}") + } + }) +} + +/// UUID-v4 字面校验:36 字符 + 5 段 `-` 分隔(8-4-4-4-12)+ 仅 ASCII 十六进制。 +/// 用于 install/detail/like —— pack_id 来自远端服务器,必须是它发的 UUID。 +fn is_valid_session_id(s: &str) -> bool { + if s.len() != 36 { + return false; + } + let bytes = s.as_bytes(); + for (i, b) in bytes.iter().enumerate() { + let is_dash_position = matches!(i, 8 | 13 | 18 | 23); + if is_dash_position { + if *b != b'-' { + return false; + } + } else if !b.is_ascii_hexdigit() { + return false; + } + } + true +} + +/// 本地 style pack id 白名单:`[A-Za-z0-9._-]`、长度 1..=128。 +/// 上传走本地 id(`builtin.light` / 用户自取 slug / UUID 都可),不是远端 UUID。 +/// 仍阻断 `..` / `/` / `\` / 控制字符,避免 path traversal 进临时 zip 文件名。 +fn is_valid_local_pack_id(s: &str) -> bool { + if s.is_empty() || s.len() > 128 { + return false; + } + s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-' || b == b'_') +} + // ─────────────────────────── vocab ─────────────────────────── #[tauri::command] @@ -1256,6 +1320,26 @@ pub fn list_style_packs(coord: CoordinatorState<'_>) -> Result, S .map_err(|e| e.to_string()) } +#[tauri::command] +pub fn create_style_pack_from_template( + coord: CoordinatorState<'_>, + app: AppHandle, + template: StylePack, +) -> Result { + log::info!( + "[style-pack] command create_from_template name={} base_mode={:?}", + template.name, + template.base_mode + ); + let created = coord + .style_packs() + .create_from_template(template) + .map_err(|e| e.to_string())?; + let prefs = coord.prefs().get(); + let _ = sync_style_pack_prefs_and_persist(&*coord, &app, prefs)?; + Ok(created) +} + #[tauri::command] pub fn save_style_pack( coord: CoordinatorState<'_>, @@ -2200,16 +2284,366 @@ pub fn export_error_log(target_path: String) -> Result<(), String> { #[allow(dead_code)] fn _ensure_snapshot_used(_: CredentialsSnapshot) {} +// ─────────────────────────── marketplace (Phase A) ─────────────────────────── +// +// 客户端跟 marketplace backend 的 HTTP 客户端封装。Backend URL 走 prefs +// `marketplace_base_url`(默认 http://127.0.0.1:8090 开发;生产用户填 https://api.)。 +// dev-mode auth:用户在 Settings 填 `marketplace_dev_login`(GitHub 风格 username), +// 后续 OAuth 接入时换成 token 字段。 +// +// 5 个 IPC: +// - marketplace_list 列表 + 搜索 + 排序 +// - marketplace_detail 详情(含完整 prompt) +// - marketplace_install 下载 ZIP + 直接调 import_from_zip 装到本地 +// - marketplace_upload 把本地某个 style pack export ZIP → multipart 上传 +// - marketplace_like 点赞 + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct MarketplaceListItem { + pub id: String, + pub slug: String, + pub name: String, + pub description: String, + #[serde(default)] + pub author_login: String, + pub version: String, + pub base_mode: String, + #[serde(default)] + pub tags: Vec, + pub like_count: i64, + pub download_count: i64, + pub published_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct MarketplaceDetail { + #[serde(flatten)] + pub summary: MarketplaceListItem, + pub prompt: String, + pub state: String, +} + +fn marketplace_url_from_prefs(prefs: &UserPreferences) -> String { + let base = prefs.marketplace_base_url.trim(); + if base.is_empty() { + "http://127.0.0.1:8090".to_string() + } else { + base.trim_end_matches('/').to_string() + } +} + +fn marketplace_dev_user(prefs: &UserPreferences) -> String { + let login = prefs.marketplace_dev_login.trim(); + if login.is_empty() { + "anonymous".to_string() + } else { + login.to_string() + } +} + +#[tauri::command] +pub async fn marketplace_list( + coord: CoordinatorState<'_>, + query: Option, + sort: Option, + limit: Option, +) -> Result, String> { + let prefs = coord.prefs().get(); + let base = marketplace_url_from_prefs(&prefs); + let mut url = reqwest::Url::parse(&format!("{base}/packs")) + .map_err(|e| format!("invalid marketplace url: {e}"))?; + if let Some(q) = query.as_deref() { + if !q.trim().is_empty() { + url.query_pairs_mut().append_pair("q", q.trim()); + } + } + if let Some(s) = sort.as_deref() { + if !s.trim().is_empty() { + url.query_pairs_mut().append_pair("sort", s.trim()); + } + } + if let Some(n) = limit { + url.query_pairs_mut().append_pair("limit", &n.to_string()); + } + let client = reqwest::Client::new(); + let resp = client + .get(url) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await + .map_err(|e| format!("marketplace request failed: {e}"))?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("marketplace HTTP {status}: {body}")); + } + let items: Vec = + resp.json().await.map_err(|e| format!("parse failed: {e}"))?; + Ok(items) +} + +#[tauri::command] +pub async fn marketplace_detail( + coord: CoordinatorState<'_>, + pack_id: String, +) -> Result { + if !is_valid_session_id(&pack_id) { + return Err("invalid pack id".into()); + } + let prefs = coord.prefs().get(); + let base = marketplace_url_from_prefs(&prefs); + let client = reqwest::Client::new(); + let resp = client + .get(format!("{base}/packs/{pack_id}")) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await + .map_err(|e| format!("marketplace request failed: {e}"))?; + if !resp.status().is_success() { + let status = resp.status(); + return Err(format!("marketplace HTTP {status}")); + } + resp.json::() + .await + .map_err(|e| format!("parse failed: {e}")) +} + +#[tauri::command] +pub async fn marketplace_install( + coord: CoordinatorState<'_>, + pack_id: String, +) -> Result { + // 安全校验:pack_id 来自远端 backend,可能含路径遍历 segment。 + // 用跟 read_audio_recording 同样的 UUID-v4 白名单挡住 ../ / 绝对路径等。 + // backend 当前用 Uuid::new_v4 生成所有 id,合法 id 必然匹配。 + if !is_valid_session_id(&pack_id) { + return Err("invalid pack id".into()); + } + let prefs = coord.prefs().get(); + let base = marketplace_url_from_prefs(&prefs); + let client = reqwest::Client::new(); + + // 先拉 detail 拿 authorLogin —— 装好后本地写 originAuthorLogin, + // 后续编辑+发布时 backend 据此判 supersede(原作者)vs derivative(他人 fork)。 + let detail_url = format!("{base}/packs/{pack_id}"); + let detail: serde_json::Value = client + .get(&detail_url) + .timeout(std::time::Duration::from_secs(15)) + .send() + .await + .map_err(|e| format!("marketplace detail failed: {e}"))? + .error_for_status() + .map_err(|e| format!("marketplace detail HTTP error: {e}"))? + .json() + .await + .map_err(|e| format!("parse detail failed: {e}"))?; + let origin_author_login = detail + .get("authorLogin") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let bytes = client + .get(format!("{base}/packs/{pack_id}/download")) + .timeout(std::time::Duration::from_secs(30)) + .send() + .await + .map_err(|e| format!("marketplace download failed: {e}"))? + .error_for_status() + .map_err(|e| format!("marketplace HTTP error: {e}"))? + .bytes() + .await + .map_err(|e| format!("read body failed: {e}"))?; + + // pack_id 已经过 UUID 白名单,拼临时文件路径安全。 + let tmp = std::env::temp_dir().join(format!("openless-marketplace-{pack_id}.zip")); + std::fs::write(&tmp, &bytes).map_err(|e| format!("write tmp zip: {e}"))?; + let imported_result = coord + .style_packs() + .import_from_zip(&tmp) + .map_err(|e| e.to_string()); + let _ = std::fs::remove_file(&tmp); + let imported = imported_result?; + + // 绑定 origin —— 后续编辑+发布走 derivative / supersede 分支。 + coord + .style_packs() + .set_origin(&imported.id, Some(pack_id), origin_author_login) + .map_err(|e| format!("set origin failed: {e}")) +} + +#[tauri::command] +pub async fn marketplace_upload( + coord: CoordinatorState<'_>, + pack_id: String, +) -> Result { + // 本地 pack id 形态:`builtin.light` / 用户 slug / Uuid。用 local 白名单挡 `..` / `/` / `\`。 + if !is_valid_local_pack_id(&pack_id) { + return Err("invalid pack id".into()); + } + let prefs = coord.prefs().get(); + let base = marketplace_url_from_prefs(&prefs); + let dev_user = marketplace_dev_user(&prefs); + + // 拉本地 pack 拿 origin_pack_id —— 装过的 pack 这里有值, + // backend 据此判同作者就 supersede 原行(新版本),他人就 derivative(独立新 row)。 + let local_pack = coord + .style_packs() + .get(&pack_id) + .map_err(|e| format!("local pack not found: {e}"))?; + let origin_pack_id = local_pack.origin_pack_id.clone(); + + // 先 export 本地 pack → 临时 ZIP + let tmp = std::env::temp_dir().join(format!("openless-marketplace-upload-{pack_id}.zip")); + coord + .style_packs() + .export_to_zip(&pack_id, &tmp) + .map_err(|e| format!("export local pack failed: {e}"))?; + let bytes = std::fs::read(&tmp).map_err(|e| format!("read exported zip: {e}"))?; + let _ = std::fs::remove_file(&tmp); + + let client = reqwest::Client::new(); + let part = reqwest::multipart::Part::bytes(bytes) + .file_name(format!("{pack_id}.zip")) + .mime_str("application/zip") + .map_err(|e| format!("multipart build failed: {e}"))?; + let mut form = reqwest::multipart::Form::new().part("file", part); + if let Some(ref oid) = origin_pack_id { + form = form.text("origin_pack_id", oid.clone()); + } + let resp = client + .post(format!("{base}/packs")) + .header("X-Dev-User", dev_user) + .timeout(std::time::Duration::from_secs(30)) + .multipart(form) + .send() + .await + .map_err(|e| format!("upload request failed: {e}"))?; + let status = resp.status(); + let body = resp + .text() + .await + .unwrap_or_else(|e| format!("read body failed: {e}")) + .clone(); + if !status.is_success() { + return Err(format!("upload HTTP {status}: {body}")); + } + let parsed = serde_json::from_str::(&body) + .map_err(|e| format!("parse upload response failed: {e}"))?; + + // 本地从未绑定 origin(首次上传一个本地原创 pack)→ 把 backend 分配的 pack id 写回本地, + // 让用户在同设备上后续编辑能继续走「同作者 supersede」分支,更新自己原创的包。 + if origin_pack_id.is_none() { + if let Some(remote_id) = parsed.get("id").and_then(|v| v.as_str()) { + let prefs2 = coord.prefs().get(); + let dev_user2 = marketplace_dev_user(&prefs2); + let _ = coord.style_packs().set_origin( + &pack_id, + Some(remote_id.to_string()), + Some(dev_user2), + ); + } + } + + Ok(parsed) +} + +#[tauri::command] +pub async fn marketplace_like( + coord: CoordinatorState<'_>, + pack_id: String, +) -> Result { + if !is_valid_session_id(&pack_id) { + return Err("invalid pack id".into()); + } + let prefs = coord.prefs().get(); + let base = marketplace_url_from_prefs(&prefs); + let dev_user = marketplace_dev_user(&prefs); + let client = reqwest::Client::new(); + let resp = client + .post(format!("{base}/packs/{pack_id}/like")) + .header("X-Dev-User", dev_user) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await + .map_err(|e| format!("like request failed: {e}"))?; + if !resp.status().is_success() { + return Err(format!("like HTTP {}", resp.status())); + } + resp.json::() + .await + .map_err(|e| format!("parse failed: {e}")) +} + +/// 撤回自己发布的 pack(后端软删 state='withdrawn',前端列表不再可见)。 +/// pack_id 来自远端,必须是 UUID-v4。 +#[tauri::command] +pub async fn marketplace_delete( + coord: CoordinatorState<'_>, + pack_id: String, +) -> Result<(), String> { + if !is_valid_session_id(&pack_id) { + return Err("invalid pack id".into()); + } + let prefs = coord.prefs().get(); + let base = marketplace_url_from_prefs(&prefs); + let dev_user = marketplace_dev_user(&prefs); + if dev_user.is_empty() { + return Err("未登录:先在 Settings 填发布者名字".into()); + } + let client = reqwest::Client::new(); + let resp = client + .delete(format!("{base}/packs/{pack_id}")) + .header("X-Dev-User", dev_user) + .timeout(std::time::Duration::from_secs(15)) + .send() + .await + .map_err(|e| format!("delete request failed: {e}"))?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(format!("delete HTTP {status}: {body}")); + } + Ok(()) +} + +/// 拉当前用户赞过的所有 pack id,用于客户端市场页面渲染红心 + 「我赞过的」过滤。 +#[tauri::command] +pub async fn marketplace_my_likes(coord: CoordinatorState<'_>) -> Result, String> { + let prefs = coord.prefs().get(); + let base = marketplace_url_from_prefs(&prefs); + let dev_user = marketplace_dev_user(&prefs); + if dev_user.is_empty() { + return Ok(Vec::new()); // 未登录就空集合,UI 渲染无红心 + } + let client = reqwest::Client::new(); + let resp = client + .get(format!("{base}/me/likes")) + .header("X-Dev-User", dev_user) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await + .map_err(|e| format!("my-likes request failed: {e}"))?; + if !resp.status().is_success() { + return Err(format!("my-likes HTTP {}", resp.status())); + } + resp.json::>() + .await + .map_err(|e| format!("parse my-likes failed: {e}")) +} + #[cfg(test)] mod tests { use super::{ active_asr_is_keyless_for_validation, active_foundry_model_from_prefs, asr_configured_for_provider, asr_transcriptions_url, fetch_provider_models, - is_gemini_base_url, llm_configured_for_provider, local_asr_release_plan_for_provider, - models_url, normalize_foundry_language_hint, parse_gemini_model_ids, - parse_latest_beta_from_atom, parse_model_ids, persist_settings, - release_foundry_runtime_if_inactive, validate_foundry_model_alias, ProviderConfig, - SettingsWriter, + is_gemini_base_url, is_valid_local_pack_id, is_valid_session_id, + llm_configured_for_provider, local_asr_release_plan_for_provider, models_url, + normalize_foundry_language_hint, parse_gemini_model_ids, parse_latest_beta_from_atom, + parse_model_ids, persist_settings, release_foundry_runtime_if_inactive, + validate_foundry_model_alias, ProviderConfig, SettingsWriter, }; use crate::persistence::CredentialsSnapshot; use crate::types::{ @@ -2922,4 +3356,54 @@ mod tests { assert_eq!(models, vec!["m1".to_string(), "m2".to_string()]); server.join().unwrap(); } + + #[test] + fn is_valid_session_id_accepts_canonical_uuid_v4() { + // canonical UUID-v4 字面:8-4-4-4-12,全小写、全大写、混合都接受。 + assert!(is_valid_session_id("550e8400-e29b-41d4-a716-446655440000")); + assert!(is_valid_session_id("550E8400-E29B-41D4-A716-446655440000")); + assert!(is_valid_session_id("Abc12345-6789-abcd-EF01-234567890abc")); + } + + #[test] + fn is_valid_session_id_rejects_path_traversal_and_garbage() { + assert!(!is_valid_session_id("")); + assert!(!is_valid_session_id("../../etc/passwd")); + assert!(!is_valid_session_id("..\\..\\windows\\system32")); + // 长度对但含 `/`:dash 位置错或非 hex 字符都不通过 + assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-44665544/000")); + assert!(!is_valid_session_id("550e8400_e29b_41d4_a716_446655440000")); // 用 _ 代 - + // 非 hex 字符 + assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-44665544000g")); + // 长度不对(35 / 37) + assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-44665544000")); + assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-4466554400000")); + // NUL 字节 + assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-44665544\x00000")); + // 百分号编码与绝对路径 + assert!(!is_valid_session_id("%2e%2e/recordings/x")); + assert!(!is_valid_session_id("/Users/attacker/secret.wav")); + } + + #[test] + fn is_valid_local_pack_id_accepts_realistic_ids() { + assert!(is_valid_local_pack_id("builtin.light")); + assert!(is_valid_local_pack_id("builtin.structured")); + assert!(is_valid_local_pack_id("custom.meeting")); + assert!(is_valid_local_pack_id("550e8400-e29b-41d4-a716-446655440000")); + assert!(is_valid_local_pack_id("my_pack_v2")); + assert!(is_valid_local_pack_id("Pack-2026.05")); + } + + #[test] + fn is_valid_local_pack_id_rejects_path_traversal() { + assert!(!is_valid_local_pack_id("")); + assert!(!is_valid_local_pack_id("../etc/passwd")); + assert!(!is_valid_local_pack_id("..\\windows\\system32")); + assert!(!is_valid_local_pack_id("pack/../../etc")); + assert!(!is_valid_local_pack_id("/abs/path")); + assert!(!is_valid_local_pack_id("with space")); + assert!(!is_valid_local_pack_id("with\x00null")); + assert!(!is_valid_local_pack_id(&"a".repeat(129))); + } } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index b8e064cb..c2c68f3a 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -116,6 +116,11 @@ struct Inner { #[cfg(target_os = "windows")] foundry_local_runtime: Arc, recorder: Mutex>>, + /// 当前 dictation / QA session 的 wav 归档是否真的被写到磁盘上。 + /// 由 Recorder::start 返回值 (archive_active) 写入;history.append 路径读取, + /// 决定 DictationSession.has_audio_recording 字段。比单纯读 prefs.record_audio_for_debug + /// 更准确:用户开了开关但路径无法创建(权限 / 磁盘满)也算 false。 + audio_archive_active: AtomicBool, recording_mute: Mutex, hotkey: Mutex>, hotkey_status: Mutex, @@ -203,6 +208,7 @@ impl Coordinator { state: Mutex::new(SessionState::default()), asr: Mutex::new(None), recorder: Mutex::new(None), + audio_archive_active: AtomicBool::new(false), recording_mute: Mutex::new(SharedRecordingMuteState::new()), hotkey: Mutex::new(None), hotkey_status: Mutex::new(HotkeyStatus::default()), @@ -252,6 +258,7 @@ impl Coordinator { state: Mutex::new(SessionState::default()), asr: Mutex::new(None), recorder: Mutex::new(None), + audio_archive_active: AtomicBool::new(false), recording_mute: Mutex::new(SharedRecordingMuteState::new()), hotkey: Mutex::new(None), hotkey_status: Mutex::new(HotkeyStatus::default()), @@ -2584,8 +2591,15 @@ async fn begin_qa_session(inner: &Arc) -> Result<(), String> { let microphone_device_name = selected_microphone_device_name(inner); stop_microphone_preview_monitor(inner, "QA recorder"); acquire_recording_mute(inner, "qa").await; - match Recorder::start(microphone_device_name, consumer, level_handler) { - Ok((rec, runtime_errors)) => { + // QA 默认不留痕(qa_save_history 默认 false),录音文件归档也跟着不开。 + // 调试 QA 麦克风请用主听写路径。 + match Recorder::start(microphone_device_name, consumer, level_handler, None) { + Ok((rec, runtime_errors, archive_active)) => { + // QA 路径不写 dictation 的 history,但仍把 archive 状态归零,避免 dictation + // 接力时读到上一个 QA session 的过期值。 + inner + .audio_archive_active + .store(archive_active, std::sync::atomic::Ordering::Relaxed); *inner.qa_recorder.lock() = Some(rec); // QA 也跟主听写一样监听 cpal runtime error。设备中途消失 / panic 时 // 不能让 QA 永远卡在 Recording 没反馈。详见 issue #168。 @@ -2853,11 +2867,14 @@ async fn end_qa_session(inner: &Arc) -> Result<(), String> { error_code: Some("qaSession".to_string()), duration_ms: Some(raw.duration_ms), dictionary_entry_count: None, + has_audio_recording: None, }; - if let Err(e) = inner - .history - .append_with_retention(session, inner.prefs.get().history_retention_days) - { + let prefs_snapshot = inner.prefs.get(); + if let Err(e) = inner.history.append_with_retention( + session, + prefs_snapshot.history_retention_days, + prefs_snapshot.history_max_entries, + ) { log::error!("[coord] QA history append failed: {e}"); } } @@ -3583,7 +3600,7 @@ mod tests { #[test] fn focus_restore_failure_uses_specific_error_code_when_insert_fails() { assert_eq!( - dictation_error_code(InsertStatus::Failed, false, false, false), + dictation_error_code(InsertStatus::Failed, false, false, false, false), Some("focusRestoreFailed") ); } @@ -3600,7 +3617,7 @@ mod tests { #[cfg(target_os = "windows")] fn tsf_required_failure_keeps_tsf_error_when_focus_was_ready() { assert_eq!( - dictation_error_code(InsertStatus::Failed, false, true, false), + dictation_error_code(InsertStatus::Failed, false, true, false, false), Some("windowsImeTsfRequired") ); } diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 50f1338b..0b74a106 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -324,6 +324,49 @@ fn finalize_polished_text( } } +fn streaming_insert_eligible( + streaming_insert_enabled: bool, + translation_active: bool, + mode: PolishMode, + raw_uses_llm: bool, + wayland_session: bool, +) -> bool { + streaming_insert_enabled + && !translation_active + && (mode != PolishMode::Raw || raw_uses_llm) + && !wayland_session +} + +fn wayland_done_message(status: InsertStatus, polish_failed: bool) -> Option { + match status { + InsertStatus::Inserted | InsertStatus::PasteSent => None, + InsertStatus::CopiedFallback => Some(if polish_failed { + "Wayland 未启用自动输入,已复制原文到剪贴板,请手动粘贴".to_string() + } else { + "Wayland 未启用自动输入,已复制到剪贴板,请手动粘贴".to_string() + }), + InsertStatus::Failed => Some("Wayland 未启用自动输入,剪贴板写入失败".to_string()), + } +} + +fn default_done_message(status: InsertStatus, polish_failed: bool) -> Option { + if polish_failed { + // polish 失败优先告知用户,即使 insert 成功也要让用户知道这版是原文 + Some("润色失败,已插入原文".to_string()) + } else { + match status { + InsertStatus::Inserted => None, + InsertStatus::PasteSent => Some("已尝试粘贴".to_string()), + InsertStatus::CopiedFallback => Some(if cfg!(target_os = "windows") { + "已复制,请 Ctrl+V".to_string() + } else { + "已复制,请粘贴".to_string() + }), + InsertStatus::Failed => Some("插入失败".to_string()), + } + } +} + pub(super) async fn handle_pressed_edge(inner: &Arc) { let was_held = inner.hotkey_trigger_held.swap(true, Ordering::SeqCst); if !was_held { @@ -785,8 +828,31 @@ pub(super) async fn start_recorder_for_starting( let microphone_device_name = selected_microphone_device_name(inner); stop_microphone_preview_monitor(inner, "dictation recorder"); acquire_recording_mute(inner, "dictation").await; - match Recorder::start(microphone_device_name, consumer, level_handler) { - Ok((rec, runtime_errors)) => { + let audio_archive_path = if inner.prefs.get().record_audio_for_debug { + // 用 coordinator 的 SessionId 作为文件名,跟 history 那条记录 id 对齐(见 + // 下游 polish 收尾时 `history_session_id = current_session_id.to_string()`)。 + // 顺手把超龄 / 超量录音清理一下,避免 debug 开关常开时磁盘膨胀。 + let prefs = inner.prefs.get(); + let _ = crate::persistence::prune_recordings( + prefs.history_retention_days, + prefs.audio_recording_max_entries, + ); + crate::persistence::recording_path_for_session(&session_id.to_string()).ok() + } else { + None + }; + match Recorder::start( + microphone_device_name, + consumer, + level_handler, + audio_archive_path, + ) { + Ok((rec, runtime_errors, archive_active)) => { + // 把 archive 实际创建状态存到 Inner,让 history 写入路径(含 empty-transcript + // 失败分支)读真实情况,而不是 prefs 开关。修 pr_agent "Wrong Flag" 反馈。 + inner + .audio_archive_active + .store(archive_active, std::sync::atomic::Ordering::Relaxed); store_recorder_for_session(inner, session_id, rec); spawn_recorder_error_monitor(inner, runtime_errors); // 不在这里 emit Recording capsule。 @@ -1230,11 +1296,17 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { error_code: Some("emptyTranscript".to_string()), duration_ms: Some(raw.duration_ms), dictionary_entry_count: Some(enabled_phrases(inner).len() as u32), + // empty-transcript(ASR 没识别到任何文字)也保留 wav 标记——这是用户最想 + // 通过原始录音定位"是不是麦克风太小声 / ASR 模型问题"的场景。修 pr_agent + // "Missing Audio" 反馈。 + has_audio_recording: Some(inner.audio_archive_active.load(Ordering::Relaxed)), }; - if let Err(e) = inner - .history - .append_with_retention(session, inner.prefs.get().history_retention_days) - { + let prefs_snapshot = inner.prefs.get(); + if let Err(e) = inner.history.append_with_retention( + session, + prefs_snapshot.history_retention_days, + prefs_snapshot.history_max_entries, + ) { log::error!("[coord] history append failed: {e}"); } emit_capsule( @@ -1338,10 +1410,16 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { }; // 流式插入 opt-in 路径:开关打开 + 非翻译 + 非 Raw 模式 → 进入流式分支。 // 任何不满足都走原一次性 polish_or_passthrough 路径,行为跟历史完全一致。 - let streaming_eligible = - prefs.streaming_insert && !translation_active && (mode != PolishMode::Raw || raw_uses_llm); + let wayland_session = crate::hotkey::is_wayland_session(); + let streaming_eligible = streaming_insert_eligible( + prefs.streaming_insert, + translation_active, + mode, + raw_uses_llm, + wayland_session, + ); log::info!( - "[coord] polish dispatch: translation={translation_active} mode={mode:?} streaming_eligible={streaming_eligible}" + "[coord] polish dispatch: translation={translation_active} mode={mode:?} wayland_session={wayland_session} streaming_eligible={streaming_eligible}" ); let (polished, polish_error, already_streamed) = if translation_active { @@ -1445,6 +1523,24 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { polish_error ); InsertStatus::Inserted + } else if wayland_session { + log::info!( + "[coord] Wayland session detected; skipping synthetic paste and attempting copy-only fallback ({} chars)", + polished.chars().count() + ); + let status = inner.inserter.copy_fallback(&polished); + match status { + InsertStatus::CopiedFallback => { + log::info!("[coord] Wayland copy-only fallback succeeded") + } + InsertStatus::Failed => { + log::error!("[coord] Wayland copy-only fallback failed: clipboard write failed") + } + other => log::warn!( + "[coord] Wayland copy-only fallback returned unexpected status: {other:?}" + ), + } + status } else if focus_ready_for_paste { #[cfg(target_os = "windows")] { @@ -1503,12 +1599,16 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { polish_error.is_some(), focus_ready_for_paste, allow_non_tsf_insertion_fallback, + wayland_session, ) .map(str::to_string); let tsf_required_insert_failed = error_code.as_deref() == Some("windowsImeTsfRequired"); - let history_session_id = Uuid::new_v4().to_string(); + // 与 coordinator 内部 SessionId 对齐:方便 recorder 旁路写盘的 `.wav` + // 跟 history 这条 DictationSession.id 同名,前端凭 id 就能找到对应录音文件。 + let history_session_id = current_session_id.to_string(); let history_created_at = Utc::now().to_rfc3339(); + let prefs_snapshot = inner.prefs.get(); let session = DictationSession { id: history_session_id.clone(), created_at: history_created_at.clone(), @@ -1523,29 +1623,23 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { // 历史详情页的"X 个热词"显示:用本次实际命中次数(每个匹配实例算一次), // 比"启用词条总数"更能反映本段口述命中了多少。u64 → u32 截断对单段听写足够。 dictionary_entry_count: Some(total_hits.min(u32::MAX as u64) as u32), + // 用 begin_session 时 Recorder::start 返回的实际写盘状态,而不是 prefs 开关—— + // 开关打开但路径创建失败时这里是 false,避免前端渲染播放按钮后端 404。 + has_audio_recording: Some(inner.audio_archive_active.load(Ordering::Relaxed)), }; - if let Err(e) = inner - .history - .append_with_retention(session, inner.prefs.get().history_retention_days) - { + if let Err(e) = inner.history.append_with_retention( + session, + prefs_snapshot.history_retention_days, + prefs_snapshot.history_max_entries, + ) { log::error!("[coord] history append failed: {e}"); } let done_message = if tsf_required_insert_failed { Some("TSF 未上屏,已禁止非 TSF 兜底".to_string()) - } else if polish_error.is_some() { - // polish 失败优先告知用户,即使 insert 成功也要让用户知道这版是原文 - Some("润色失败,已插入原文".to_string()) + } else if wayland_session { + wayland_done_message(status, polish_error.is_some()) } else { - match status { - InsertStatus::Inserted => None, - InsertStatus::PasteSent => Some("已尝试粘贴".to_string()), - InsertStatus::CopiedFallback => Some(if cfg!(target_os = "windows") { - "已复制,请 Ctrl+V".to_string() - } else { - "已复制,请粘贴".to_string() - }), - InsertStatus::Failed => Some("插入失败".to_string()), - } + default_done_message(status, polish_error.is_some()) }; emit_capsule( @@ -1572,8 +1666,11 @@ pub(super) fn dictation_error_code( polish_failed: bool, focus_ready_for_paste: bool, allow_non_tsf_insertion_fallback: bool, + wayland_session: bool, ) -> Option<&'static str> { - if !focus_ready_for_paste && status == InsertStatus::Failed { + if wayland_session && status == InsertStatus::Failed { + Some("waylandClipboardWriteFailed") + } else if !focus_ready_for_paste && status == InsertStatus::Failed { Some("focusRestoreFailed") } else if cfg!(target_os = "windows") && focus_ready_for_paste @@ -1628,8 +1725,11 @@ fn append_typed_prefix(target: &mut String, delta: &str, typed_chars: usize) -> #[cfg(test)] mod tests { - use super::{append_typed_prefix, finalize_polished_text}; - use crate::types::{ChineseScriptPreference, CorrectionRule, PolishMode}; + use super::{ + append_typed_prefix, default_done_message, dictation_error_code, finalize_polished_text, + streaming_insert_eligible, wayland_done_message, + }; + use crate::types::{ChineseScriptPreference, CorrectionRule, InsertStatus, PolishMode}; fn correction_rule(pattern: &str, replacement: &str) -> CorrectionRule { CorrectionRule { @@ -1712,4 +1812,62 @@ mod tests { assert_eq!(appended, 1); assert_eq!(typed, "好"); } + + #[test] + fn wayland_disables_streaming_insert_even_when_pref_enabled() { + assert!(!streaming_insert_eligible( + true, + false, + PolishMode::Light, + false, + true + )); + } + + #[test] + fn x11_linux_can_still_use_streaming_insert_when_other_gates_pass() { + assert!(streaming_insert_eligible( + true, + false, + PolishMode::Light, + false, + false + )); + } + + #[test] + fn wayland_done_message_tells_user_manual_paste_is_required() { + assert_eq!( + wayland_done_message(InsertStatus::CopiedFallback, false), + Some("Wayland 未启用自动输入,已复制到剪贴板,请手动粘贴".to_string()) + ); + assert_eq!( + wayland_done_message(InsertStatus::CopiedFallback, true), + Some("Wayland 未启用自动输入,已复制原文到剪贴板,请手动粘贴".to_string()) + ); + assert_eq!( + wayland_done_message(InsertStatus::Failed, false), + Some("Wayland 未启用自动输入,剪贴板写入失败".to_string()) + ); + } + + #[test] + fn default_done_message_keeps_existing_non_wayland_behavior() { + assert_eq!( + default_done_message(InsertStatus::PasteSent, false), + Some("已尝试粘贴".to_string()) + ); + assert_eq!( + default_done_message(InsertStatus::Inserted, true), + Some("润色失败,已插入原文".to_string()) + ); + } + + #[test] + fn wayland_clipboard_failure_uses_specific_error_code() { + assert_eq!( + dictation_error_code(InsertStatus::Failed, false, false, true, true), + Some("waylandClipboardWriteFailed") + ); + } } diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 420c4ba8..94bb9bd4 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -283,6 +283,14 @@ pub fn run() { commands::list_history, commands::delete_history_entry, commands::clear_history, + commands::read_audio_recording, + commands::marketplace_list, + commands::marketplace_detail, + commands::marketplace_install, + commands::marketplace_upload, + commands::marketplace_like, + commands::marketplace_my_likes, + commands::marketplace_delete, commands::list_vocab, commands::add_vocab, commands::remove_vocab, @@ -301,6 +309,7 @@ pub fn run() { commands::inject_hotkey_click_for_dev, commands::repolish, commands::list_style_packs, + commands::create_style_pack_from_template, commands::save_style_pack, commands::preview_style_pack_runtime, commands::set_active_style_pack, diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index f4722a12..5e6e4748 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -139,6 +139,90 @@ pub fn local_models_root() -> Result { Ok(dir) } +/// 录音归档目录:`/recordings/`。 +/// 仅当用户开 `prefs.record_audio_for_debug` 时才会有内容(每次会话一个 `.wav`)。 +/// 同样受 `history_retention_days` 清理(写入新文件时顺手裁旧的)。 +pub fn recordings_root() -> Result { + let dir = data_dir()?.join("recordings"); + ensure_dir(&dir)?; + Ok(dir) +} + +/// 双重 cap 清理 `recordings/*.wav`: +/// - `retention_days > 0` → 把超过 N 天的删掉(沿用 history 的 retention 逻辑)。 +/// - `max_entries == Some(n)` → 按 mtime 倒序保留最新的 n 条(clamp 到 1..=HISTORY_CAP); +/// `None` 时退回 HISTORY_CAP (200) 硬上限,避免无限增长。 +/// 调用方:每次新建一条录音前。失败仅打 warn,避免影响主路径。 +pub fn prune_recordings(retention_days: u32, max_entries: Option) -> Result<()> { + let dir = match data_dir() { + Ok(d) => d.join("recordings"), + Err(_) => return Ok(()), + }; + if !dir.exists() { + return Ok(()); + } + + // 第一步:按天清理。仅扫 .wav,跟第二步保持一致;metadata 读不到的文件按"过期"处理 + // —— fs 损坏 / 未来格式不一致的孤儿文件应当被回收而不是无限累积。 + if retention_days > 0 { + let cutoff = std::time::SystemTime::now() + - std::time::Duration::from_secs(u64::from(retention_days) * 24 * 3600); + for entry in fs::read_dir(&dir).context("read recordings dir")?.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("wav") { + continue; + } + let modified = entry + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .unwrap_or(std::time::UNIX_EPOCH); + if modified < cutoff { + if let Err(err) = fs::remove_file(&path) { + log::warn!("[recordings] prune (days) remove failed for {path:?}: {err}"); + } + } + } + } + + // 第二步:按条数清理。剩下的 wav 按 mtime 倒序,超出 cap 的删掉。 + let cap = max_entries + .map(|n| (n as usize).clamp(1, HISTORY_CAP)) + .unwrap_or(HISTORY_CAP); + let mut entries: Vec<(PathBuf, std::time::SystemTime)> = fs::read_dir(&dir) + .context("read recordings dir")? + .flatten() + .filter_map(|e| { + let path = e.path(); + // 只看 .wav,避免误删未来其他类型的归档文件。 + if path.extension().and_then(|ext| ext.to_str()) != Some("wav") { + return None; + } + let modified = e.metadata().ok()?.modified().ok()?; + Some((path, modified)) + }) + .collect(); + if entries.len() <= cap { + return Ok(()); + } + entries.sort_by(|a, b| b.1.cmp(&a.1)); + for (path, _) in entries.into_iter().skip(cap) { + if let Err(err) = fs::remove_file(&path) { + log::warn!( + "[recordings] prune (count) remove failed for {:?}: {err}", + path + ); + } + } + Ok(()) +} + +/// 单个 session 的录音文件路径。不保证文件已存在(DictationSession.has_audio_recording +/// 决定文件是否被写过)。前端用 `read_audio_recording` IPC 读字节流喂 HTMLAudio。 +pub fn recording_path_for_session(session_id: &str) -> Result { + Ok(recordings_root()?.join(format!("{session_id}.wav"))) +} + /// Foundry Local 下载与缓存根目录。DLL 和模型都不打进安装包,和 Qwen3-ASR /// 一样放在 OpenLess 的 models 目录下,卸载清理用户数据时可以一起删除。 #[cfg(target_os = "windows")] @@ -200,6 +284,45 @@ fn read_or_default Deserialize<'de> + Default>(path: &Path) -> Resul .with_context(|| format!("decode failed: {}", path.display())) } +fn read_preferences(path: &Path) -> Result { + if !path.exists() { + return Ok(UserPreferences::default()); + } + let bytes = fs::read(path).with_context(|| format!("read failed: {}", path.display()))?; + if bytes.is_empty() { + return Ok(UserPreferences::default()); + } + let prefs = serde_json::from_slice::(&bytes) + .with_context(|| format!("decode failed: {}", path.display()))?; + + // issue #440:老版本可能已把旧默认 `streamingInsert:false` 写进 preferences.json。 + // 反序列化会在内存里迁到 true,但还必须把迁移标记落盘,否则每次启动都停留在 + // “旧文件”状态,无法表达用户后续手动关闭后的 durable opt-out。 + let streaming_default_migrated = serde_json::from_slice::(&bytes) + .ok() + .and_then(|value| { + value + .get("streamingInsertDefaultMigrated") + .and_then(|flag| flag.as_bool()) + }) + .unwrap_or(false); + if !streaming_default_migrated { + match serde_json::to_vec_pretty(&prefs) + .context("encode prefs failed") + .and_then(|json| atomic_write(path, &json)) + { + Ok(()) => log::info!("[prefs] migrated streamingInsert default marker"), + Err(err) => log::warn!( + "[prefs] failed to persist streamingInsert migration marker for {}: {}", + path.display(), + err + ), + } + } + + Ok(prefs) +} + // ───────────────────────── credentials vault ───────────────────────── // // 正常读写走系统凭据库;旧 plaintext JSON 只作为迁移来源。为保持多 provider @@ -786,16 +909,19 @@ impl HistoryStore { } pub fn append(&self, session: DictationSession) -> Result<()> { - self.append_with_retention(session, 0) + self.append_with_retention(session, 0, None) } /// `retention_days == 0` 跟旧 append 行为一致(不按时间清理)。 /// `> 0` 时在写入新条目后顺手把超过 N 天的会话裁掉,写入时就完成清理, - /// 不需要后台轮询。最后再受 200 条硬上限约束(HISTORY_CAP)。 + /// 不需要后台轮询。最后再受条数上限约束: + /// - `max_entries == None` → HISTORY_CAP (200) + /// - `max_entries == Some(n)` → clamp 到 5..=HISTORY_CAP,避免用户填 0 / 极大值。 pub fn append_with_retention( &self, session: DictationSession, retention_days: u32, + max_entries: Option, ) -> Result<()> { let _guard = self.lock.lock(); let mut sessions = self.read_locked()?; @@ -810,8 +936,11 @@ impl HistoryStore { .unwrap_or(true) }); } - if sessions.len() > HISTORY_CAP { - sessions.truncate(HISTORY_CAP); + let cap = max_entries + .map(|n| (n as usize).clamp(5, HISTORY_CAP)) + .unwrap_or(HISTORY_CAP); + if sessions.len() > cap { + sessions.truncate(cap); } self.write_locked(&sessions) } @@ -876,7 +1005,7 @@ impl PreferencesStore { ensure_dir(&dir)?; let path = dir.join(PREFERENCES_FILE); let prefs = if path.exists() { - read_or_default::(&path).unwrap_or_else(|e| { + read_preferences(&path).unwrap_or_else(|e| { log::warn!( "[prefs] load {} failed, using defaults: {}", path.display(), @@ -1023,6 +1152,37 @@ impl StylePackStore { .ok_or_else(|| anyhow!("no enabled style pack available")) } + /// 从模板新建一个 imported 风格包("+"按钮路径)。 + /// 跟 ZIP 导入不同:没有 manifest.json、没有 assets,纯空白模板。 + /// 调用方负责 set `prefs.active_style_pack_id` 等高层 wiring(这里只管落盘)。 + pub fn create_from_template(&self, template: StylePack) -> Result { + let mut packs = self.state.lock(); + let base_id = if template.id.trim().is_empty() { + format!("imported-{}", Uuid::new_v4().simple()) + } else { + template.id.clone() + }; + let assigned_id = unique_imported_style_pack_id(&packs, &base_id); + let now = Utc::now().to_rfc3339(); + let mut pack = template; + pack.id = assigned_id; + pack.kind = StylePackKind::Imported; + pack.created_at = Some(now.clone()); + pack.updated_at = Some(now); + pack.active = false; + pack.enabled = true; + packs.push(pack.clone()); + write_style_packs_file(&self.path, &packs)?; + log::info!( + "[style-pack] created from template id={} base_mode={:?} prompt_chars={} examples={}", + pack.id, + pack.base_mode, + pack.prompt.chars().count(), + pack.examples.len() + ); + Ok(pack) + } + pub fn upsert(&self, incoming: StylePack) -> Result { let mut packs = self.state.lock(); let index = packs @@ -1046,6 +1206,27 @@ impl StylePackStore { Ok(updated) } + /// 设置衍生关系;marketplace_install 安装本地包后绑定 upstream id + author。 + /// 单独走这里是为了不让前端通用 save 路径误清这两字段。 + pub fn set_origin( + &self, + id: &str, + origin_pack_id: Option, + origin_author_login: Option, + ) -> Result { + let mut packs = self.state.lock(); + let index = packs + .iter() + .position(|pack| pack.id == id) + .ok_or_else(|| anyhow!("style pack {} not found", id))?; + packs[index].origin_pack_id = normalize_optional_text(origin_pack_id); + packs[index].origin_author_login = normalize_optional_text(origin_author_login); + packs[index].updated_at = Some(Utc::now().to_rfc3339()); + let updated = packs[index].clone(); + write_style_packs_file(&self.path, &packs)?; + Ok(updated) + } + pub fn set_enabled(&self, id: &str, enabled: bool) -> Result { let mut packs = self.state.lock(); let index = packs @@ -1161,6 +1342,8 @@ impl StylePackStore { compatible_app_version: manifest .compatible_app_version .and_then(|value| normalize_optional_text(Some(value))), + origin_pack_id: None, + origin_author_login: None, }; packs.insert(0, pack.clone()); write_style_packs_file(&self.path, &packs)?; @@ -1510,6 +1693,8 @@ fn merge_style_pack_update(existing: StylePack, incoming: StylePack) -> Result r, - Err(e) => { - if e.is_timeout() { - return Err(LLMError::Timeout); - } - return Err(LLMError::Network(e.to_string())); - } - }; + let response = send_with_transient_retry(request).await?; let status = response.status(); let body_text = response @@ -588,15 +580,7 @@ impl OpenAICompatibleLLMProvider { } let request = request.json(&body); - let response = match request.send().await { - Ok(r) => r, - Err(e) => { - if e.is_timeout() { - return Err(LLMError::Timeout); - } - return Err(LLMError::Network(e.to_string())); - } - }; + let response = send_with_transient_retry(request).await?; let status = response.status(); if !status.is_success() { @@ -717,15 +701,7 @@ impl OpenAICompatibleLLMProvider { } let request = request.json(&body); - let response = match request.send().await { - Ok(r) => r, - Err(e) => { - if e.is_timeout() { - return Err(LLMError::Timeout); - } - return Err(LLMError::Network(e.to_string())); - } - }; + let response = send_with_transient_retry(request).await?; let status = response.status(); if !status.is_success() { @@ -1260,6 +1236,54 @@ pub(crate) fn http_client_builder(base_url: &str, timeout_secs: u64) -> reqwest: } } +/// 发请求 + 网络抖动 retry:**只**对 `is_connect()` / `is_request()` 这两类「服务端 +/// 必然没收到」的失败重试一次。`is_timeout()` 故意**不**重试——超时时服务端可能已经 +/// 在处理请求并扣计费(LLM completion 是非幂等动作),重试会导致重复 billing + 重复 +/// completion。HTTP 4xx/5xx 不在这里触发——那些走 response.status() 分支单独处理。 +/// +/// 调用前提:传入的 RequestBuilder body 必须是内存型(json / form),不能是 stream +/// reader——retry 用 `try_clone()` 复制 RequestBuilder,stream body 不支持。 +/// +/// 对流式 SSE 路径 retry 是安全的:connect / request 类失败发生在 TCP 握手 / HTTP +/// 请求写出阶段,response 还没回 → on_delta 必然未被调用 → 不会有「已流式输出的字 +/// 被重复」的问题。 +async fn send_with_transient_retry( + request: reqwest::RequestBuilder, +) -> Result { + const RETRY_DELAY_MS: u64 = 500; + let initial = request + .try_clone() + .expect("memory-backed body (json/form) must be clonable for retry"); + match initial.send().await { + Ok(r) => Ok(r), + Err(e) if e.is_connect() || e.is_request() => { + log::warn!( + "[llm] send transient failure, retry in {}ms: {}", + RETRY_DELAY_MS, + e + ); + tokio::time::sleep(Duration::from_millis(RETRY_DELAY_MS)).await; + match request.send().await { + Ok(r) => Ok(r), + Err(e2) => { + if e2.is_timeout() { + Err(LLMError::Timeout) + } else { + Err(LLMError::Network(e2.to_string())) + } + } + } + } + Err(e) => { + if e.is_timeout() { + Err(LLMError::Timeout) + } else { + Err(LLMError::Network(e.to_string())) + } + } + } +} + fn should_bypass_proxy_for_base_url(base_url: &str) -> bool { let Ok(url) = reqwest::Url::parse(base_url.trim()) else { return false; @@ -1735,45 +1759,74 @@ pub(crate) fn compose_qa_system_prompt( system_prompt } -fn compose_system_prompt(style_system_prompt: &str, hotwords: &[String]) -> String { - let base = style_system_prompt.trim_end().to_string(); +/// 构建「热词 + 错别字纠错」模块文本:agent-style 措辞,把模型当成接到一段 ASR 转写 +/// 的写作助手,明确告诉它「输入可能有错别字,按这个列表 + 上下文修正」。 +/// +/// 内置 default prompt 里的 `{{HOTWORDS}}` 占位符被这段文本替换;用户自定义 prompt +/// 没占位符时 compose_system_prompt 兜底拼到末尾。 +/// +/// 这段文本 100% 对齐 compose_hotword_block_preview,让 Style Pack 设置页的预览跟 +/// 实际发给 LLM 的 prompt 一致。 +fn build_hotword_block(hotwords: &[String]) -> String { let cleaned: Vec = hotwords .iter() .map(|h| h.trim().to_string()) .filter(|h| !h.is_empty()) .collect(); + if cleaned.is_empty() { - return base; + return "# 热词与纠错(系统内置)\n\ + 你接到的转写来自 ASR,可能含错别字 / 同音误识别 / 形近词。\ + 按上下文自动纠回正确字面:常见模式如「跟目录 / 根木鹿」→「根目录」、\ + 「代码厂」→「代码仓」、「编一编」→「编译」、英文短词同音(如 VIP / ZIP)按上下文判断、\ + 带次版本号产品名(GPT-5.6 不省略成 GPT-5)。\ + 人名 / 品牌名 / 含义会变化的词原样保留,不强行改字。" + .to_string(); } + let bullets = cleaned .iter() .map(|h| format!("- {}", h)) .collect::>() .join("\n"); format!( - "{}\n\n热词(用户希望以下写法在输出中保持准确;当转写中出现这些词的同音或形近误识别时,优先按上述写法输出,不做无关词的机械替换):\n{}", - base, bullets + "# 热词与纠错(系统内置)\n\ + 你接到的转写来自 ASR,可能含错别字。用户希望以下写法在输出中保持准确;\ + 当转写中出现这些词的同音或形近误识别时,优先按上述写法输出,不做无关词的机械替换:\n\ + {bullets}\n\ + \n\ + 上面热词的纠偏指令优先于通用规则 2 的「原样保留」——当转写词是热词的同音 / 形近误识别\ + (例:转写出「VIP」而热词里有「ZIP」),就按热词写法输出,不要因为它看起来像英文专有名词\ + 或中英混输而保留误识别结果。\n\ + \n\ + 转写中其它 ASR 错别字按上下文自动纠回正确字面:常见模式如「跟目录 / 根木鹿」→「根目录」、\ + 英文短词同音(如 VIP / ZIP)按上下文判断、带次版本号产品名(GPT-5.6 不省略成 GPT-5)。\ + 人名 / 品牌名 / 含义会变化的词原样保留。", + bullets = bullets ) } -fn compose_hotword_block_preview(hotwords: &[String]) -> String { - let cleaned: Vec = hotwords - .iter() - .map(|h| h.trim().to_string()) - .filter(|h| !h.is_empty()) - .collect(); - if cleaned.is_empty() { - return String::new(); +/// 系统提示词组装:先把内置 default prompt 的 `{{HOTWORDS}}` 占位符替换为实际热词块; +/// 用户自定义 prompt 没占位符时 fallback 行为: +/// - hotwords 非空 → 末尾追加热词块(兼容历史 prompt 仍能拿到热词) +/// - hotwords 空 → 不附加任何东西(用户决定自己 prompt 的内容,不强行注入) +fn compose_system_prompt(style_system_prompt: &str, hotwords: &[String]) -> String { + let base = style_system_prompt.trim_end(); + if base.contains(crate::types::HOTWORDS_PLACEHOLDER) { + let block = build_hotword_block(hotwords); + return base.replace(crate::types::HOTWORDS_PLACEHOLDER, &block); } - let bullets = cleaned - .iter() - .map(|h| format!("- {}", h)) - .collect::>() - .join("\n"); - format!( - "热词(用户希望以下写法在输出中保持准确;当转写中出现这些词的同音或形近误识别时,优先按上述写法输出,不做无关词的机械替换):\n{}", - bullets - ) + let has_hotwords = hotwords.iter().any(|h| !h.trim().is_empty()); + if !has_hotwords { + return base.to_string(); + } + format!("{}\n\n{}", base, build_hotword_block(hotwords)) +} + +fn compose_hotword_block_preview(hotwords: &[String]) -> String { + // Style Pack 设置页的预览 100% 跟 system prompt 用同一段文本,避免「设置里看到一段、 + // 实际发给 LLM 是另一段」的不一致。空热词时返回纯错别字纠错指南。 + build_hotword_block(hotwords) } fn extract_assistant_content(body: &str) -> Result { diff --git a/openless-all/app/src-tauri/src/recorder.rs b/openless-all/app/src-tauri/src/recorder.rs index a599fa3f..a943b783 100644 --- a/openless-all/app/src-tauri/src/recorder.rs +++ b/openless-all/app/src-tauri/src/recorder.rs @@ -10,6 +10,7 @@ //! - cpal `Stream` 是 `!Send`,所以独立线程持有它。 //! - 主线程通过 `AtomicBool` 通知"该停了",并 `join` 线程;线程内 `drop` Stream。 +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::mpsc::{channel, Receiver, Sender}; use std::sync::Arc; @@ -60,13 +61,20 @@ pub struct Recorder { impl Recorder { /// 启动采集。`consumer` 收到 16 kHz/Mono/Int16-LE 的 PCM; /// `level_handler` 收到 0..1 的 RMS 电平。 + /// `audio_archive_path` 不为 None 时,同样的 16 kHz/Mono/Int16-LE 旁路写入 WAV 文件, + /// 用于 debug 麦克风灵敏度 / ASR 误识别。Drop 时自动回填 RIFF / data 长度。 + /// + /// 返回值第三个 `bool` = "archive 实际成功创建":caller 写 history 时应当用这个值 + /// 决定 `has_audio_recording`,而不是 prefs 开关。开关打开但写盘失败(路径不存在 / + /// 权限不足 / 磁盘满)时仍返回 false,避免前端渲染播放按钮后端却 404。 /// /// 实际的 cpal Stream 在独立线程里构造、播放、最终析构——因为它 `!Send`。 pub fn start( microphone_device_name: Option, consumer: Arc, level_handler: Arc, - ) -> Result<(Self, Receiver), RecorderError> { + audio_archive_path: Option, + ) -> Result<(Self, Receiver, bool), RecorderError> { // 启动信号:子线程构造 Stream 完成后通过 startup_tx 报告结果。 let (startup_tx, startup_rx) = channel::>(); // 运行期错误:Stream 已成功启动后,cpal 通过 err_cb 异步上报。 @@ -74,6 +82,17 @@ impl Recorder { let stop_flag = Arc::new(AtomicBool::new(false)); let stop_for_thread = Arc::clone(&stop_flag); + // 同步路径上尝试创建 WavArchiver——成功 / 失败都立刻知道,传给 caller 决定 + // 是否在 history 标 has_audio_recording。失败仅 log::warn 不抛错,主路径继续。 + let archiver = audio_archive_path.and_then(|path| match WavArchiver::create(&path) { + Ok(arch) => Some(Arc::new(Mutex::new(arch))), + Err(err) => { + log::warn!("[recorder] wav archive create failed at {path:?}: {err}"); + None + } + }); + let archive_active = archiver.is_some(); + let join_handle = thread::Builder::new() .name("openless-recorder".into()) .spawn(move || { @@ -81,6 +100,7 @@ impl Recorder { microphone_device_name, consumer, level_handler, + archiver, stop_for_thread, startup_tx, runtime_error_tx, @@ -101,6 +121,7 @@ impl Recorder { join_handle: Mutex::new(Some(join_handle)), }, runtime_error_rx, + archive_active, )) } @@ -144,10 +165,13 @@ pub fn list_input_devices() -> Result, RecorderError> { } /// 音频线程主体:构造 Stream → 通过 startup_tx 报告 → 循环到 stop_flag。 +/// `archiver` 由 caller 在同步路径上已经尝试创建好(成功 → Some / 失败 → None), +/// 这里只负责把它穿透到 build_input_stream 给 cpal callback 用。 fn run_audio_thread( microphone_device_name: Option, consumer: Arc, level_handler: Arc, + archiver: Option>>, stop_flag: Arc, startup_tx: Sender>, runtime_error_tx: Sender, @@ -156,6 +180,7 @@ fn run_audio_thread( microphone_device_name, consumer, level_handler, + archiver, runtime_error_tx.clone(), ) { Ok(s) => s, @@ -275,6 +300,7 @@ fn build_input_stream( microphone_device_name: Option, consumer: Arc, level_handler: Arc, + archiver: Option>>, runtime_error_tx: Sender, ) -> Result<(cpal::Stream, Arc), RecorderError> { let host = cpal::default_host(); @@ -304,6 +330,7 @@ fn build_input_stream( sample_format, consumer, level_handler, + archiver, Arc::clone(&state), input_sr, channels, @@ -368,6 +395,7 @@ fn build_stream_for_format( sample_format: SampleFormat, consumer: Arc, level_handler: Arc, + archiver: Option>>, state: Arc, input_sr: u32, channels: usize, @@ -377,6 +405,7 @@ fn build_stream_for_format( ($t:ty, $to_f32:expr) => {{ let consumer = Arc::clone(&consumer); let level_handler = Arc::clone(&level_handler); + let archiver = archiver.clone(); let state = Arc::clone(&state); let runtime_error_tx = runtime_error_tx.clone(); let err_cb = move |err| { @@ -398,6 +427,7 @@ fn build_stream_for_format( input_sr, consumer.as_ref(), level_handler.as_ref(), + archiver.as_deref(), &state, ); }, @@ -461,6 +491,7 @@ fn process_callback( input_sr: u32, consumer: &dyn AudioConsumer, level_handler: &(dyn Fn(f32) + Send + Sync), + archiver: Option<&Mutex>, state: &StreamState, ) { if interleaved.is_empty() || channels == 0 { @@ -479,6 +510,9 @@ fn process_callback( let level = (output_rms * LEVEL_RMS_GAIN).clamp(0.0, 1.0); consumer.consume_pcm_chunk(&pcm_bytes); + if let Some(arch) = archiver { + arch.lock().append(&pcm_bytes); + } level_handler(level); // 更新最后一次成功调用的时间戳(用于 liveness 检测) @@ -620,6 +654,69 @@ fn update_peak(slot: &AtomicUsize, current: f32) { } } +/// 16 kHz / mono / 16-bit PCM WAV 的简易追加写入器。 +/// 构造时写一个 data_size=0 的 header 占位,每次 append 把 i16 PCM bytes 追加到文件, +/// Drop 时 seek 回 0 把 RIFF / data 长度字段回填——避免依赖外部 finalize 调用点。 +struct WavArchiver { + file: std::fs::File, + bytes_written: u32, +} + +impl WavArchiver { + fn create(path: &Path) -> std::io::Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut file = std::fs::File::create(path)?; + use std::io::Write; + file.write_all(&build_wav_header(0))?; + Ok(Self { + file, + bytes_written: 0, + }) + } + + fn append(&mut self, pcm_bytes: &[u8]) { + use std::io::Write; + if self.file.write_all(pcm_bytes).is_ok() { + self.bytes_written = self + .bytes_written + .saturating_add(pcm_bytes.len().min(u32::MAX as usize) as u32); + } + } +} + +impl Drop for WavArchiver { + fn drop(&mut self) { + use std::io::{Seek, SeekFrom, Write}; + let header = build_wav_header(self.bytes_written); + if self.file.seek(SeekFrom::Start(0)).is_ok() { + let _ = self.file.write_all(&header); + let _ = self.file.sync_all(); + } + } +} + +fn build_wav_header(data_size: u32) -> [u8; 44] { + // RIFF/WAVE PCM 标准 44-byte header,16 kHz / mono / 16-bit 写死。 + let total_size = data_size.saturating_add(36); + let mut h = [0u8; 44]; + h[0..4].copy_from_slice(b"RIFF"); + h[4..8].copy_from_slice(&total_size.to_le_bytes()); + h[8..12].copy_from_slice(b"WAVE"); + h[12..16].copy_from_slice(b"fmt "); + h[16..20].copy_from_slice(&16u32.to_le_bytes()); // fmt chunk size + h[20..22].copy_from_slice(&1u16.to_le_bytes()); // PCM + h[22..24].copy_from_slice(&1u16.to_le_bytes()); // mono + h[24..28].copy_from_slice(&(TARGET_SAMPLE_RATE).to_le_bytes()); + h[28..32].copy_from_slice(&(TARGET_SAMPLE_RATE * 2).to_le_bytes()); // byte rate (sr * block_align) + h[32..34].copy_from_slice(&2u16.to_le_bytes()); // block align + h[34..36].copy_from_slice(&16u16.to_le_bytes()); // bits per sample + h[36..40].copy_from_slice(b"data"); + h[40..44].copy_from_slice(&data_size.to_le_bytes()); + h +} + #[cfg(test)] mod tests { use super::*; @@ -703,6 +800,7 @@ mod tests { 8_000, &consumer, &move |level| levels_for_handler.lock().unwrap().push(level), + None, &state, ); @@ -726,6 +824,7 @@ mod tests { TARGET_SAMPLE_RATE, &consumer, &move |level| levels_for_handler.lock().unwrap().push(level), + None, &state, ); @@ -749,6 +848,7 @@ mod tests { TARGET_SAMPLE_RATE, &consumer, &move |level| levels_for_handler.lock().unwrap().push(level), + None, &state, ); @@ -773,6 +873,7 @@ mod tests { TARGET_SAMPLE_RATE, &consumer, &move |level| levels_for_handler.lock().unwrap().push(level), + None, &state, ); process_callback( @@ -781,6 +882,7 @@ mod tests { TARGET_SAMPLE_RATE, &consumer, &move |level| levels.lock().unwrap().push(level), + None, &state, ); diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index a7525b85..5ea66e87 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -98,6 +98,11 @@ pub struct DictationSession { pub error_code: Option, pub duration_ms: Option, pub dictionary_entry_count: Option, + /// 当 `prefs.record_audio_for_debug` 开启时,本次会话的原始麦克风音频被写到 + /// `recordings/.wav`。前端凭这个字段决定是否在 History 渲染播放按钮。 + /// `None` / `Some(false)` 都按"无录音"处理;旧 JSON 不带这字段也兼容。 + #[serde(default)] + pub has_audio_recording: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -271,6 +276,11 @@ pub struct StylePack { pub active: bool, pub recommended_model: Option, pub compatible_app_version: Option, + /// 衍生关系:从 marketplace 安装时记录 upstream pack id; + /// 后续编辑 + 发布时客户端把这两个字段带到 backend,让 backend 判 supersede vs derivative。 + /// 全新本地创建的 pack 这两个字段为 None。 + pub origin_pack_id: Option, + pub origin_author_login: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] @@ -319,6 +329,8 @@ impl Default for StylePack { active: false, recommended_model: None, compatible_app_version: None, + origin_pack_id: None, + origin_author_login: None, } } } @@ -365,6 +377,8 @@ pub fn builtin_style_pack_for_mode(mode: PolishMode) -> StylePack { active: false, recommended_model: None, compatible_app_version: Some(env!("CARGO_PKG_VERSION").into()), + origin_pack_id: None, + origin_author_login: None, }, PolishMode::Light => StylePack { id: BUILTIN_STYLE_PACK_LIGHT_ID.into(), @@ -388,6 +402,8 @@ pub fn builtin_style_pack_for_mode(mode: PolishMode) -> StylePack { active: false, recommended_model: None, compatible_app_version: Some(env!("CARGO_PKG_VERSION").into()), + origin_pack_id: None, + origin_author_login: None, }, PolishMode::Structured => StylePack { id: BUILTIN_STYLE_PACK_STRUCTURED_ID.into(), @@ -411,6 +427,8 @@ pub fn builtin_style_pack_for_mode(mode: PolishMode) -> StylePack { active: false, recommended_model: None, compatible_app_version: Some(env!("CARGO_PKG_VERSION").into()), + origin_pack_id: None, + origin_author_login: None, }, PolishMode::Formal => StylePack { id: BUILTIN_STYLE_PACK_FORMAL_ID.into(), @@ -434,6 +452,8 @@ pub fn builtin_style_pack_for_mode(mode: PolishMode) -> StylePack { active: false, recommended_model: None, compatible_app_version: Some(env!("CARGO_PKG_VERSION").into()), + origin_pack_id: None, + origin_author_login: None, }, } } @@ -587,15 +607,49 @@ pub struct UserPreferences { /// - 仅 OpenAI-compatible provider 实装(v1);Gemini / Codex provider 走原一次性 /// 插入路径 /// - /// 默认 false 与历史行为一致。 - #[serde(default)] + /// 默认 true(自 1.3.2-3 起)—— 流式落字感知延迟低,所有 fallback case 都已经接好, + /// 让开箱即用就能体验。CJK IME / Codex / Gemini provider 自动回落到一次性路径, + /// 用户无感。详见上面「限制」段。 + #[serde(default = "default_true")] pub streaming_insert: bool, + /// issue #440 的一次性迁移标记。老版本会把默认 `streamingInsert:false` + /// 写进 preferences.json,升级后仅看 bool 无法区分「老默认」和「用户手动关」。 + /// 缺少此标记的旧文件统一迁到 true;迁移后用户再关会带着标记保存,后续保留 false。 + #[serde(default)] + pub streaming_insert_default_migrated: bool, /// 流式输入成功后是否把最终润色文本写回剪贴板。一次性路径天然走剪贴板,所以 /// Cmd+V 可以重复粘贴;流式路径直接合成键盘事件、不动剪贴板,会让用户失去这层 /// 兜底。开启后流式成功收尾时把 final text 写到系统剪贴板,跟一次性行为对齐。 /// 默认 true(更接近用户习惯)。 #[serde(default = "default_true")] pub streaming_insert_save_clipboard: bool, + /// 主窗口启动 + 后台每 60 分钟自动检查云端新版本。默认 true。 + /// 用户在 Settings → 关于 里可关。关闭后仅手动「检查更新」按钮可用。 + #[serde(default = "default_true")] + pub auto_update_check: bool, + /// 历史记录上限(条数)。`None` = 使用代码内 200 条硬上限; + /// `Some(n)` 表示用户在 Settings 自定义了上限(5..=200 之间)。 + #[serde(default)] + pub history_max_entries: Option, + /// 是否为每次会话保留原始麦克风音频文件(wav)到 `recordings/` 目录, + /// 用于排查 ASR 误识别 / 麦克风灵敏度问题。默认 false。开启会占磁盘空间, + /// 受 `history_retention_days` 同样的清理策略约束。 + #[serde(default)] + pub record_audio_for_debug: bool, + /// `recordings/` 里保留的最近 wav 文件数(按 mtime 倒序保留最新的)。 + /// `None` = 跟随 `HISTORY_CAP` (200);`Some(n)` 时 clamp 到 1..=200。 + /// 调用点:每次开新会话前裁旧。让用户在「文本历史保留 200 条但 wav 只留最近 5 条」 + /// 这种「文本档案多 + 录音不占盘」组合下精确控制。 + #[serde(default)] + pub audio_recording_max_entries: Option, + /// Style Pack Marketplace HTTP 基地址。空 = 本地开发默认 http://127.0.0.1:8090; + /// 用户在 Settings 里填生产 URL (如 https://api.openless-marketplace.com)。 + #[serde(default)] + pub marketplace_base_url: String, + /// Marketplace dev-mode 模拟登录用户名(GitHub login 风格)。生产换 OAuth token 后此字段废弃。 + /// 上传 / 点赞需要带这个 header;空时上传被后端 401。 + #[serde(default)] + pub marketplace_dev_login: String, } fn default_local_asr_model() -> String { @@ -697,10 +751,24 @@ struct UserPreferencesWire { polish_context_window_minutes: u32, #[serde(default)] start_minimized: bool, - #[serde(default)] + #[serde(default = "default_true")] streaming_insert: bool, + #[serde(default)] + streaming_insert_default_migrated: bool, #[serde(default = "default_true")] streaming_insert_save_clipboard: bool, + #[serde(default = "default_true")] + auto_update_check: bool, + #[serde(default)] + history_max_entries: Option, + #[serde(default)] + record_audio_for_debug: bool, + #[serde(default)] + audio_recording_max_entries: Option, + #[serde(default)] + marketplace_base_url: String, + #[serde(default)] + marketplace_dev_login: String, } impl Default for UserPreferencesWire { @@ -746,7 +814,14 @@ impl Default for UserPreferencesWire { polish_context_window_minutes: prefs.polish_context_window_minutes, start_minimized: prefs.start_minimized, streaming_insert: prefs.streaming_insert, + streaming_insert_default_migrated: prefs.streaming_insert_default_migrated, streaming_insert_save_clipboard: prefs.streaming_insert_save_clipboard, + auto_update_check: prefs.auto_update_check, + history_max_entries: prefs.history_max_entries, + record_audio_for_debug: prefs.record_audio_for_debug, + audio_recording_max_entries: prefs.audio_recording_max_entries, + marketplace_base_url: prefs.marketplace_base_url, + marketplace_dev_login: prefs.marketplace_dev_login, } } } @@ -762,6 +837,13 @@ impl<'de> Deserialize<'de> for UserPreferences { None => default_dictation_hotkey_from_legacy(&wire.hotkey, &wire.custom_combo_hotkey) .map_err(serde::de::Error::custom)?, }; + let streaming_insert_default_migrated = wire.streaming_insert_default_migrated; + let streaming_insert = if streaming_insert_default_migrated { + wire.streaming_insert + } else { + true + }; + Ok(Self { hotkey: wire.hotkey, dictation_hotkey, @@ -813,8 +895,15 @@ impl<'de> Deserialize<'de> for UserPreferences { history_retention_days: wire.history_retention_days, polish_context_window_minutes: wire.polish_context_window_minutes, start_minimized: wire.start_minimized, - streaming_insert: wire.streaming_insert, + streaming_insert, + streaming_insert_default_migrated: true, streaming_insert_save_clipboard: wire.streaming_insert_save_clipboard, + auto_update_check: wire.auto_update_check, + history_max_entries: wire.history_max_entries, + record_audio_for_debug: wire.record_audio_for_debug, + audio_recording_max_entries: wire.audio_recording_max_entries, + marketplace_base_url: wire.marketplace_base_url, + marketplace_dev_login: wire.marketplace_dev_login, }) } } @@ -893,13 +982,24 @@ const ROLE_BLOCK: &str = "# 角色\n\ const COMMON_RULES: &str = "# 通用规则\n\ 1) \u{4E0D}确定 / 转写明显不完整 / 断句在半截 \u{2192} 保留原话,\u{4E0D}要替用户补全或猜测。\n\ - 2) 中英混输、专有名词、产品名、代码 / 命令 / 路径 / URL、数字与单位、emoji \u{2192} 原样保留。\n\ + 2) 中英混输、专有名词、产品名、代码 / 命令 / 路径 / URL、数字与单位、emoji \u{2192} 原样保留。\ + 带次版本号的产品名(如 GPT-5.6、Claude 4.7、iOS 26.1、Python 3.13、Tauri 2.10)也算\u{201C}数字与单位\u{201D}的一部分,\ + 完整保留小数 / 次版本号,\u{4E0D}省略成主版本(GPT-5.6 \u{4E0D}写成 GPT-5、Claude 4.7 \u{4E0D}写成 Claude 4)。\ + (例外:当转写词是 # 热词列表中某个词的同音 / 形近误识别时,按热词列表里的正确写法输出,这一条比\u{201C}原样保留\u{201D}优先。)\n\ 3) \u{4E0D}引入用户没说过的事实;中途改口以最终版本为准。在保留原意和语气的前提下,按用户的整体意图把零碎口语组织成协调、自然的书面表达。\n\ 4) 如果原始转写本身是在\u{201C}询问 / 要求别人做某事\u{201D},只整理为清楚的问题或请求,\u{4E0D}代替对方回答。\n\ - 5) 自动纠错:明显的 ASR 同音 / 形近错字按上下文纠回正确字面,常见模式包括\ - \u{201C}跟目录 / 根木鹿\u{201D}\u{2192}\u{201C}根目录\u{201D}、\u{201C}代码厂\u{201D}\u{2192}\u{201C}代码仓\u{201D}、\ - \u{201C}编一编\u{201D}\u{2192}\u{201C}编译\u{201D}、\u{201C}的 / 得 / 地\u{201D}用法、\u{201C}做 / 作\u{201D} 等常见错别字。\ - 专有名词(见 # 热词)、人名、品牌名、不在常见中文词典里的词原样保留,\u{4E0D}强行改字;改了之后含义会发生变化的不改。"; + 5) 自动纠错(ASR 主动纠错,按置信度分级处理):\n\ + \u{2003}\u{2003}\u{2022} 高置信度:错误明显、正确写法唯一 \u{2192} 直接替换,\u{4E0D}保留原词、\u{4E0D}加说明。\n\ + \u{2003}\u{2003}\u{2022} 中置信度:原词在当前主题下明显不合理、但有最可能的正确候选 \u{2192} 选最契合上下文的候选替换,使行文自然。\n\ + \u{2003}\u{2003}\u{2022} 低置信度:无法判断正确词 \u{2192} 保留原词,\u{4E0D}强行编造不存在的字段、链接、路径或步骤。\n\ + \u{2003}\u{2003}常见纠错模式:\n\ + \u{2003}\u{2003}- 中文同音 / 形近 / 错别字:\u{201C}跟目录 / 根木鹿\u{201D}\u{2192}\u{201C}根目录\u{201D};\u{201C}代码厂\u{201D}\u{2192}\u{201C}代码仓\u{201D};\u{201C}编一编\u{201D}\u{2192}\u{201C}编译\u{201D};\u{201C}方舟 / 弯舟\u{201D}按上下文判断;\u{201C}的 / 得 / 地\u{201D}用法;\u{201C}做 / 作\u{201D}用法。\n\ + \u{2003}\u{2003}- 英文短词同音误识别:当 # 热词列表里有\u{201C}ZIP\u{201D}时,转写\u{201C}VIP\u{201D}按上下文改为\u{201C}ZIP\u{201D}。\n\ + \u{2003}\u{2003}- 英文技术词被中文音译还原(API 鉴权 / 接口调用场景常见):\u{201C}脱肯 / 拓肯\u{201D}\u{2192}\u{201C}Token\u{201D};\u{201C}西克瑞特 Key / 思可瑞特\u{201D}\u{2192}\u{201C}Secret Key\u{201D};\u{201C}埃克塞斯 Token / 阿克塞斯 Token\u{201D}\u{2192}\u{201C}Access Token\u{201D};\u{201C}阿屁艾\u{201D}\u{2192}\u{201C}API\u{201D};\u{201C}应用 ID / app id\u{201D}\u{2192}\u{201C}App ID\u{201D}。\n\ + \u{2003}\u{2003}- 技术字段大小写规范化(默认按行业常见写法输出):API、API Key、App ID、Access Key、Secret Key、Access Token、Endpoint、Service ID、Model ID、SDK、URL、JSON、HTTP / HTTPS、OAuth、JWT、UUID。\n\ + \u{2003}\u{2003}- 大小写敏感场景(代码变量名、Bash 命令、文件路径、环境变量、URL 路径段)原样保留\u{4E0D}规范化。\n\ + \u{2003}\u{2003}人名、品牌名、不在常见中文词典里的词原样保留,\u{4E0D}强行改字;改了之后含义会发生变化的\u{4E0D}改。\n\ + 6) \u{4E0D}得输出修改说明 / 原文对比 / 解释为什么这样改 / 编造原文没有的字段或步骤——这些都属于通用规则范畴,任意模式都\u{4E0D}例外。"; const OUTPUT_BLOCK: &str = "# 输出\n\ 直接输出最终文本正文。需要结构化时直接从标题 / 段落 / 编号开始。\n\ @@ -945,6 +1045,9 @@ pub fn default_style_system_prompt_for_mode(mode: PolishMode) -> String { 把口述整理为脉络清晰、可直接复制走的结构化文本:保留用户的口语引子(润色后作为首行过渡),\ 主动按语义把扁平事项归类成 2\u{2013}4 个主题,用双层格式呈现,尾巴查询用自然收尾句。\n\ \n\ + **多条独立条目场景例外**:当输入是「多条互相独立的新闻 / 公司动态 / 产品发布 / 行业进展」拼成的播报式内容(典型如 AI 日报、行业资讯整理、多家公司发布、多个独立事件回顾),\ + 每条独立成一个主题,可以超过 4 个,\u{4E0D}强行合并到 2\u{2013}4 类。判断信号:条目之间没有共享主体、彼此互不相关、用户用\u{201C}下面是几条新闻\u{201D}\u{201C}今天的资讯\u{201D}\u{201C}最新进展\u{201D}等播报式引子。\n\ + \n\ **默认行为:双层 list。判断事项的标准**:\ 以下任意一种都算一个事项 \u{2192} \u{4E0D}\u{4F9D}\u{8D56}\u{7528}\u{6237}\u{662F}\u{5426}\u{660E}\u{8BF4}\u{201C}\u{7B2C}\u{4E00}\u{201D}\u{201C}\u{7B2C}\u{4E8C}\u{201D}\u{201C}\u{53E6}\u{5916}\u{201D}\u{7B49}\u{8FDE}\u{63A5}\u{8BCD}\u{3002}\n\ \u{2003}\u{2003}1) 可独立成句的陈述(\u{4E3B}+\u{8C13}+\u{5BBE},如\u{201C}\u{300A}\u{67D0}\u{4E1C}\u{897F}\u{300B}\u{8FD8}\u{662F}\u{767D}\u{8272}\u{201D})\n\ @@ -968,7 +1071,10 @@ pub fn default_style_system_prompt_for_mode(mode: PolishMode) -> String { 都必须按语义重新归类成下面定义的双层格式。\u{200D}\u{200D}照抄原结构 = 失败。\n\ \n\ 双层格式(主清单标准写法):\n\ - - 第一层(主题):行首用 \"1.\" \"2.\" \"3.\" \u{2026},每个主题一行短标题(4\u{2013}8 字最佳);\n\ + - 第一层(主题):行首用 \"1.\" \"2.\" \"3.\" \u{2026},每个主题一行短标题(4\u{2013}8 字最佳);\ + 主题标题应包含事项中的关键实体名(人名 / 公司名 / 产品名 / 平台名),\ + 例如\u{300C}OpenAI 模型动态\u{300D}\u{300C}苹果与欧盟监管争议\u{300D},而非纯抽象类别如\u{300C}模型进展\u{300D}\u{300C}监管争议\u{300D};\ + 只有当某主题包含多个不同实体且无法压缩时,才退回到抽象命名。\n\ - 第二层(子项):另起一行,行首用 \"(a)\" \"(b)\" \"(c)\" \u{2026},每条一句完整陈述。\n\ 顶层\u{4E0D}使用半括号写法(如 \"1)\" \"2)\");不在子项内再嵌第三层。\n\ \n\ @@ -1065,12 +1171,27 @@ pub fn default_style_system_prompt_for_mode(mode: PolishMode) -> String { \u{200B}(注意:\u{4E0D}写\u{201C}\u{6211}\u{4EEC}\u{770B}\u{4E86}\u{4E00}\u{4E0B}\u{201D}\u{201C}\u{7ECF}\u{8FC7}\u{8BC4}\u{4F30}\u{201D}\u{4E4B}\u{7C7B}\u{4EE3}\u{5165}\u{8BED})", }; + // 热词与纠错模块以 `{{HOTWORDS}}` 占位符在 ROLE_BLOCK 之后预留位置——polish.rs + // 的 compose_system_prompt 拿到 prompt 后查找此占位符并替换为运行时构造的实际热词 + // + 错别字纠正块。把它放在「人格之后、任务之前」让模型在确立角色后立刻收到这个 + // 高优先级指令;与传统「拼在末尾」相比,对中段注意力衰减更友好。 + // + // 用户在 Style Pack 编辑器自定义 prompt 时可以保留 / 移动 / 删除 `{{HOTWORDS}}`: + // 含 → 替换位置;不含 → fallback 拼在末尾(兼容历史 prompt)。 format!( - "{}\n\n{}\n\n{}\n\n{}", - ROLE_BLOCK, task_and_example, COMMON_RULES, OUTPUT_BLOCK + "{}\n\n{}\n\n{}\n\n{}\n\n{}", + ROLE_BLOCK, + HOTWORDS_PLACEHOLDER, + task_and_example, + COMMON_RULES, + OUTPUT_BLOCK ) } +/// 热词与纠错模块在 system prompt 里的位置占位符。 +/// polish.rs::compose_system_prompt 找到后替换为运行时实际热词块。 +pub const HOTWORDS_PLACEHOLDER: &str = "{{HOTWORDS}}"; + fn default_raw_style_system_prompt() -> String { default_style_system_prompt_for_mode(PolishMode::Raw) } @@ -1096,7 +1217,7 @@ impl Default for UserPreferences { &None, ) .expect("default legacy hotkey is not custom"), - default_mode: PolishMode::Light, + default_mode: PolishMode::Structured, enabled_modes: vec![ PolishMode::Raw, PolishMode::Light, @@ -1137,8 +1258,15 @@ impl Default for UserPreferences { history_retention_days: default_history_retention_days(), polish_context_window_minutes: default_polish_context_window_minutes(), start_minimized: false, - streaming_insert: false, + streaming_insert: true, + streaming_insert_default_migrated: true, streaming_insert_save_clipboard: true, + auto_update_check: true, + history_max_entries: None, + record_audio_for_debug: false, + audio_recording_max_entries: None, + marketplace_base_url: String::new(), + marketplace_dev_login: String::new(), } } } @@ -1565,7 +1693,7 @@ impl HotkeyCapability { supports_side_specific_modifiers: true, explicit_fallback_available: false, status_hint: Some( - "Linux 仅 best-effort:X11 可尝试 rdev 监听;Wayland 会明确提示暂不支持全局热键。".into(), + "Linux 仅 best-effort:X11 可尝试 rdev 监听;Wayland 请在桌面环境中绑定 openless --toggle-dictation 等 CLI 命令。".into(), ), } } @@ -1823,6 +1951,48 @@ mod tests { assert_eq!(from_empty.paste_shortcut, PasteShortcut::CtrlV); } + /// issue #440: 老版本会把默认 `streamingInsert:false` 写进 preferences.json。 + /// 缺少迁移标记的旧文件统一迁到 true;带有迁移标记后,用户再手动关掉的 false + /// 必须保留。 + #[test] + fn streaming_insert_defaults_to_enabled_for_missing_or_legacy_unmigrated_pref() { + let prefs = UserPreferences::default(); + assert!(prefs.streaming_insert); + assert!(prefs.streaming_insert_default_migrated); + assert!(prefs.streaming_insert_save_clipboard); + + let from_empty: UserPreferences = serde_json::from_str("{}").unwrap(); + assert!(from_empty.streaming_insert); + assert!(from_empty.streaming_insert_default_migrated); + assert!(from_empty.streaming_insert_save_clipboard); + + let from_legacy_false: UserPreferences = serde_json::from_str( + r#"{ + "streamingInsert": false, + "streamingInsertSaveClipboard": true + }"#, + ) + .unwrap(); + assert!(from_legacy_false.streaming_insert); + assert!(from_legacy_false.streaming_insert_default_migrated); + } + + #[test] + fn streaming_insert_preserves_explicit_disabled_value() { + let prefs: UserPreferences = serde_json::from_str( + r#"{ + "streamingInsert": false, + "streamingInsertDefaultMigrated": true, + "streamingInsertSaveClipboard": false + }"#, + ) + .unwrap(); + + assert!(!prefs.streaming_insert); + assert!(prefs.streaming_insert_default_migrated); + assert!(!prefs.streaming_insert_save_clipboard); + } + #[test] fn paste_shortcut_round_trips_explicit_values() { for (raw, expected) in [ diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index 2888892c..285e9d4e 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenLess", - "version": "1.3.1", + "version": "1.3.2-3", "identifier": "com.openless.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index 37b82aed..af14908e 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { AutoUpdateGate } from './components/AutoUpdateGate'; import { Capsule } from './components/Capsule'; import { FloatingShell } from './components/FloatingShell'; import { Onboarding } from './components/Onboarding'; @@ -150,6 +151,7 @@ export function App({ isCapsule, isQa }: AppProps) { return ( {gate === 'onboarding' ? setGate('ready')} /> : } + {gate === 'ready' && } ); } diff --git a/openless-all/app/src/components/AutoUpdateGate.tsx b/openless-all/app/src/components/AutoUpdateGate.tsx new file mode 100644 index 00000000..a1953dac --- /dev/null +++ b/openless-all/app/src/components/AutoUpdateGate.tsx @@ -0,0 +1,58 @@ +// 主窗口启动 + 后台每 60 分钟自动调一次 plugin-updater check。 +// 受 prefs.autoUpdateCheck 开关控制;关闭时只走 Settings → 关于 的手动按钮。 +// 找到新版本时直接挂 UpdateDialog;不弹自定义通知,沿用既有 dialog 视觉。 + +import { useEffect, useRef } from 'react'; +import { isDialogStatus, UpdateDialog, useAutoUpdate } from './AutoUpdate'; +import { useHotkeySettings } from '../state/HotkeySettingsContext'; + +const AUTO_CHECK_INTERVAL_MS = 60 * 60 * 1000; +const STARTUP_DELAY_MS = 4_000; + +export function AutoUpdateGate() { + const { prefs } = useHotkeySettings(); + const u = useAutoUpdate(); + const enabled = prefs?.autoUpdateCheck ?? true; + + // 用 ref 保持 tick 闭包始终读到最新的 useAutoUpdate 返回值。 + // 之前直接捕获 `u` 会让 60min interval 触发时读旧 status 闭包——例如用户已经 + // 手动打开 UpdateDialog 后,tick 仍可能错过 busy 检查触发并发 check。 + // 修 pr_agent "Stale closure" 反馈。 + const uRef = useRef(u); + uRef.current = u; + + useEffect(() => { + if (!enabled) return; + let cancelled = false; + + const tick = () => { + if (cancelled) return; + const current = uRef.current; + if (current.checking || current.busy || isDialogStatus(current.status)) return; + void current.checkForUpdates().catch(error => { + console.warn('[auto-update] background check failed', error); + }); + }; + + const startupTimer = window.setTimeout(tick, STARTUP_DELAY_MS); + const intervalTimer = window.setInterval(tick, AUTO_CHECK_INTERVAL_MS); + return () => { + cancelled = true; + window.clearTimeout(startupTimer); + window.clearInterval(intervalTimer); + }; + }, [enabled]); + + if (!isDialogStatus(u.status)) return null; + return ( + + ); +} diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index 93dbfed2..154cf1ea 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -15,6 +15,7 @@ import { Vocab } from '../pages/Vocab'; import { Style } from '../pages/Style'; import { Translation } from '../pages/Translation'; import { SelectionAsk } from '../pages/SelectionAsk'; +// 风格市场不再作为独立 nav tab —— 已整合为 Style 页面内 modal(入口在「风格包」标题右侧)。 // LocalAsr 不再作为主 nav tab——本地 ASR 模型管理已合并到 Settings → Advanced 中 // 通过 渲染。这里之前的 import 与 NAV_BASE 条目都已移除。 import { APP_VERSION_LABEL, IS_BETA_BUILD } from '../lib/appVersion'; diff --git a/openless-all/app/src/components/Icon.tsx b/openless-all/app/src/components/Icon.tsx index 8bcc6ca6..6fe6bede 100644 --- a/openless-all/app/src/components/Icon.tsx +++ b/openless-all/app/src/components/Icon.tsx @@ -55,6 +55,10 @@ export const ICONS: Record = { info: 'M12 8h.01M11 12h1v4h1M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z', external:'M9 5h10v10M19 5L9 15M5 9v10h10', close: 'M6 6l12 12M6 18L18 6', + // play — 右指三角箭头,标识"播放录音"按钮(History 详情) + play: 'M8 5v14l11-7z', + // download — 向下箭头 + 底托,标识"导出录音"按钮(History 详情) + download:'M12 3v12M7 12l5 5 5-5M5 21h14', }; export interface IconProps { diff --git a/openless-all/app/src/components/MarketplaceModal.tsx b/openless-all/app/src/components/MarketplaceModal.tsx new file mode 100644 index 00000000..c46f7bdc --- /dev/null +++ b/openless-all/app/src/components/MarketplaceModal.tsx @@ -0,0 +1,123 @@ +// MarketplaceModal.tsx — 风格市场弹窗。 +// 跟 SettingsModal 同款 backdrop + 居中卡片。内容直接复用 。 +// 入口在 Style 页面「风格包」标题右侧,由 Style.tsx 控制 open/close。 +// 顶部 pill 显示当前「登录身份」(dev 模式 = marketplaceDevLogin),未填时引导跳 Settings。 + +import { useEffect } from 'react'; +import { Icon } from './Icon'; +import { Marketplace } from '../pages/Marketplace'; +import { useHotkeySettings } from '../state/HotkeySettingsContext'; + +interface MarketplaceModalProps { + onClose: () => void; +} + +export function MarketplaceModal({ onClose }: MarketplaceModalProps) { + const { prefs } = useHotkeySettings(); + const login = (prefs?.marketplaceDevLogin ?? '').trim(); + const loggedIn = login.length > 0; + // Esc 关闭 + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }; + window.addEventListener('keydown', onKey, true); + return () => window.removeEventListener('keydown', onKey, true); + }, [onClose]); + + return ( +
+
e.stopPropagation()} + style={{ + width: '100%', maxWidth: 1080, height: '100%', maxHeight: 720, + background: 'var(--ol-surface)', + borderRadius: 14, + border: '0.5px solid rgba(0,0,0,.08)', + boxShadow: '0 30px 80px -20px rgba(15,17,22,.35), 0 0 0 0.5px rgba(0,0,0,.06)', + overflow: 'hidden', + position: 'relative', + display: 'flex', flexDirection: 'column', + animation: 'ol-modal-pop .28s var(--ol-motion-spring)', + }} + > +
+ +
+ + + {/* paddingTop 64 给 X 按钮留位置 —— PageHeader 的 right 槽(刷新/上传)会下沉到 X 下方 */} +
+ +
+
+
+ ); +} diff --git a/openless-all/app/src/components/SavedToast.tsx b/openless-all/app/src/components/SavedToast.tsx index c88eefc2..18439a0c 100644 --- a/openless-all/app/src/components/SavedToast.tsx +++ b/openless-all/app/src/components/SavedToast.tsx @@ -9,8 +9,10 @@ export type SaveToastState = 'idle' | 'saving' | 'saved' | 'failed'; interface SavedToastProps { saveState: SaveToastState; message: string; - /** 覆盖默认 top:16 right:16 偏移,例如 SettingsModal 里要避开 28×28 的关闭按钮。 */ - offsetStyle?: Pick; + /** 覆盖默认 position:absolute、top:16 right:16 偏移。 + * Style 页传 position:'fixed' 把 toast 锚到视口右上角,编辑器展开后向下滚也能看见; + * SettingsModal 用默认 absolute 锚在模态内容右上角。 */ + offsetStyle?: Pick; } export function SavedToast({ saveState, message, offsetStyle }: SavedToastProps) { @@ -21,7 +23,8 @@ export function SavedToast({ saveState, message, offsetStyle }: SavedToastProps) top: 16, right: 16, ...offsetStyle, - zIndex: 5, + // 必须高于所有 modal(backdrop zIndex 50);失败 toast 决不能被 modal 盖住,否则用户看不到错因。 + zIndex: 9999, padding: '5px 12px', borderRadius: 999, border: failed diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index f5ee3c72..65e915b2 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -58,10 +58,40 @@ export const en: typeof zhCN = { history: 'History', vocab: 'Vocabulary', style: 'Style', + marketplace: 'Marketplace', translation: 'Translation', selectionAsk: 'Ask', localAsr: 'Models', }, + marketplace: { + kicker: 'MARKETPLACE', + title: 'Style Pack Marketplace', + desc: 'Browse community style packs, install in one click, like, and share your own.', + searchPlaceholder: 'Search name / description / tags…', + sortPopular: 'Popular', + sortNew: 'Newest', + uploadBtn: 'Upload', + uploadDisabledHint: 'Set your GitHub login in Settings → Marketplace first', + refreshBtn: 'Refresh', + empty: 'No style packs yet', + emptyHint: 'Try a different keyword, or upload your own', + loadFailed: 'Load failed: {{err}}', + noDescription: '(no description)', + installBtn: 'Install', + likeBtn: 'Like', + installed: 'Installed "{{name}}" locally', + uploaded: 'Uploaded — waiting for review', + uploadTitle: 'Pick a style pack to upload', + uploadHint: 'Uploading as {{login}}. Content goes to the cloud review queue.', + uploadNoLocal: 'No local style packs to upload', + errors: { + detail: 'Detail load failed: {{err}}', + install: 'Install failed: {{err}}', + like: 'Like failed: {{err}}', + upload: 'Upload failed: {{err}}', + loadLocal: 'Load local packs failed: {{err}}', + }, + }, shell: { shortcutLabel: 'Recording shortcut', shortcutHint: 'Start / Stop', @@ -155,6 +185,11 @@ export const en: typeof zhCN = { retry: 'Retry', clearFailed: 'Failed to clear history: {{err}}', deleteFailed: 'Failed to delete entry: {{err}}', + copyFailed: 'Failed to copy: {{err}}', + playRecording: 'Play recording', + audioLoading: 'Loading…', + exportRecording: 'Export recording', + exportFailed: 'Failed to export: {{err}}', rawLabel: 'Raw', rawEmpty: '(empty)', selectHint: 'Select an entry on the left to see details.', @@ -350,11 +385,24 @@ export const en: typeof zhCN = { historyGroupTitle: 'History & context', historyRetentionLabel: 'History retention (days)', historyRetentionDesc: 'Entries older than this are pruned on new writes; 0 = no time-based pruning.', + historyMaxEntriesLabel: 'Max history entries', + historyMaxEntriesDesc: 'Cap on locally retained sessions; blank = 200. Range 5–200. Oldest are pruned past the cap.', polishContextWindowLabel: 'Polish context window (minutes)', polishContextWindowDesc: 'Use the last N minutes of polished transcripts as multi-turn context; 0 = disabled.', + recordAudioForDebugLabel: 'Keep raw recording (debug)', + recordAudioForDebugDesc: 'When on, each session saves the raw microphone audio as wav for diagnosing mic sensitivity / ASR misrecognition. All speech is stored locally in plaintext, subject to the same retention as history.', + audioRecordingMaxEntriesLabel: 'Max raw recordings', + audioRecordingMaxEntriesDesc: 'Cap on locally retained wav files; blank = 200. Range 1–200. Oldest are pruned past the cap. Independent from text history cap.', startupGroupTitle: 'Startup', startMinimizedLabel: 'Start minimized (no main window)', startMinimizedDesc: 'No main window on any launch path — menu bar / tray only.', + autoUpdateCheckLabel: 'Auto-check for updates', + autoUpdateCheckDesc: 'Check for new releases on main window launch and every 60 minutes. When off, only the manual "Check for updates" button in About works.', + marketplaceGroupTitle: 'Style Pack Marketplace', + marketplaceBaseUrlLabel: 'Backend URL', + marketplaceBaseUrlDesc: 'Marketplace backend HTTP URL. Blank = local dev default http://127.0.0.1:8090; production: https://api..', + marketplaceDevLoginLabel: 'GitHub login (upload identity)', + marketplaceDevLoginDesc: 'Dev-mode identifies uploader by GitHub login. When blank, upload/like is disabled. Will be replaced by OAuth later.', startupAtBoot: 'Launch at login', startupAtBootDesc: 'Start OpenLess automatically when you sign in.', startupAtBootError: 'Failed to toggle launch at login: {{message}}', @@ -384,17 +432,6 @@ export const en: typeof zhCN = { llmProviderDesc: 'Selecting a preset auto-fills the default Base URL.', credentialStorageNotice: 'Credentials are stored in the OS credential vault. Legacy local JSON credentials are migrated into the vault and removed after a successful write.', codexOAuthNotice: 'Codex OAuth uses the local Codex login state (~/.codex/auth.json). OpenLess does not store an API key or Base URL for this provider.', - styleSystemPromptTitle: 'Polish system prompts', - styleSystemPromptDesc: 'Each built-in style now exposes its full system prompt here. Saved edits are shared by live polish and History repolish.', - styleSystemPromptPlaceholder: 'Enter the full system prompt for this style', - styleSystemPromptHint: 'This edits the full prompt, not just an appended suffix. Press Ctrl/Cmd+Enter to save as well.', - styleSystemPromptSave: 'Save prompt', - styleSystemPromptReset: 'Reset to default', - styleSystemPromptDirty: 'Unsaved', - styleSystemPromptSaveFailed: 'Failed to save system prompt: {{error}}', - styleSystemPromptMovedBadge: 'Migrated', - styleSystemPromptMovedDesc: 'Full system prompts are no longer edited per mode on the Settings page. They now live in the Style Pack detail panel on the Style page.', - styleSystemPromptMovedHint: 'To change runtime prompts, examples, tags, or ZIP import/export, use the Style page.', asrProviderDesc: 'Switching providers automatically loads the matching credentials.', asrTitle: 'ASR (transcription)', asrDesc: 'Used to turn speech into text in real time.', @@ -530,7 +567,7 @@ export const en: typeof zhCN = { streamingInsertHintWindows: 'SendInput Unicode types directly, bypassing TSF / IME — no input-method switching needed.', streamingInsertHintLinux: - 'enigo + XTest synthesize keystrokes. Stable on X11; on Wayland it depends on the compositor — failures fall back automatically.', + 'Uses enigo + XTest on X11. On Wayland, streaming insertion is disabled and output is kept in the clipboard for manual paste.', streamingInsertSaveClipboardLabel: 'Copy to clipboard', streamingInsertSaveClipboardHint: 'After a successful insert, write the final text to the clipboard so Cmd+V can paste it again. Off = clipboard is never touched.', localAsrTitle: 'Local ASR models (experimental)', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 162d47a9..bd7b5b08 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -60,6 +60,7 @@ export const ja: typeof zhCN = { history: '履歴', vocab: '語彙', style: 'スタイル', + marketplace: 'マーケット', translation: '翻訳', selectionAsk: '選択追問', localAsr: 'モデル設定', @@ -157,6 +158,11 @@ export const ja: typeof zhCN = { retry: '再試行', clearFailed: '履歴の消去に失敗:{{err}}', deleteFailed: '記録の削除に失敗:{{err}}', + copyFailed: 'コピーに失敗:{{err}}', + playRecording: '録音を再生', + audioLoading: '読み込み中…', + exportRecording: '録音をエクスポート', + exportFailed: 'エクスポート失敗:{{err}}', rawLabel: '原文', rawEmpty: '(空)', selectHint: '左側から 1 件選択して詳細を表示。', @@ -352,11 +358,24 @@ export const ja: typeof zhCN = { historyGroupTitle: '履歴とコンテキスト', historyRetentionLabel: '履歴保持期間(日)', historyRetentionDesc: '保持日数を超えた履歴は新規書き込み時に削除されます。0 = 時間で削除しない。', + historyMaxEntriesLabel: '履歴件数の上限', + historyMaxEntriesDesc: 'ローカルに保持する直近セッション数。空欄 = 200。範囲 5–200。超過分は古い順に削除。', polishContextWindowLabel: '会話コンテキスト窓(分)', polishContextWindowDesc: '直近 N 分間の整文済み転写をマルチターン文脈として渡します。0 = 無効。', + recordAudioForDebugLabel: '元の録音を保持(デバッグ)', + recordAudioForDebugDesc: 'オンにすると各セッションのマイク音声を wav として保存し、マイク感度 / ASR 誤認識の診断に使えます。発話は平文でローカルに保存され、履歴の保持期間に従って削除されます。', + audioRecordingMaxEntriesLabel: '元音声の保持件数', + audioRecordingMaxEntriesDesc: 'ローカルに保持する最近の wav 件数。空欄 = 200。範囲 1–200。超過分は古い順に削除。テキスト履歴件数とは独立。', startupGroupTitle: '起動', startMinimizedLabel: '起動時にメインウィンドウを表示しない', startMinimizedDesc: 'どの起動経路でもメインウィンドウを開かず、メニューバー / トレイのみで動作。', + autoUpdateCheckLabel: 'アップデートを自動チェック', + autoUpdateCheckDesc: 'メインウィンドウ起動時と 60 分ごとに新バージョンを確認します。オフ時は「バージョン情報」内の手動ボタンのみ有効。', + marketplaceGroupTitle: 'スタイルパックマーケット', + marketplaceBaseUrlLabel: 'バックエンド URL', + marketplaceBaseUrlDesc: 'マーケットプレイス バックエンド HTTP URL。空 = ローカル開発 http://127.0.0.1:8090;本番は https://api.<ドメイン>。', + marketplaceDevLoginLabel: 'GitHub ログイン名(アップロード ID)', + marketplaceDevLoginDesc: 'dev モードでは GitHub ログイン名でアップロード者を識別。空時はアップロード/いいね不可。', startupAtBoot: '起動時に自動起動', startupAtBootDesc: 'ログイン時に OpenLess を自動起動。', startupAtBootError: '自動起動の切り替えに失敗:{{message}}', @@ -386,17 +405,6 @@ export const ja: typeof zhCN = { llmProviderDesc: '選択するとデフォルトの Base URL が自動入力されます。', credentialStorageNotice: '認証情報は OS の認証情報ストアに保存されます。旧バージョンのローカル JSON 認証情報はストアへ移行され、書き込み成功後に削除されます。', codexOAuthNotice: 'Codex OAuth はローカルの Codex ログイン状態(~/.codex/auth.json)を使用します。OpenLess は API Key や Base URL を保存しません。', - styleSystemPromptTitle: 'Polish system prompts', - styleSystemPromptDesc: 'Each built-in style now exposes its full system prompt here. Saved edits are shared by live polish and History repolish.', - styleSystemPromptPlaceholder: 'Enter the full system prompt for this style', - styleSystemPromptHint: 'This edits the full prompt, not just an appended suffix. Press Ctrl/Cmd+Enter to save as well.', - styleSystemPromptSave: 'Save prompt', - styleSystemPromptReset: 'Reset to default', - styleSystemPromptDirty: 'Unsaved', - styleSystemPromptSaveFailed: 'Failed to save system prompt: {{error}}', - styleSystemPromptMovedBadge: '移行済み', - styleSystemPromptMovedDesc: '完全な system prompt は設定ページでモード別に直接編集せず、「スタイル」ページの Style Pack 詳細パネルに集約されました。', - styleSystemPromptMovedHint: '実行時 prompt、例、タグ、ZIP のインポート/エクスポートを変更する場合は「スタイル」ページで操作してください。', asrProviderDesc: '切り替えると対応する認証情報が自動選択されます。', asrTitle: 'ASR 音声(転写)', asrDesc: '口述をリアルタイムでテキストに転写。', @@ -532,7 +540,7 @@ export const ja: typeof zhCN = { streamingInsertHintWindows: 'SendInput Unicode で TSF / IME を迂回。入力ソースの切替は不要です。', streamingInsertHintLinux: - 'enigo + XTest でキー合成。X11 は安定、Wayland は compositor 依存で失敗時は自動フォールバック。', + 'X11 では enigo + XTest でキー合成します。Wayland ではストリーミング入力を無効化し、出力をクリップボードに残して手動貼り付けします。', streamingInsertSaveClipboardLabel: 'クリップボードに保存', streamingInsertSaveClipboardHint: '挿入成功後に最終テキストをクリップボードへ書き込み、Cmd+V で再貼付け可能にします。OFF ではクリップボードに触れません。', localAsrTitle: 'ローカル ASR モデル(実験的)', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 32890cd4..19a54b51 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -60,6 +60,7 @@ export const ko: typeof zhCN = { history: '기록', vocab: '어휘', style: '스타일', + marketplace: '마켓', translation: '번역', selectionAsk: '선택 질문', localAsr: '모델 설정', @@ -157,6 +158,11 @@ export const ko: typeof zhCN = { retry: '다시 시도', clearFailed: '기록 비우기 실패: {{err}}', deleteFailed: '항목 삭제 실패: {{err}}', + copyFailed: '복사 실패: {{err}}', + playRecording: '녹음 재생', + audioLoading: '로딩 중…', + exportRecording: '녹음 내보내기', + exportFailed: '내보내기 실패: {{err}}', rawLabel: '원문', rawEmpty: '(비어 있음)', selectHint: '왼쪽에서 하나를 선택하여 자세히 보기.', @@ -352,11 +358,24 @@ export const ko: typeof zhCN = { historyGroupTitle: '기록 및 컨텍스트', historyRetentionLabel: '기록 보관 기간(일)', historyRetentionDesc: '보관 기간을 초과한 기록은 새 항목 작성 시 정리됩니다. 0 = 시간 기반 정리 비활성화.', + historyMaxEntriesLabel: '기록 개수 상한', + historyMaxEntriesDesc: '로컬에 보관할 최근 세션 수. 비워두면 200. 범위 5–200. 초과 시 오래된 것부터 삭제.', polishContextWindowLabel: '대화 컨텍스트 윈도(분)', polishContextWindowDesc: '최근 N 분간 정리된 전사를 멀티턴 컨텍스트로 전달합니다. 0 = 비활성화.', + recordAudioForDebugLabel: '원본 녹음 보관(디버그)', + recordAudioForDebugDesc: '켜면 각 세션의 마이크 원본을 wav로 저장하여 마이크 감도 / ASR 오인식 진단에 사용합니다. 음성이 평문으로 로컬에 저장되며 기록 보관 기간과 동일한 정리 규칙을 따릅니다.', + audioRecordingMaxEntriesLabel: '원본 녹음 보관 개수', + audioRecordingMaxEntriesDesc: '로컬에 보관할 최근 wav 개수. 비워두면 200. 범위 1–200. 초과 시 오래된 것부터 삭제. 텍스트 기록 개수와 독립.', startupGroupTitle: '시작', startMinimizedLabel: '시작 시 메인 창 숨기기', startMinimizedDesc: '모든 시작 경로에서 메인 창을 열지 않고 메뉴 막대 / 트레이에서만 실행합니다.', + autoUpdateCheckLabel: '자동 업데이트 확인', + autoUpdateCheckDesc: '메인 창 시작 시와 60분마다 새 버전을 확인합니다. 끄면 "정보" 패널의 수동 버튼만 동작합니다.', + marketplaceGroupTitle: '스타일 팩 마켓플레이스', + marketplaceBaseUrlLabel: '백엔드 URL', + marketplaceBaseUrlDesc: '마켓플레이스 백엔드 HTTP URL. 비워두면 로컬 개발 http://127.0.0.1:8090; 프로덕션은 https://api.<도메인>.', + marketplaceDevLoginLabel: 'GitHub 로그인 이름 (업로드 ID)', + marketplaceDevLoginDesc: 'dev 모드에서는 GitHub 로그인 이름으로 업로더 식별. 비워두면 업로드/좋아요 불가.', startupAtBoot: '부팅 시 자동 시작', startupAtBootDesc: '로그인 시 OpenLess 자동 시작.', startupAtBootError: '자동 시작 전환 실패: {{message}}', @@ -389,17 +408,6 @@ export const ko: typeof zhCN = { asrProviderDesc: '전환 시 해당하는 자격 증명이 자동 선택됩니다.', asrTitle: 'ASR 음성(전사)', asrDesc: '구술을 실시간으로 텍스트로 전사합니다.', - styleSystemPromptTitle: 'Polish system prompts', - styleSystemPromptDesc: 'Each built-in style now exposes its full system prompt here. Saved edits are shared by live polish and History repolish.', - styleSystemPromptPlaceholder: 'Enter the full system prompt for this style', - styleSystemPromptHint: 'This edits the full prompt, not just an appended suffix. Press Ctrl/Cmd+Enter to save as well.', - styleSystemPromptSave: 'Save prompt', - styleSystemPromptReset: 'Reset to default', - styleSystemPromptDirty: 'Unsaved', - styleSystemPromptSaveFailed: 'Failed to save system prompt: {{error}}', - styleSystemPromptMovedBadge: '마이그레이션됨', - styleSystemPromptMovedDesc: '전체 system prompt는 더 이상 설정 페이지에서 모드별로 직접 편집하지 않고, 스타일 페이지의 Style Pack 상세 패널로 통합되었습니다.', - styleSystemPromptMovedHint: '런타임 prompt, 예시, 태그 또는 ZIP 가져오기/내보내기를 변경하려면 스타일 페이지에서 작업하세요.', presets: { ark: 'ARK (Volcengine Ark)', deepseek: 'DeepSeek', @@ -532,7 +540,7 @@ export const ko: typeof zhCN = { streamingInsertHintWindows: 'SendInput Unicode 로 TSF / IME 를 우회. 입력 소스 전환 불필요.', streamingInsertHintLinux: - 'enigo + XTest 로 키 합성. X11 안정; Wayland 는 compositor 의존이며 실패 시 자동 폴백.', + 'X11에서는 enigo + XTest로 키를 합성합니다. Wayland에서는 스트리밍 입력을 비활성화하고 출력을 클립보드에 남겨 수동 붙여넣기를 사용합니다.', streamingInsertSaveClipboardLabel: '클립보드에 저장', streamingInsertSaveClipboardHint: '삽입 성공 후 최종 텍스트를 클립보드에 기록하여 Cmd+V 로 다시 붙여넣을 수 있게 합니다. 끄면 클립보드를 건드리지 않습니다.', localAsrTitle: '로컬 ASR 모델 (실험적)', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 5d75fcae..cd95e0ef 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -56,10 +56,40 @@ export const zhCN = { history: '历史', vocab: '词汇表', style: '风格', + marketplace: '风格市场', translation: '翻译', selectionAsk: '划词追问', localAsr: '模型设置', }, + marketplace: { + kicker: 'MARKETPLACE', + title: '风格包市场', + desc: '浏览社区风格包,一键安装到本地;点赞和上传你自己的风格包。', + searchPlaceholder: '搜索名称 / 描述 / 标签…', + sortPopular: '按热度', + sortNew: '最新', + uploadBtn: '上传', + uploadDisabledHint: '请先在 设置 → 风格市场 配置 GitHub 用户名', + refreshBtn: '刷新', + empty: '还没有风格包', + emptyHint: '换个搜索词,或自己上传一个分享给社区', + loadFailed: '加载失败:{{err}}', + noDescription: '(暂无描述)', + installBtn: '安装到本地', + likeBtn: '点赞', + installed: '已安装「{{name}}」到本地风格包', + uploaded: '上传成功,等待审核', + uploadTitle: '选择要上传的风格包', + uploadHint: '上传以 {{login}} 身份登录。包内容会发到云端审核队列。', + uploadNoLocal: '本地没有可上传的风格包', + errors: { + detail: '加载详情失败:{{err}}', + install: '安装失败:{{err}}', + like: '点赞失败:{{err}}', + upload: '上传失败:{{err}}', + loadLocal: '加载本地风格包失败:{{err}}', + }, + }, shell: { shortcutLabel: '录音快捷键', shortcutHint: '开始 / 停止', @@ -153,6 +183,11 @@ export const zhCN = { retry: '重试', clearFailed: '清空失败:{{err}}', deleteFailed: '删除失败:{{err}}', + copyFailed: '复制失败:{{err}}', + playRecording: '播放录音', + audioLoading: '加载中…', + exportRecording: '导出录音', + exportFailed: '导出失败:{{err}}', rawLabel: '原文', rawEmpty: '(空)', selectHint: '左侧选一条查看详情。', @@ -348,11 +383,24 @@ export const zhCN = { historyGroupTitle: '历史与上下文', historyRetentionLabel: '历史保留天数', historyRetentionDesc: '超过保留天数的历史在写入新条目时被清理;0 = 不按时间清理。', + historyMaxEntriesLabel: '历史条数上限', + historyMaxEntriesDesc: '本地保留的最近会话数;留空 = 200。范围 5–200。超出会从最旧的开始删。', polishContextWindowLabel: '对话上下文窗口(分钟)', polishContextWindowDesc: '把最近 N 分钟内已润色的转写作为多轮上下文,0 = 关闭。', + recordAudioForDebugLabel: '保留原始录音(调试)', + recordAudioForDebugDesc: '开启后每次会话会把原始麦克风音频存为 wav,便于判断麦克风灵敏度 / ASR 误识别。录音落盘后所有话明文存本地,受"历史保留天数"清理。', + audioRecordingMaxEntriesLabel: '原始录音保留条数', + audioRecordingMaxEntriesDesc: '本地保留的最近 wav 文件数;留空 = 200。范围 1–200。超出会从最旧的开始删,与文本历史条数独立。', startupGroupTitle: '启动', startMinimizedLabel: '启动时静默运行', startMinimizedDesc: '所有启动路径都不弹主窗口,仅菜单栏 / 托盘运行。', + autoUpdateCheckLabel: '自动检查更新', + autoUpdateCheckDesc: '主窗口启动 + 后台每 60 分钟检查云端新版本。关闭后仅"关于"的手动按钮可用。', + marketplaceGroupTitle: '风格市场', + marketplaceBaseUrlLabel: '云端服务地址', + marketplaceBaseUrlDesc: '风格包市场后端 URL。留空 = 本地开发默认 http://127.0.0.1:8090;生产填 https://api.<你的域名>。', + marketplaceDevLoginLabel: 'GitHub 用户名(上传身份)', + marketplaceDevLoginDesc: 'dev 模式用 GitHub 用户名标识上传者;空时不能上传 / 点赞。后期接入 OAuth 后此字段废弃。', startupAtBoot: '开机自启', startupAtBootDesc: '登录系统时自动启动 OpenLess。', startupAtBootError: '开机自启切换失败:{{message}}', @@ -382,17 +430,6 @@ export const zhCN = { llmProviderDesc: '选择后将自动填入 Base URL 默认值。', credentialStorageNotice: '凭据保存在系统凭据库中。旧版本地 JSON 凭据会迁移到系统凭据库,并在成功写入后删除。', codexOAuthNotice: 'Codex OAuth 使用本机 Codex 登录状态(~/.codex/auth.json),无需在 OpenLess 中保存 API Key 或 Base URL。', - styleSystemPromptTitle: '润色 System Prompt', - styleSystemPromptDesc: '每个内置风格都可以直接编辑完整 system prompt。保存后,实时润色和 History 里的 repolish 会共用这一套提示词。', - styleSystemPromptPlaceholder: '输入该风格的完整 system prompt', - styleSystemPromptHint: '这里编辑的是完整提示词,不再只是附加片段。按 Ctrl/Cmd+Enter 也可保存。', - styleSystemPromptSave: '保存 Prompt', - styleSystemPromptReset: '恢复默认', - styleSystemPromptDirty: '未保存', - styleSystemPromptSaveFailed: '保存 System Prompt 失败:{{error}}', - styleSystemPromptMovedBadge: '已迁移', - styleSystemPromptMovedDesc: '完整 system prompt 不再在设置页里按模式硬编码编辑,而是统一收敛到「风格」页的 Style Pack 详情面板。', - styleSystemPromptMovedHint: '如果你要改运行时 prompt、示例、标签或导入导出 ZIP,请去「风格」页操作。', asrProviderDesc: '切换后将自动选用对应凭据。', asrTitle: 'ASR 语音(转写)', asrDesc: '用于将口述实时转写为文本。', @@ -528,7 +565,7 @@ export const zhCN = { streamingInsertHintWindows: 'SendInput Unicode 直接送字符,绕过 TSF / IME,不切输入法。', streamingInsertHintLinux: - 'enigo + XTest 合成按键。X11 稳定;Wayland 取决于 compositor,失败自动回落。', + 'X11 使用 enigo + XTest 合成按键;Wayland 下会自动关闭流式输入,并保留到剪贴板供手动粘贴。', streamingInsertSaveClipboardLabel: '同步到剪贴板', streamingInsertSaveClipboardHint: '插入成功后把最终文本写入剪贴板,方便 Cmd+V 再次粘贴;关闭后流式过程不动剪贴板。', localAsrTitle: '本地 ASR 模型(实验性)', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index e694f04c..f86426fa 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -58,10 +58,40 @@ export const zhTW: typeof zhCN = { history: '歷史', vocab: '詞彙表', style: '風格', + marketplace: '風格市場', translation: '翻譯', selectionAsk: '劃詞追問', localAsr: '模型設置', }, + marketplace: { + kicker: 'MARKETPLACE', + title: '風格包市場', + desc: '瀏覽社群風格包,一鍵安裝到本機;點讚和上傳你自己的風格包。', + searchPlaceholder: '搜尋名稱 / 描述 / 標籤…', + sortPopular: '按熱度', + sortNew: '最新', + uploadBtn: '上傳', + uploadDisabledHint: '請先在 設定 → 風格市場 配置 GitHub 使用者名稱', + refreshBtn: '重新整理', + empty: '還沒有風格包', + emptyHint: '換個搜尋詞,或自己上傳一個分享給社群', + loadFailed: '載入失敗:{{err}}', + noDescription: '(暫無描述)', + installBtn: '安裝到本機', + likeBtn: '點讚', + installed: '已安裝「{{name}}」到本機風格包', + uploaded: '上傳成功,等待審核', + uploadTitle: '選擇要上傳的風格包', + uploadHint: '以 {{login}} 身份上傳。包內容會發送到雲端審核佇列。', + uploadNoLocal: '本機沒有可上傳的風格包', + errors: { + detail: '載入詳情失敗:{{err}}', + install: '安裝失敗:{{err}}', + like: '點讚失敗:{{err}}', + upload: '上傳失敗:{{err}}', + loadLocal: '載入本機風格包失敗:{{err}}', + }, + }, shell: { shortcutLabel: '錄音快捷鍵', shortcutHint: '開始 / 停止', @@ -155,6 +185,11 @@ export const zhTW: typeof zhCN = { retry: '重試', clearFailed: '清空失敗:{{err}}', deleteFailed: '刪除失敗:{{err}}', + copyFailed: '複製失敗:{{err}}', + playRecording: '播放錄音', + audioLoading: '載入中…', + exportRecording: '匯出錄音', + exportFailed: '匯出失敗:{{err}}', rawLabel: '原文', rawEmpty: '(空)', selectHint: '左側選一條查看詳情。', @@ -350,11 +385,24 @@ export const zhTW: typeof zhCN = { historyGroupTitle: '歷史與上下文', historyRetentionLabel: '歷史保留天數', historyRetentionDesc: '超過保留天數的歷史在寫入新條目時被清理;0 = 不按時間清理。', + historyMaxEntriesLabel: '歷史條數上限', + historyMaxEntriesDesc: '本地保留的最近會話數;留空 = 200。範圍 5–200。超出會從最舊的開始刪。', polishContextWindowLabel: '對話上下文窗口(分鐘)', polishContextWindowDesc: '把最近 N 分鐘內已潤色的轉寫作為多輪上下文,0 = 關閉。', + recordAudioForDebugLabel: '保留原始錄音(除錯)', + recordAudioForDebugDesc: '開啟後每次會話會把原始麥克風音訊存為 wav,便於判斷麥克風靈敏度 / ASR 誤識別。錄音落盤後所有話明文存本地,受「歷史保留天數」清理。', + audioRecordingMaxEntriesLabel: '原始錄音保留條數', + audioRecordingMaxEntriesDesc: '本地保留的最近 wav 檔案數;留空 = 200。範圍 1–200。超出會從最舊的開始刪,與文字歷史條數獨立。', startupGroupTitle: '啟動', startMinimizedLabel: '啓動時靜默運行', startMinimizedDesc: '所有啓動路徑都不彈主窗口,僅選單欄 / 托盤運行。', + autoUpdateCheckLabel: '自動檢查更新', + autoUpdateCheckDesc: '主視窗啟動時 + 後台每 60 分鐘檢查雲端新版本。關閉後僅「關於」的手動按鈕可用。', + marketplaceGroupTitle: '風格市場', + marketplaceBaseUrlLabel: '雲端服務位址', + marketplaceBaseUrlDesc: '風格包市場後端 URL。留空 = 本機開發預設 http://127.0.0.1:8090;生產填 https://api.<你的網域>。', + marketplaceDevLoginLabel: 'GitHub 使用者名稱(上傳身份)', + marketplaceDevLoginDesc: 'dev 模式用 GitHub 使用者名稱識別上傳者;空時不能上傳 / 點讚。', startupAtBoot: '開機自啓', startupAtBootDesc: '登錄系統時自動啓動 OpenLess。', startupAtBootError: '開機自啓切換失敗:{{message}}', @@ -384,17 +432,6 @@ export const zhTW: typeof zhCN = { llmProviderDesc: '選擇後將自動填入 Base URL 默認值。', credentialStorageNotice: '憑據儲存在系統憑據庫中。舊版本機 JSON 憑據會遷移到系統憑據庫,並在成功寫入後刪除。', codexOAuthNotice: 'Codex OAuth 使用本機 Codex 登入狀態(~/.codex/auth.json),無需在 OpenLess 中保存 API Key 或 Base URL。', - styleSystemPromptTitle: '潤色 System Prompt', - styleSystemPromptDesc: '每個內建風格都可以直接編輯完整 system prompt。儲存後,即時潤色和 History 裡的 repolish 會共用這一套提示詞。', - styleSystemPromptPlaceholder: '輸入該風格的完整 system prompt', - styleSystemPromptHint: '這裡編輯的是完整提示詞,不再只是附加片段。按 Ctrl/Cmd+Enter 也可儲存。', - styleSystemPromptSave: '儲存 Prompt', - styleSystemPromptReset: '恢復預設', - styleSystemPromptDirty: '未儲存', - styleSystemPromptSaveFailed: '儲存 System Prompt 失敗:{{error}}', - styleSystemPromptMovedBadge: '已遷移', - styleSystemPromptMovedDesc: '完整 system prompt 不再在設定頁裡按模式硬編碼編輯,而是統一收斂到「風格」頁的 Style Pack 詳情面板。', - styleSystemPromptMovedHint: '如果你要改執行時 prompt、範例、標籤或匯入匯出 ZIP,請去「風格」頁操作。', asrProviderDesc: '切換後將自動選用對應憑據。', asrTitle: 'ASR 語音(轉寫)', asrDesc: '用於將口述實時轉寫爲文本。', @@ -530,7 +567,7 @@ export const zhTW: typeof zhCN = { streamingInsertHintWindows: 'SendInput Unicode 直接送字元,繞過 TSF / IME,不切輸入法。', streamingInsertHintLinux: - 'enigo + XTest 合成按鍵。X11 穩定;Wayland 取決於 compositor,失敗自動回落。', + 'X11 使用 enigo + XTest 合成按鍵;Wayland 下會自動關閉串流輸入,並保留到剪貼簿供手動貼上。', streamingInsertSaveClipboardLabel: '同步到剪貼簿', streamingInsertSaveClipboardHint: '插入成功後把最終文字寫入剪貼簿,方便 Cmd+V 再次貼上;關閉後流式過程不動剪貼簿。', localAsrTitle: '本地 ASR 模型(實驗性)', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index a3fd0c09..98852e83 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -9,6 +9,8 @@ import type { DictationSession, DictionaryEntry, HotkeyCapability, + MarketplaceDetail, + MarketplaceListItem, HotkeyStatus, MicrophoneDevice, PermissionStatus, @@ -95,8 +97,15 @@ let mockSettings: UserPreferences = { polishContextWindowMinutes: 5, startMinimized: false, updateChannel: 'stable', - streamingInsert: false, + streamingInsert: true, + streamingInsertDefaultMigrated: true, streamingInsertSaveClipboard: true, + autoUpdateCheck: true, + historyMaxEntries: null, + recordAudioForDebug: false, + audioRecordingMaxEntries: null, + marketplaceBaseUrl: '', + marketplaceDevLogin: '', }; const mockFullStylePrompts: StyleSystemPrompts = { @@ -419,6 +428,7 @@ const mockHistory: DictationSession[] = OL_DATA.history.map((h, i) => ({ errorCode: null, durationMs: 600, dictionaryEntryCount: 28, + hasAudioRecording: null, })); const mockVocab: DictionaryEntry[] = OL_DATA.vocab.map((v, i) => ({ @@ -563,6 +573,22 @@ export function clearHistory(): Promise { return invokeOrMock('clear_history', undefined, () => undefined); } +/** 读取某次会话的原始麦克风 wav 字节流。仅当 prefs.recordAudioForDebug 当时打开 + * 并且文件没被 retention 清理掉时才有内容;其他情况后端会返回 "recording not found" 错。 + * 调用方应仅在 session.hasAudioRecording === true 时触发,避免无效 IPC。 */ +export function readAudioRecording(sessionId: string): Promise { + return invokeOrMock( + 'read_audio_recording', + { sessionId }, + () => new Uint8Array(), + ).then(value => { + // Tauri 默认把 Vec 序列化为 number[],前端拿到的是普通数组;统一转 Uint8Array。 + if (value instanceof Uint8Array) return value; + if (Array.isArray(value)) return new Uint8Array(value as number[]); + return new Uint8Array(value as ArrayBuffer); + }); +} + // ── Vocab ────────────────────────────────────────────────────────────── export function listVocab(): Promise { return invokeOrMock('list_vocab', undefined, () => mockVocab); @@ -689,6 +715,22 @@ export function saveStylePack(stylePack: StylePack): Promise { }); } +export function createStylePackFromTemplate(template: StylePack): Promise { + return invokeOrMock('create_style_pack_from_template', { template }, () => { + const created: StylePack = { + ...cloneStylePack(template), + id: `imported-mock-${Date.now()}`, + kind: 'imported', + active: false, + enabled: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + mockStylePacks = [...mockStylePacks, created]; + return cloneStylePack(created); + }); +} + export function previewStylePackRuntime(stylePack: StylePack): Promise { return invokeOrMock('preview_style_pack_runtime', { stylePack }, () => composeMockStylePackRuntimeDiagnostics(stylePack)); } @@ -917,3 +959,71 @@ export async function exportErrorLog(suggestedFileName: string): Promise { + return invokeOrMock('marketplace_list', options, () => MOCK_MARKETPLACE); +} + +export function fetchMarketplaceDetail(packId: string): Promise { + return invokeOrMock('marketplace_detail', { packId }, () => ({ + ...MOCK_MARKETPLACE[0], + prompt: '# 角色\n你是测试用 polish 助手。\n\n# 任务\n按整体意图整理转写。', + state: 'approved' as const, + })); +} + +export function installMarketplacePack(packId: string): Promise { + return invokeOrMock('marketplace_install', { packId }, () => mockStylePacks[0]); +} + +export function uploadMarketplacePack( + packId: string, +): Promise<{ id: string; state: string; message: string }> { + return invokeOrMock('marketplace_upload', { packId }, () => ({ + id: 'mock-uploaded', + state: 'pending', + message: 'Mock 上传成功(vite dev)', + })); +} + +export function likeMarketplacePack( + packId: string, +): Promise<{ likeCount: number; alreadyLiked: boolean }> { + return invokeOrMock('marketplace_like', { packId }, () => ({ + likeCount: 13, + alreadyLiked: false, + })); +} + +/** 拉当前登录用户赞过的所有 pack id(用于红心 + 「我赞过的」过滤)。 */ +export function marketplaceMyLikes(): Promise { + return invokeOrMock('marketplace_my_likes', undefined, () => []); +} + +/** 撤回自己发布的 pack(后端软删 state='withdrawn')。仅允许原作者。 */ +export function marketplaceDelete(packId: string): Promise { + return invokeOrMock('marketplace_delete', { packId }, () => undefined); +} diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 848fdd5f..3f93ca38 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -59,8 +59,15 @@ const previousPrefs: UserPreferences = { polishContextWindowMinutes: 5, startMinimized: false, updateChannel: 'stable', - streamingInsert: false, + streamingInsert: true, + streamingInsertDefaultMigrated: true, streamingInsertSaveClipboard: true, + autoUpdateCheck: true, + historyMaxEntries: null, + recordAudioForDebug: false, + audioRecordingMaxEntries: null, + marketplaceBaseUrl: '', + marketplaceDevLogin: '', }; const nextPrefs: UserPreferences = { diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 441e3646..74631256 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -18,6 +18,9 @@ export interface DictationSession { errorCode: string | null; durationMs: number | null; dictionaryEntryCount: number | null; + /** 该会话是否在录音时归档了原始 wav(取决于当时 prefs.recordAudioForDebug)。 + * true 时前端在 History 渲染播放按钮,凭 id 通过 read_audio_recording IPC 拿字节流。 */ + hasAudioRecording: boolean | null; } export interface DictionaryEntry { @@ -174,6 +177,9 @@ export interface StylePack { active: boolean; recommendedModel?: string | null; compatibleAppVersion?: string | null; + /** 衍生关系:null = 本地原创(或还没首发到云端);非空 = 这份 pack 安装自云端 originPackId。 */ + originPackId?: string | null; + originAuthorLogin?: string | null; } export interface StylePackRuntimeDiagnostics { @@ -273,11 +279,52 @@ export interface UserPreferences { updateChannel: UpdateChannel; /** 流式输入:润色 SSE 一边到达一边逐字模拟键盘事件输出到当前焦点。开启后用户感知到 * 的处理时延显著降低。v1 限定 macOS + OpenAI-compatible provider,其他配置自动回落 - * 到原一次性插入。默认 false 与历史行为一致。 */ + * 到原一次性插入。默认 true。 */ streamingInsert: boolean; + /** issue #440 一次性迁移标记:旧配置缺少该字段时后端会把老默认 false 迁到 true; + * 迁移后用户再手动关掉 streamingInsert 时保留 false。 */ + streamingInsertDefaultMigrated: boolean; /** 流式输入成功后是否把最终润色文本写回剪贴板。开启后 Cmd+V 还能重复粘贴该次输出, * 与一次性路径行为对齐。默认 true。 */ streamingInsertSaveClipboard: boolean; + /** 主窗口启动 + 后台每 60 分钟自动检查云端新版本。默认 true。 + * 关闭后仅 Settings → 关于 的「检查更新」手动按钮可用。 */ + autoUpdateCheck: boolean; + /** 历史记录上限(条数)。null = 走默认 200;5..=200 之间为用户自定义。 */ + historyMaxEntries: number | null; + /** 是否为每次会话保留原始麦克风音频文件(wav),用于排查 ASR 误识别 / 麦克风灵敏度。 + * 默认 false。开启后会占磁盘空间,受 historyRetentionDays 同样的清理策略约束。 */ + recordAudioForDebug: boolean; + /** recordings/ 里保留的最近 wav 文件数。null = 跟随 200 硬上限;1..=200 之间为用户自定义。 + * 跟 historyMaxEntries 解耦——「文本档案多但 wav 只留最近 5 条」是合法组合。 */ + audioRecordingMaxEntries: number | null; + /** Marketplace HTTP 基地址。空 = 本地开发默认 http://127.0.0.1:8090;生产填 https://api.。 */ + marketplaceBaseUrl: string; + /** Marketplace dev-mode 模拟登录用户名(GitHub login 风格)。生产换 OAuth token 后此字段废弃。 */ + marketplaceDevLogin: string; +} + +export interface MarketplaceListItem { + id: string; + slug: string; + name: string; + description: string; + authorLogin: string; + version: string; + baseMode: PolishMode; + tags: string[]; + likeCount: number; + downloadCount: number; + publishedAt: string; + updatedAt: string; + /** 衍生关系:null = 原创;非空 = 衍生自 originPackId,UI 显「衍生自 @originAuthorLogin」。 */ + originPackId?: string | null; + originAuthorLogin?: string | null; +} + +export interface MarketplaceDetail extends MarketplaceListItem { + prompt: string; + state: 'pending' | 'approved' | 'rejected'; } export interface MicrophoneDevice { diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index 22644f5b..f030fe9b 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; import { detectOS } from '../components/WindowChrome'; import { formatComboLabel } from '../lib/hotkey'; -import { clearHistory, deleteHistoryEntry, listHistory } from '../lib/ipc'; +import { clearHistory, deleteHistoryEntry, listHistory, readAudioRecording } from '../lib/ipc'; import type { DictationSession, PolishMode } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { Btn, Card, PageHeader, Pill } from './_atoms'; @@ -43,6 +43,21 @@ export function History() { const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [actionError, setActionError] = useState(null); + const [justCopied, setJustCopied] = useState(false); + // 录音文件 lazily-detected missing 状态:retention / 条数 cap 清理后磁盘上 wav + // 可能已被删,但 history 条目 hasAudioRecording 仍写 true。任一组件 + // (播放 / 导出)首次 IPC 拿到 'recording not found' 时把 id 加进来, + // 之后渲染按钮的条件就转 false,避免反复点击得到同样的 error。 + // 修 pr_agent "Missing file check" 反馈。 + const [audioMissingIds, setAudioMissingIds] = useState>(() => new Set()); + const markAudioMissing = useCallback((id: string) => { + setAudioMissingIds(prev => { + if (prev.has(id)) return prev; + const next = new Set(prev); + next.add(id); + return next; + }); + }, []); const { prefs } = useHotkeySettings(); const refresh = useCallback(async () => { @@ -102,9 +117,50 @@ export function History() { } }; - const onCopy = () => { + const onCopy = async () => { if (!item) return; - navigator.clipboard?.writeText(item.finalText); + try { + if (!navigator.clipboard?.writeText) { + throw new Error('clipboard unavailable'); + } + await navigator.clipboard.writeText(item.finalText); + setActionError(null); + setJustCopied(true); + window.setTimeout(() => setJustCopied(false), 1500); + } catch (error) { + console.error('[history] failed to copy entry', error); + setActionError(t('history.copyFailed', { err: errorMessage(error) })); + } + }; + + const onExportAudio = async () => { + if (!item || !item.hasAudioRecording) return; + try { + const bytes = await readAudioRecording(item.id); + if (bytes.byteLength === 0) throw new Error('empty recording'); + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + const blob = new Blob([buffer], { type: 'audio/wav' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `openless-recording-${item.id}.wav`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + // 浏览器异步触发下载,立刻 revoke 偶尔被中断;延后 60s 兜底。 + window.setTimeout(() => URL.revokeObjectURL(url), 60_000); + setActionError(null); + } catch (error) { + console.error('[history] failed to export recording', error); + const msg = errorMessage(error); + // wav 已被 retention / 条数 cap 清理:把按钮隐藏,不显示错误(用户没干错事)。 + if (msg.includes('recording not found') || msg.includes('not found')) { + markAudioMissing(item.id); + return; + } + setActionError(t('history.exportFailed', { err: msg })); + } }; return ( @@ -208,10 +264,20 @@ export function History() { {formatDuration(item.durationMs, t)}
- {t('common.copy')} + void onCopy()}>{justCopied ? t('common.copied') : t('common.copy')} + {item.hasAudioRecording && !audioMissingIds.has(item.id) && ( + void onExportAudio()}>{t('history.exportRecording')} + )} {t('common.delete')}
+ {item.hasAudioRecording && !audioMissingIds.has(item.id) && ( + markAudioMissing(item.id)} + key={item.id} + /> + )}
{t('history.rawLabel')} @@ -260,6 +326,79 @@ function errorMessage(error: unknown): string { return String(error); } +/** 当 session.hasAudioRecording 为 true 时渲染:一个加载按钮 + 拿到字节后切换为 + * 原生 audio controls。Blob URL 在组件 unmount 时 revoke,避免泄漏。 + * `onMissing` 在后端返回 'recording not found'(wav 已被 prune)时触发,让父组件 + * 把按钮永久隐藏,避免用户继续点击得到同样错误。 */ +function AudioRecordingPlayer({ + sessionId, + onMissing, +}: { + sessionId: string; + onMissing?: () => void; +}) { + const { t } = useTranslation(); + const [url, setUrl] = useState(null); + const [status, setStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle'); + const [errorText, setErrorText] = useState(null); + + useEffect(() => { + return () => { + if (url) URL.revokeObjectURL(url); + }; + }, [url]); + + const load = async () => { + setStatus('loading'); + setErrorText(null); + try { + const bytes = await readAudioRecording(sessionId); + if (bytes.byteLength === 0) throw new Error('empty recording'); + // typed array 在严格 TS lib 下不直接是 BlobPart;构造独立 ArrayBuffer 后 cast。 + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + const blob = new Blob([buffer], { type: 'audio/wav' }); + const objectUrl = URL.createObjectURL(blob); + setUrl(objectUrl); + setStatus('ready'); + } catch (error) { + console.error('[history] load recording failed', error); + const msg = errorMessage(error); + // 文件被清理:通知父组件隐藏按钮组,自身不显示 error UI(用户没干错事)。 + if (msg.includes('recording not found') || msg.includes('not found')) { + onMissing?.(); + return; + } + setStatus('error'); + setErrorText(msg); + } + }; + + if (status === 'ready' && url) { + return ( +
+
+ ); + } + return ( +
+ void load()} + disabled={status === 'loading'} + > + {status === 'loading' ? t('history.audioLoading') : t('history.playRecording')} + + {status === 'error' && ( + {errorText} + )} +
+ ); +} + function formatTime(iso: string): string { const d = new Date(iso); if (isNaN(d.getTime())) return iso; diff --git a/openless-all/app/src/pages/Marketplace.tsx b/openless-all/app/src/pages/Marketplace.tsx new file mode 100644 index 00000000..64cd4fd8 --- /dev/null +++ b/openless-all/app/src/pages/Marketplace.tsx @@ -0,0 +1,648 @@ +// Marketplace.tsx — Style Pack Marketplace 浏览面板。 +// +// Phase A 目标(goal 1.a-e): +// (a) 后端验证 — 通过 marketplace_* IPC 跟后端通信 +// (b) 上传与拉取功能 — Install / Upload 按钮 +// (c) 单独弹窗界面 — modal-style detail 卡片 +// (d) 搜索框 — 顶部 input + server-side ?q= +// (e) 按排名自动推荐 — 默认 sort=popular +// +// 后端 URL 走 prefs.marketplaceBaseUrl,dev 模式默认 http://127.0.0.1:8090; +// 用户在 Settings 填生产 URL 后客户端自动切换。 +// dev 上传需要 prefs.marketplaceDevLogin(GitHub login 风格)—— 空时上传按钮 disabled。 + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Icon } from '../components/Icon'; +import { + fetchMarketplaceDetail, + installMarketplacePack, + likeMarketplacePack, + listMarketplace, + listStylePacks, + marketplaceDelete, + marketplaceMyLikes, + uploadMarketplacePack, +} from '../lib/ipc'; +import { useHotkeySettings } from '../state/HotkeySettingsContext'; +import type { MarketplaceDetail, MarketplaceListItem, StylePack } from '../lib/types'; +import { Btn, Card, PageHeader, Pill } from './_atoms'; + +type SortMode = 'popular' | 'new' | 'liked' | 'mine'; + +export function Marketplace() { + const { t } = useTranslation(); + const { prefs } = useHotkeySettings(); + + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [query, setQuery] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(''); + const [sort, setSort] = useState('popular'); + const [selectedId, setSelectedId] = useState(null); + const [detail, setDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [actionMsg, setActionMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null); + // leaving=true 触发右滑出动画;动画跑完再真正 setActionMsg(null) 卸载 DOM。 + const [actionLeaving, setActionLeaving] = useState(false); + // 自动消失:ok 2.4s、err 4s 后切 leaving;leaving 持续 ~280ms 等动画结束。 + useEffect(() => { + if (!actionMsg) return; + setActionLeaving(false); + const dwellMs = actionMsg.kind === 'ok' ? 2400 : 4000; + const exitDelay = 280; + const leaveId = window.setTimeout(() => setActionLeaving(true), dwellMs); + const dropId = window.setTimeout(() => setActionMsg(null), dwellMs + exitDelay); + return () => { + window.clearTimeout(leaveId); + window.clearTimeout(dropId); + }; + }, [actionMsg]); + const dismissActionMsg = () => { + // 用户点击立即触发右滑出。 + setActionLeaving(true); + window.setTimeout(() => setActionMsg(null), 280); + }; + + const [showUpload, setShowUpload] = useState(false); + const [localPacks, setLocalPacks] = useState([]); + // 当前用户赞过的 pack id 集合 —— 用于红心渲染 + 「我赞过的」过滤。 + // 进入 marketplace 时拉一次;点赞/取消赞后本地 mutate。 + const [likedIds, setLikedIds] = useState>(new Set()); + const canUpload = (prefs?.marketplaceDevLogin ?? '').trim().length > 0; + const currentLogin = (prefs?.marketplaceDevLogin ?? '').trim(); + // 「衍生自」只在 origin 作者 != 当前登录身份时显示 —— 自己的 pack 不要给自己挂衍生标签。 + const isDerivative = (originLogin: string | null | undefined): boolean => + !!originLogin && originLogin !== currentLogin; + + // search 防抖 300ms + useEffect(() => { + const id = window.setTimeout(() => setDebouncedQuery(query), 300); + return () => window.clearTimeout(id); + }, [query]); + + // 单调递增 seq 防 stale 响应覆盖:用户快速改 query / 切换 pack 时旧请求 response + // 可能晚于新请求到达,比较 seq 丢弃过期结果。 + const reqSeqRef = useRef(0); + const detailSeqRef = useRef(0); + const refresh = useCallback(async () => { + const seq = ++reqSeqRef.current; + setLoading(true); + setLoadError(null); + try { + // backend 只认 popular/new —— 'liked' / 'mine' 都走 popular 拉一批回来,前端再过滤。 + const serverSort: 'popular' | 'new' = + sort === 'liked' || sort === 'mine' ? 'popular' : sort; + const list = await listMarketplace({ query: debouncedQuery, sort: serverSort, limit: 50 }); + if (seq !== reqSeqRef.current) return; // stale response + setItems(list); + } catch (error) { + if (seq !== reqSeqRef.current) return; + console.error('[marketplace] list failed', error); + setLoadError(errorMessage(error)); + } finally { + if (seq === reqSeqRef.current) setLoading(false); + } + }, [debouncedQuery, sort]); + + const visibleItems = useMemo(() => { + if (sort === 'liked') return items.filter(it => likedIds.has(it.id)); + if (sort === 'mine') return items.filter(it => it.authorLogin === currentLogin); + return items; + }, [items, sort, likedIds, currentLogin]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + // 拉一次「我赞过的」缓存,渲染红心 + 「我赞过的」过滤。登录身份变更时重拉。 + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const ids = await marketplaceMyLikes(); + if (!cancelled) setLikedIds(new Set(ids)); + } catch (error) { + console.warn('[marketplace] fetch my-likes failed', error); + } + })(); + return () => { cancelled = true; }; + }, [currentLogin]); + + const openDetail = async (id: string) => { + const seq = ++detailSeqRef.current; + setSelectedId(id); + setDetail(null); + setDetailLoading(true); + try { + const d = await fetchMarketplaceDetail(id); + if (seq !== detailSeqRef.current) return; // stale: 用户已切到另一个 pack + setDetail(d); + } catch (error) { + if (seq !== detailSeqRef.current) return; + console.error('[marketplace] detail failed', error); + setActionMsg({ kind: 'err', text: t('marketplace.errors.detail', { err: errorMessage(error) }) }); + setSelectedId(null); + } finally { + if (seq === detailSeqRef.current) setDetailLoading(false); + } + }; + + const onInstall = async () => { + if (!detail) return; + try { + await installMarketplacePack(detail.id); + setActionMsg({ kind: 'ok', text: t('marketplace.installed', { name: detail.name }) }); + setSelectedId(null); + } catch (error) { + setActionMsg({ kind: 'err', text: t('marketplace.errors.install', { err: errorMessage(error) }) }); + } + }; + + const onLike = async () => { + if (!detail) return; + try { + const r = await likeMarketplacePack(detail.id); + setDetail({ ...detail, likeCount: r.likeCount }); + setItems(prev => prev.map(p => (p.id === detail.id ? { ...p, likeCount: r.likeCount } : p))); + // 后端是 toggle 语义:已赞→取消(alreadyLiked=false),未赞→已赞(alreadyLiked=true)。本地同步 mutate。 + setLikedIds(prev => { + const next = new Set(prev); + if (r.alreadyLiked) next.add(detail.id); + else next.delete(detail.id); + return next; + }); + } catch (error) { + setActionMsg({ kind: 'err', text: t('marketplace.errors.like', { err: errorMessage(error) }) }); + } + }; + + const openUploadPicker = async () => { + try { + const packs = await listStylePacks(); + // 内置 pack 是只读模板,不能上传;过滤掉避免用户选了再被 backend 拒。 + setLocalPacks(packs.filter(p => p.kind !== 'builtin')); + setShowUpload(true); + } catch (error) { + setActionMsg({ kind: 'err', text: t('marketplace.errors.loadLocal', { err: errorMessage(error) }) }); + } + }; + + const onDelete = async () => { + if (!detail) return; + if (detail.authorLogin !== currentLogin) return; // 只有作者能删 + // eslint-disable-next-line no-alert + if (!window.confirm(`确认从风格市场撤回「${detail.name}」?本地副本不会被删除。`)) return; + try { + await marketplaceDelete(detail.id); + setActionMsg({ kind: 'ok', text: '已从风格市场撤回' }); + setSelectedId(null); + // 撤回后立即从列表里去掉,再请求一次确认 + setItems(prev => prev.filter(p => p.id !== detail.id)); + void refresh(); + } catch (error) { + setActionMsg({ kind: 'err', text: `撤回失败:${errorMessage(error)}` }); + } + }; + + const onUpload = async (packId: string) => { + try { + await uploadMarketplacePack(packId); + setActionMsg({ kind: 'ok', text: t('marketplace.uploaded') }); + setShowUpload(false); + // 自传后 ~3s 让 agent 走完审核再回灌,让用户立刻看到自己的包出现在列表里。 + window.setTimeout(() => { void refresh(); }, 1500); + window.setTimeout(() => { void refresh(); }, 5000); + } catch (error) { + setActionMsg({ kind: 'err', text: t('marketplace.errors.upload', { err: errorMessage(error) }) }); + } + }; + + const sortPills = useMemo>( + () => [ + { id: 'popular', label: t('marketplace.sortPopular') }, + { id: 'new', label: t('marketplace.sortNew') }, + { id: 'liked', label: '我赞过的' }, + { id: 'mine', label: '我发布的' }, + ], + [t], + ); + + return ( +
+ + void refresh()}> + {t('common.refresh')} + + + void openUploadPicker()} + disabled={!canUpload} + > + {t('marketplace.uploadBtn')} + + +
+ } + /> + + {/* 顶部搜索 + 排序 */} +
+
+ + setQuery(e.target.value)} + style={{ + flex: 1, + outline: 'none', + border: 0, + background: 'transparent', + fontSize: 13, + color: 'var(--ol-ink-1)', + }} + /> +
+
+ {sortPills.map(p => ( + + ))} +
+
+ + {actionMsg && ( +
+ {actionMsg.text} + +
+ )} + + {loadError && ( + +
+ {t('marketplace.loadFailed', { err: loadError })} +
+
+ )} + + {/* 卡片列表 */} +
+ {loading ? ( +
+ {t('common.loading')} +
+ ) : visibleItems.length === 0 ? ( + +
+ {sort === 'liked' && '你还没有赞过任何风格包'} + {sort === 'mine' && '你还没有发布过风格包'} + {(sort === 'popular' || sort === 'new') && t('marketplace.empty')} +
+
+ {sort === 'liked' && '点开任一风格包,红心点亮后会出现在这里'} + {sort === 'mine' && '在「风格」页面编辑后点「发布到风格市场」'} + {(sort === 'popular' || sort === 'new') && t('marketplace.emptyHint')} +
+
+ ) : ( +
+ {visibleItems.map(p => ( + + ))} +
+ )} +
+ + {/* 详情弹窗 */} + {selectedId && ( + setSelectedId(null)}> + {detailLoading || !detail ? ( +
+ {t('common.loading')} +
+ ) : ( + <> +
+

{detail.name}

+ {detail.baseMode} + {isDerivative(detail.originAuthorLogin) && ( + + 衍生自 @{detail.originAuthorLogin} + + )} + + v{detail.version} + +
+
+ @{detail.authorLogin} + {' · '} + + {likedIds.has(detail.id) ? '♥' : '♡'} + + {' '}{detail.likeCount}{' · ↓ '}{detail.downloadCount} +
+ {detail.description && ( +
+ {detail.description} +
+ )} +
+ {detail.prompt} +
+
+
+ {detail.authorLogin === currentLogin && currentLogin.length > 0 && ( + void onDelete()}> + 🗑 + 撤回发布 + + )} +
+
+ void onLike()}> + + {likedIds.has(detail.id) ? '♥' : '♡'} + + {likedIds.has(detail.id) ? '取消赞' : t('marketplace.likeBtn')} + + setSelectedId(null)}> + {t('common.cancel')} + + void onInstall()}> + {t('marketplace.installBtn')} + +
+
+ + + )} +
+ )} + + {/* 上传选包器 */} + {showUpload && ( + setShowUpload(false)}> +

{t('marketplace.uploadTitle')}

+
+ {t('marketplace.uploadHint', { login: prefs?.marketplaceDevLogin ?? '' })} +
+
+ {localPacks.length === 0 ? ( +
+ {t('marketplace.uploadNoLocal')} +
+ ) : ( + localPacks.map(p => ( + + )) + )} +
+
+ setShowUpload(false)}> + {t('common.cancel')} + +
+
+ )} +
+ ); +} + +function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { + return ( +
+
e.stopPropagation()} + style={{ + width: 'min(560px, 100%)', + maxHeight: '85vh', + overflow: 'auto', + borderRadius: 16, + background: 'var(--ol-surface)', + border: '0.5px solid var(--ol-line-strong)', + boxShadow: '0 18px 42px rgba(0,0,0,0.18)', + padding: 22, + }} + > + {children} +
+
+ ); +} + +function errorMessage(error: unknown): string { + if (typeof error === 'string') return error; + if (error instanceof Error) return error.message; + return String(error); +} diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 427bc793..725ea492 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -315,6 +315,36 @@ function RecordingSection() { }; const onStartMinimizedChange = (startMinimized: boolean) => savePrefs({ ...prefs, startMinimized }); + const onAutoUpdateCheckChange = (autoUpdateCheck: boolean) => + savePrefs({ ...prefs, autoUpdateCheck }); + const onMarketplaceBaseUrlChange = (marketplaceBaseUrl: string) => + savePrefs({ ...prefs, marketplaceBaseUrl }); + const onMarketplaceDevLoginChange = (marketplaceDevLogin: string) => + savePrefs({ ...prefs, marketplaceDevLogin }); + const onRecordAudioForDebugChange = (recordAudioForDebug: boolean) => + savePrefs({ ...prefs, recordAudioForDebug }); + // 历史条数 200 是当前 HISTORY_CAP(persistence.rs:32),下限 5 是避免用户填 0 导致 + // 写一条就立刻被清光;空字符串视为不限制,落回 null → 后端走 200 默认。 + const onHistoryMaxEntriesChange = (raw: string) => { + const trimmed = raw.trim(); + if (trimmed === '') { + void savePrefs({ ...prefs, historyMaxEntries: null }); + return; + } + const parsed = Number.parseInt(trimmed, 10); + if (Number.isNaN(parsed)) return; + void savePrefs({ ...prefs, historyMaxEntries: clamp(parsed, 5, 200) }); + }; + const onAudioRecordingMaxEntriesChange = (raw: string) => { + const trimmed = raw.trim(); + if (trimmed === '') { + void savePrefs({ ...prefs, audioRecordingMaxEntries: null }); + return; + } + const parsed = Number.parseInt(trimmed, 10); + if (Number.isNaN(parsed)) return; + void savePrefs({ ...prefs, audioRecordingMaxEntries: clamp(parsed, 1, 200) }); + }; const choices: Array<[HotkeyMode, string]> = [ ['toggle', t('settings.recording.modeToggle')], @@ -516,6 +546,20 @@ function RecordingSection() { style={{ ...inputStyle, width: 80, textAlign: 'right' }} /> + + onHistoryMaxEntriesChange(e.target.value)} + style={{ ...inputStyle, width: 80, textAlign: 'right' }} + /> + + + + + + onAudioRecordingMaxEntriesChange(e.target.value)} + style={{ ...inputStyle, width: 80, textAlign: 'right' }} + disabled={!prefs.recordAudioForDebug} + /> + {/* ─── 启动(折叠) ──────────────────────────────────────────── */} @@ -540,12 +605,46 @@ function RecordingSection() { > + + + {capability.statusHint && (
{capability.statusHint}
)} + + {/* ─── 风格市场(折叠) ────────────────────────────────────────── */} + + + onMarketplaceBaseUrlChange(e.target.value)} + style={{ ...inputStyle, width: 280 }} + /> + + + onMarketplaceDevLoginChange(e.target.value)} + style={{ ...inputStyle, width: 180 }} + /> + + ); } @@ -1607,34 +1706,6 @@ function ProvidersSection() { setLlmModelRevision(v => v + 1)} /> - -
-
{t('settings.providers.styleSystemPromptTitle')}
-
- {t('settings.providers.styleSystemPromptDesc')} -
-
-
-
- {t('settings.providers.styleSystemPromptMovedBadge')} - {t('settings.providers.styleSystemPromptTitle')} -
-
- {t('settings.providers.styleSystemPromptMovedDesc')} -
-
- {t('settings.providers.styleSystemPromptMovedHint')} -
-
-
-
{t('settings.providers.asrTitle')}
diff --git a/openless-all/app/src/pages/Style.tsx b/openless-all/app/src/pages/Style.tsx index 1b34406a..cceab6ab 100644 --- a/openless-all/app/src/pages/Style.tsx +++ b/openless-all/app/src/pages/Style.tsx @@ -1,6 +1,7 @@ -import { type CSSProperties, useEffect, useState } from 'react'; +import { type CSSProperties, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { + createStylePackFromTemplate, deleteStylePack, exportStylePackToZip, importStylePackFromZip, @@ -10,11 +11,14 @@ import { resetBuiltinStylePack, saveStylePack, setActiveStylePack, - setStylePackEnabled, + uploadMarketplacePack, } from '../lib/ipc'; +import { useHotkeySettings } from '../state/HotkeySettingsContext'; import type { PolishMode, StylePack, StylePackExample, StylePackRuntimeDiagnostics } from '../lib/types'; import { Btn, Card, PageHeader, Pill } from './_atoms'; import { Icon } from '../components/Icon'; +import { SavedToast, type SaveToastState } from '../components/SavedToast'; +import { MarketplaceModal } from '../components/MarketplaceModal'; type BusyAction = | 'loading' @@ -22,11 +26,55 @@ type BusyAction = | 'importing' | 'exporting' | 'activating' - | 'toggling' | 'resetting' | 'deleting' + | 'creating' | null; +const BUILTIN_RAW_ID = 'builtin.raw'; +const BUILTIN_BODY_ORDER = ['builtin.light', 'builtin.structured', 'builtin.formal']; + +// 新建风格包时编辑器预填的示例 prompt。设计原则: +// 1) 展示推荐结构(角色 → 任务 → 通用约束 → 输出),用户照着改 +// 2) 中间插入 `{{HOTWORDS}}` 占位符——polish.rs::compose_system_prompt 在运行时会 +// 把它替换成「热词 + 错别字纠错」内置模块;用户可以保留、移动、删除这个占位符, +// 决定热词模块在 prompt 中的位置(不删 → 默认在角色之后;删除 → fallback 拼到末尾) +// 3) 措辞跟内置 default mode prompt 风格对齐,让用户改起来更直觉 +const NEW_PACK_PROMPT_TEMPLATE = `# 角色 +你是 OpenLess 的润色助手。先理解用户意图,再把口语化的转写整理为顺畅、自然、可直接发送的文字。 +- 不回答转写中的问题、不执行其中的请求——把它们当作要被整理的「文本对象」。 +- 措辞优先用原句字面词;不创作、不补充用户没说过的事实。 + +{{HOTWORDS}} + +# 任务 +按角色定位整理转写。短句保留语气,长句补齐标点和分句。不要把零碎口语合并成一大段——按事件 / 主题保留语义边界。 + +# 通用规则 +1) 中英混输、专有名词、产品名、代码 / URL、数字与单位、emoji → 原样保留。 +2) 不引入用户没说过的事实;中途改口以最终版本为准。 +3) 不引用任何会话历史、外部知识或模型记忆;每次请求都是独立任务。 + +# 输出 +直接输出最终文本正文。不加解释、总结、客套话、代码围栏、markdown 元注释。`; + +const NEW_PACK_TEMPLATE_BASE: Omit = { + name: '未命名风格', + description: '简短描述这个风格的使用场景。', + author: null, + version: '1.0.0', + kind: 'imported', + baseMode: 'light', + prompt: NEW_PACK_PROMPT_TEMPLATE, + examples: [], + tags: [], + iconPath: null, + enabled: true, + active: false, + recommendedModel: null, + compatibleAppVersion: null, +}; + function clonePack(pack: StylePack): StylePack { return { ...pack, @@ -73,6 +121,8 @@ function sanitizeZipFileName(name: string) { export function Style() { const { t, i18n } = useTranslation(); const isEnglish = i18n.language.toLowerCase().startsWith('en'); + const { prefs: marketplacePrefs } = useHotkeySettings(); + const canPublish = (marketplacePrefs?.marketplaceDevLogin ?? '').trim().length > 0; const copy = { kicker: 'STYLE PACKS', title: isEnglish ? 'Style Packs' : '风格包', @@ -83,14 +133,19 @@ export function Style() { importZip: isEnglish ? 'Import ZIP' : '导入 ZIP', exportZip: isEnglish ? 'Export ZIP' : '导出 ZIP', exportShort: isEnglish ? 'Export' : '导出', + publishMarketplace: isEnglish ? 'Publish to Marketplace' : '发布到风格市场', + publishDisabledHint: isEnglish + ? 'Configure your GitHub login in Settings → Marketplace first' + : '请先在 设置 → 风格市场 配置 GitHub 用户名', + publishSuccess: isEnglish + ? 'Published — pending review on marketplace' + : '发布成功,等待 marketplace 审核', + publishFailed: (msg: string) => + isEnglish ? `Publish failed: ${msg}` : `发布失败:${msg}`, builtin: isEnglish ? 'Built-in' : '内置', imported: isEnglish ? 'Imported' : '导入', active: isEnglish ? 'Active' : '当前', - enabled: isEnglish ? 'In Rotation' : '已加入轮换', - disabled: isEnglish ? 'Out of Rotation' : '未加入轮换', activate: isEnglish ? 'Activate' : '激活', - enable: isEnglish ? 'Rotation ON' : '轮换 ON', - disable: isEnglish ? 'Rotation OFF' : '轮换 OFF', edit: isEnglish ? 'Edit' : '编辑', closeEditor: isEnglish ? 'Close' : '关闭', unsaved: isEnglish ? 'Unsaved' : '未保存', @@ -99,15 +154,17 @@ export function Style() { ? 'Browse and switch packs.' : '浏览和切换风格包。', listCount: (count: number) => (isEnglish ? `${count} packs` : `${count} 个风格包`), + addPackTileTitle: isEnglish ? 'New Pack' : '新建风格包', + addPackTileHint: isEnglish ? 'Start from a blank template.' : '从空白模板开始。', + createSuccess: isEnglish ? 'New pack created.' : '已创建新风格包', + createFailed: (message: string) => (isEnglish ? `Failed to create pack: ${message}` : `创建风格包失败:${message}`), + builtinPackEditLabel: (name: string) => (isEnglish ? `Edit "${name}"` : `编辑「${name}」`), save: isEnglish ? 'Save' : '保存', revert: isEnglish ? 'Revert' : '撤销', saveSuccess: isEnglish ? 'Style pack saved.' : '风格包已保存', saveFailed: (message: string) => (isEnglish ? `Failed to save style pack: ${message}` : `保存风格包失败:${message}`), activateSuccess: (name: string) => (isEnglish ? `Set "${name}" as current.` : `已将“${name}”设为当前风格`), activateFailed: (message: string) => (isEnglish ? `Failed to set current style pack: ${message}` : `设为当前风格失败:${message}`), - enableSuccess: (name: string) => (isEnglish ? `Added "${name}" to rotation.` : `已将“${name}”加入轮换`), - disableSuccess: (name: string) => (isEnglish ? `Removed "${name}" from rotation.` : `已将“${name}”移出轮换`), - toggleFailed: (message: string) => (isEnglish ? `Failed to change rotation status: ${message}` : `切换轮换状态失败:${message}`), importSuccess: (name: string) => (isEnglish ? `Imported "${name}".` : `已导入“${name}”`), importFailed: (message: string) => (isEnglish ? `Failed to import ZIP: ${message}` : `导入 ZIP 失败:${message}`), exportSuccess: (path: string) => (isEnglish ? `Exported to ${path}` : `已导出到 ${path}`), @@ -122,12 +179,6 @@ export function Style() { : `确定删除“${name}”吗?删除后无法恢复。`), deleteSuccess: (name: string) => (isEnglish ? `Deleted "${name}".` : `已删除“${name}”`), deleteFailed: (message: string) => (isEnglish ? `Failed to delete pack: ${message}` : `删除风格包失败:${message}`), - summaryBuiltin: isEnglish ? 'Built-in Packs' : '内置风格', - summaryBuiltinHint: isEnglish ? 'Default product semantics with one-click reset.' : '跟随产品默认语义,可一键重置到官方基线。', - summaryImported: isEnglish ? 'Imported Packs' : '导入风格', - summaryImportedHint: isEnglish ? 'Installed from ZIP and fully portable.' : '来自 ZIP 包,可启用、编辑、导出和删除。', - summaryEnabled: isEnglish ? 'In Rotation' : '已加入轮换', - summaryCurrent: (name: string) => (isEnglish ? `Current: ${name}` : `当前启用:${name}`), summaryCurrentEmpty: isEnglish ? 'No pack selected yet' : '还没有选中风格包', editorTitle: isEnglish ? 'Edit Pack' : '编辑风格', editorDesc: isEnglish @@ -136,7 +187,6 @@ export function Style() { metaTitle: isEnglish ? 'Installation Info' : '安装信息', metaSource: isEnglish ? 'Source' : '来源', metaBaseMode: isEnglish ? 'Base Mode' : '基础模式', - metaStatus: isEnglish ? 'Rotation' : '轮换状态', metaUpdatedAt: isEnglish ? 'Updated' : '更新时间', fieldName: isEnglish ? 'Name' : '名称', fieldAuthor: isEnglish ? 'Author' : '作者', @@ -194,18 +244,46 @@ export function Style() { }; const [packs, setPacks] = useState([]); + const [selectedId, setSelectedId] = useState(null); const [draft, setDraft] = useState(null); const [busy, setBusy] = useState('loading'); - const [error, setError] = useState(null); - const [notice, setNotice] = useState(null); + const [saveState, setSaveState] = useState('idle'); + const [saveMessage, setSaveMessage] = useState(''); + const statusTimer = useRef(null); const [editorOpen, setEditorOpen] = useState(false); + const [editorClosing, setEditorClosing] = useState(false); + const editorCloseTimer = useRef(null); const [runtimePreview, setRuntimePreview] = useState(null); const [runtimePreviewError, setRuntimePreviewError] = useState(null); + const [marketplaceOpen, setMarketplaceOpen] = useState(false); + + useEffect(() => () => { + if (statusTimer.current !== null) window.clearTimeout(statusTimer.current); + if (editorCloseTimer.current !== null) window.clearTimeout(editorCloseTimer.current); + }, []); + + const showSaveStatus = (state: SaveToastState, message: string, temporary = false) => { + if (statusTimer.current !== null) { + window.clearTimeout(statusTimer.current); + statusTimer.current = null; + } + setSaveState(state); + setSaveMessage(message); + // 自动消失:success/info 默认 ~1.6s;failure 给用户更长时间读再消失(6s)。 + // 「saving」过程态不自动消失(等真正终态覆盖)。 + if (temporary || state === 'failed') { + const delay = state === 'failed' ? 6000 : 1600; + statusTimer.current = window.setTimeout(() => { + setSaveState('idle'); + setSaveMessage(''); + statusTimer.current = null; + }, delay); + } + }; const loadPacks = async (preferredId?: string | null) => { setBusy('loading'); - setError(null); try { const next = await listStylePacks(); setPacks(next); @@ -216,7 +294,7 @@ export function Style() { null; setSelectedId(nextSelectedId); } catch (loadError) { - setError(copy.loadFailed(String(loadError))); + showSaveStatus('failed', copy.loadFailed(String(loadError))); } finally { setBusy(null); } @@ -248,6 +326,12 @@ export function Style() { const selectedPack = packs.find(pack => pack.id === selectedId) ?? null; const activePack = packs.find(pack => pack.active) ?? null; + const rawPack = packs.find(pack => pack.id === BUILTIN_RAW_ID) ?? null; + const otherBuiltinPacks = packs + .filter(pack => pack.kind === 'builtin' && pack.id !== BUILTIN_RAW_ID) + .sort((a, b) => BUILTIN_BODY_ORDER.indexOf(a.id) - BUILTIN_BODY_ORDER.indexOf(b.id)); + const importedPacks = packs.filter(pack => pack.kind === 'imported'); + const bodyPacks = [...otherBuiltinPacks, ...importedPacks]; const builtinCount = packs.filter(pack => pack.kind === 'builtin').length; const importedCount = packs.filter(pack => pack.kind === 'imported').length; const enabledCount = packs.filter(pack => pack.enabled).length; @@ -284,8 +368,6 @@ export function Style() { const focusPack = (packId: string) => { setSelectedId(packId); - setNotice(null); - setError(null); }; const discardDraftChanges = () => { @@ -294,14 +376,26 @@ export function Style() { } }; + const startEditorClose = () => { + if (editorClosing) return; + setEditorClosing(true); + if (editorCloseTimer.current !== null) window.clearTimeout(editorCloseTimer.current); + editorCloseTimer.current = window.setTimeout(() => { + setEditorOpen(false); + setEditorClosing(false); + editorCloseTimer.current = null; + }, 200); + }; + const closeEditor = () => { + if (editorClosing) return; if (dirty) { if (!window.confirm(copy.discardCloseConfirm)) { return; } discardDraftChanges(); } - setEditorOpen(false); + startEditorClose(); }; const openEditorForPack = (pack: StylePack) => { @@ -310,14 +404,17 @@ export function Style() { return; } } + if (editorCloseTimer.current !== null) { + window.clearTimeout(editorCloseTimer.current); + editorCloseTimer.current = null; + } + setEditorClosing(false); focusPack(pack.id); setEditorOpen(true); }; useEffect(() => { if (!editorOpen) return; - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); @@ -326,7 +423,6 @@ export function Style() { }; window.addEventListener('keydown', handleKeyDown); return () => { - document.body.style.overflow = previousOverflow; window.removeEventListener('keydown', handleKeyDown); }; }, [editorOpen, dirty, selectedPack, draft]); @@ -359,23 +455,19 @@ export function Style() { }); }; - const showSuccess = (message: string) => { - setNotice(message); - setError(null); - }; - const handleSave = async () => { if (!draft) return; setBusy('saving'); + showSaveStatus('saving', t('common.saving')); try { const saved = await saveStylePack({ ...draft, tags: draft.tags.filter(Boolean), }); - showSuccess(copy.saveSuccess); + showSaveStatus('saved', copy.saveSuccess, true); await loadPacks(saved.id); } catch (saveError) { - setError(copy.saveFailed(String(saveError))); + showSaveStatus('failed', copy.saveFailed(String(saveError))); } finally { setBusy(null); } @@ -385,23 +477,10 @@ export function Style() { setBusy('activating'); try { await setActiveStylePack(pack.id); - showSuccess(copy.activateSuccess(pack.name)); + showSaveStatus('saved', copy.activateSuccess(pack.name), true); await loadPacks(pack.id); } catch (activateError) { - setError(copy.activateFailed(String(activateError))); - } finally { - setBusy(null); - } - }; - - const handleToggleEnabled = async (pack: StylePack) => { - setBusy('toggling'); - try { - await setStylePackEnabled(pack.id, !pack.enabled); - showSuccess(pack.enabled ? copy.disableSuccess(pack.name) : copy.enableSuccess(pack.name)); - await loadPacks(pack.id); - } catch (toggleError) { - setError(copy.toggleFailed(String(toggleError))); + showSaveStatus('failed', copy.activateFailed(String(activateError))); } finally { setBusy(null); } @@ -412,28 +491,60 @@ export function Style() { setBusy('resetting'); try { await resetBuiltinStylePack(selectedPack.id); - showSuccess(copy.resetSuccess(selectedPack.name)); + showSaveStatus('saved', copy.resetSuccess(selectedPack.name), true); await loadPacks(selectedPack.id); } catch (resetError) { - setError(copy.resetFailed(String(resetError))); + showSaveStatus('failed', copy.resetFailed(String(resetError))); } finally { setBusy(null); } }; - const handleDeleteImported = async () => { - if (!selectedPack || selectedPack.kind !== 'imported') return; - if (!window.confirm(copy.deleteConfirm(selectedPack.name))) { + const handleDeleteImportedPack = async (pack: StylePack) => { + if (pack.kind !== 'imported') return; + if (!window.confirm(copy.deleteConfirm(pack.name))) { return; } setBusy('deleting'); try { - await deleteStylePack(selectedPack.id); - showSuccess(copy.deleteSuccess(selectedPack.name)); - setEditorOpen(false); + await deleteStylePack(pack.id); + showSaveStatus('saved', copy.deleteSuccess(pack.name), true); + if (editorOpen && selectedId === pack.id) { + startEditorClose(); + } await loadPacks(); } catch (deleteError) { - setError(copy.deleteFailed(String(deleteError))); + showSaveStatus('failed', copy.deleteFailed(String(deleteError))); + } finally { + setBusy(null); + } + }; + + const handleDeleteImported = async () => { + if (!selectedPack || selectedPack.kind !== 'imported') return; + await handleDeleteImportedPack(selectedPack); + }; + + const handleCreateFromTemplate = async () => { + setBusy('creating'); + try { + const template: StylePack = { + ...NEW_PACK_TEMPLATE_BASE, + id: '', + }; + const created = await createStylePackFromTemplate(template); + showSaveStatus('saved', copy.createSuccess, true); + await loadPacks(created.id); + // Re-fetch list, then open the editor on the new pack + if (editorCloseTimer.current !== null) { + window.clearTimeout(editorCloseTimer.current); + editorCloseTimer.current = null; + } + setEditorClosing(false); + setSelectedId(created.id); + setEditorOpen(true); + } catch (createError) { + showSaveStatus('failed', copy.createFailed(String(createError))); } finally { setBusy(null); } @@ -458,10 +569,36 @@ export function Style() { return; } const imported = await importStylePackFromZip(zipPath); - showSuccess(copy.importSuccess(imported.name)); + showSaveStatus('saved', copy.importSuccess(imported.name), true); await loadPacks(imported.id); } catch (importError) { - setError(copy.importFailed(String(importError))); + showSaveStatus('failed', copy.importFailed(String(importError))); + } finally { + setBusy(null); + } + }; + + const handlePublishToMarketplace = async (pack = selectedPack) => { + if (!pack) return; + // 内置 pack 是只读模板,不能直接上传 —— 改它得先「在官方上面做一份」克隆出 imported。 + if (pack.kind === 'builtin') { + showSaveStatus('failed', isEnglish + ? 'Built-in packs cannot be published. Clone first via edit.' + : '内置风格包不能直接发布,请先编辑生成一份导入版。'); + return; + } + setBusy('exporting'); + try { + // 若编辑器有未保存改动且就是当前要发布的 pack,先自动保存再发布。 + if (editorOpen && dirty && draft && selectedPack && pack.id === selectedPack.id) { + const saved = await saveStylePack({ ...draft, tags: draft.tags.filter(Boolean) }); + await loadPacks(saved.id); + pack = saved; + } + await uploadMarketplacePack(pack.id); + showSaveStatus('saved', copy.publishSuccess, true); + } catch (publishError) { + showSaveStatus('failed', copy.publishFailed(String(publishError))); } finally { setBusy(null); } @@ -470,8 +607,7 @@ export function Style() { const handleExportZip = async (pack = selectedPack) => { if (!pack) return; if (editorOpen && dirty && selectedPack && pack.id === selectedPack.id) { - setError(copy.exportDirtyFirst); - setNotice(null); + showSaveStatus('failed', copy.exportDirtyFirst); return; } setBusy('exporting'); @@ -492,9 +628,9 @@ export function Style() { return; } const savedPath = await exportStylePackToZip(pack.id, targetPath); - showSuccess(copy.exportSuccess(savedPath)); + showSaveStatus('saved', copy.exportSuccess(savedPath), true); } catch (exportError) { - setError(copy.exportFailed(String(exportError))); + showSaveStatus('failed', copy.exportFailed(String(exportError))); } finally { setBusy(null); } @@ -506,8 +642,33 @@ export function Style() { kicker={copy.kicker} title={copy.title} desc={copy.desc} + titleRight={( + // 风格市场暂时未开放(云端服务尚未上线)—— 入口保留可见但灰色 + 点击 toast 提示。 + // 真正功能(Marketplace 组件 / IPC / backend client)保留,等云端就绪可一行恢复 onClick。 + + )} right={( -
+
void loadPacks(selectedId)} disabled={busy === 'loading'}> {t('common.refresh')} @@ -518,66 +679,67 @@ export function Style() { )} /> -
- -
- {copy.summaryBuiltin} -
-
{builtinCount}
-
{copy.summaryBuiltinHint}
-
- -
- {copy.summaryImported} -
-
{importedCount}
-
{copy.summaryImportedHint}
-
- -
- {copy.summaryEnabled} -
-
{enabledCount}
-
- {activePack ? copy.summaryCurrent(activePack.name) : copy.summaryCurrentEmpty} -
-
-
+ {/* 视口锚定(position: fixed)—— 编辑器展开后滚动到下方时仍可见。 + 放在 bottom-right 避免压在「导入 ZIP」按钮上挡文字。 */} + - {(notice || error) && ( -
{ + setMarketplaceOpen(false); + // 用户可能在 modal 内安装过远端 pack;关闭后刷新本地列表,避免新装的看不到。 + void loadPacks(); }} - > - {error ?? notice} -
+ /> )} - -
-
-
-
{copy.listTitle}
-
{copy.listDesc}
-
-
+ +
+
+
+
+
{copy.listTitle}
+
{copy.listDesc}
+
+ {rawPack && ( + + )} +
{copy.listCount(packs.length)}
-
-
-
- {packs.map(pack => { - const selected = pack.id === selectedId; +
+
+ {bodyPacks.map(pack => { + const isBuiltin = pack.kind === 'builtin'; return (
-
{pack.name}
- - {pack.kind === 'builtin' ? copy.builtin : copy.imported} - -
- {pack.active ? ( - {copy.active} - ) : !pack.enabled ? ( - {copy.disabled} - ) : null} +
+ {pack.name}
+ + {isBuiltin ? copy.builtin : copy.imported} + + {pack.originAuthorLogin + && pack.originAuthorLogin !== (marketplacePrefs?.marketplaceDevLogin ?? '').trim() && ( + + 衍生自 @{pack.originAuthorLogin} + + )} + {pack.active && {copy.active}}
-
+ {isBuiltin ? (
- +
- openEditorForPack(pack)} + ) : ( +
+ + + )}
@@ -674,24 +851,61 @@ export function Style() { void handleToggleEnabled(pack)} + icon="archive" + disabled={busy === 'exporting'} + onClick={() => void handleExportZip(pack)} > - {pack.enabled ? copy.disable : copy.enable} + {copy.exportShort} void handleExportZip(pack)} + icon="expand" + disabled={isBuiltin} + onClick={() => openEditorForPack(pack)} > - {copy.exportShort} + {copy.edit}
); })} +
@@ -704,10 +918,13 @@ export function Style() { style={{ position: 'fixed', inset: 0, - background: 'rgba(15,23,42,0.24)', - backdropFilter: 'blur(6px)', - WebkitBackdropFilter: 'blur(6px)', + background: 'rgba(15,17,22,0.32)', + backdropFilter: 'blur(8px) saturate(140%)', + WebkitBackdropFilter: 'blur(8px) saturate(140%)', zIndex: 40, + animation: editorClosing + ? 'ol-modal-backdrop-out 0.2s var(--ol-motion-soft) forwards' + : 'ol-modal-backdrop-in 0.2s var(--ol-motion-soft) both', }} />
- +
@@ -779,13 +1000,24 @@ export function Style() { void handleExportZip()} disabled={busy === 'exporting'}> {copy.exportZip} - void handleToggleEnabled(draft)} + - {draft.enabled ? copy.disable : copy.enable} - + void handlePublishToMarketplace()} + disabled={!canPublish || draft?.kind === 'builtin' || busy === 'exporting'} + > + {copy.publishMarketplace} + + -
@@ -997,9 +1228,24 @@ export function Style() { style={{ ...inputStyle, fontWeight: 600 }} placeholder={copy.exampleTitlePlaceholder(index + 1)} /> - removeExample(index)}> - {t('common.delete')} - +
diff --git a/openless-all/app/src/pages/_atoms.tsx b/openless-all/app/src/pages/_atoms.tsx index 95480f7b..7f2de1fc 100644 --- a/openless-all/app/src/pages/_atoms.tsx +++ b/openless-all/app/src/pages/_atoms.tsx @@ -10,16 +10,20 @@ interface PageHeaderProps { title: string; desc?: string; right?: ReactNode; + titleRight?: ReactNode; } -export function PageHeader({ kicker, title, desc, right }: PageHeaderProps) { +export function PageHeader({ kicker, title, desc, right, titleRight }: PageHeaderProps) { return (
{kicker && (
{kicker}
)} -

{title}

+
+

{title}

+ {titleRight} +
{desc &&

{desc}

}
{right} diff --git a/openless-all/app/src/pages/settings/AdvancedSection.tsx b/openless-all/app/src/pages/settings/AdvancedSection.tsx index ce999819..54955307 100644 --- a/openless-all/app/src/pages/settings/AdvancedSection.tsx +++ b/openless-all/app/src/pages/settings/AdvancedSection.tsx @@ -114,7 +114,7 @@ export function AdvancedSection() { 时延显著降低,但有几个限制(不满足时自动回落原一次性插入路径): - macOS:CGEvent Unicode + 临时切到 ABC 输入源(CJK / 日文 IME 拦截兜底) - Windows:SendInput Unicode,绕过 TSF / IME,不需要切输入法 - - Linux(实验):enigo XTest;Wayland compositor 拒绝 libei 时失败回落 + - Linux(实验):X11 走 enigo + XTest;Wayland 下禁用流式输入并回落剪贴板 - 仅 OpenAI-compatible provider 实装;Gemini / Codex 透明降级 - 密码框 / 1Password / SSH prompt 等 Secure Input 框拒绝合成按键 → 失败回落 每个平台用各自的 hint key,互相不显示对方平台的细节。 */} diff --git a/openless-all/app/src/styles/global.css b/openless-all/app/src/styles/global.css index 8cc79c58..a30aef3c 100644 --- a/openless-all/app/src/styles/global.css +++ b/openless-all/app/src/styles/global.css @@ -76,3 +76,35 @@ a { color: inherit; text-decoration: none; } filter: blur(0); } } + +@keyframes ol-modal-backdrop-in { + from { opacity: 0; backdrop-filter: blur(0); -webkit-backdrop-filter: blur(0); } + to { opacity: 1; backdrop-filter: blur(8px) saturate(140%); -webkit-backdrop-filter: blur(8px) saturate(140%); } +} + +@keyframes ol-modal-backdrop-out { + from { opacity: 1; backdrop-filter: blur(8px) saturate(140%); -webkit-backdrop-filter: blur(8px) saturate(140%); } + to { opacity: 0; backdrop-filter: blur(0); -webkit-backdrop-filter: blur(0); } +} + +@keyframes ol-modal-drawer-in { + from { + opacity: 0; + transform: translate3d(12px, 0, 0) scale(0.985); + } + to { + opacity: 1; + transform: translate3d(0, 0, 0) scale(1); + } +} + +@keyframes ol-modal-drawer-out { + from { + opacity: 1; + transform: translate3d(0, 0, 0) scale(1); + } + to { + opacity: 0; + transform: translate3d(12px, 0, 0) scale(0.985); + } +}