chore: codebase cleanup + behavior-preserving refactor (#693)

Audit-driven cleanup: 6 issue fixes (#689/#686/#680/#691/#678/#612), Aura skin removal (visually-neutral), dead-code cleanup (98→87 warnings), behavior-preserving modularization (coordinator.rs 6961→3083 + LocalAsr/ + polish/ submodules), and RELEASING.md (admin-only release policy). Gates: cargo test --lib 446 passed, tsc, all-platform build + contract checks green. Supersedes #685/#687/#690/#692/#682/#681.
This commit is contained in:
TRIP authored and GitHub committed 2026-06-16 23:22:21 +08:00
1 parent 4f73dbd5e4
commit 0eb69fc0f4
39 files changed
+5563 -6514

No files matched your search

+59
View File
@@ -0,0 +1,59 @@
# Releasing OpenLess
This document defines who may cut releases and how. It is policy, not a how-to for
day-to-day development.
## Authority: admins only
**Only repository administrators may create version tags and publish releases.**
- Only an admin may create a release tag (`v*-tauri`) or publish a GitHub Release.
- Contributors (including AI agents) **must not** create release tags, publish
releases, or trigger release automation. If a release is needed, **request an admin
to cut it** — open an issue or ping a maintainer with the target version and the
channel (Beta or Stable).
- Release automation (CI workflows that build, sign, and publish artifacts, and the
auto-updater feed) must be **admin-triggered only**. Tag-triggered pipelines are
considered admin-triggered because only admins may push the triggering tag.
## Channels and tags
Two channels; the branch name equals the channel name:
- **`beta`** — default development branch and the Beta channel.
- **`main`** — the Stable channel (正式版). Always releasable; only maintainers merge
`beta → main`.
Release tags (created by an admin only):
- **Stable release:** push tag `v<version>-tauri`.
- **Beta release:** push tag `v<version>-beta-tauri` (published as a GitHub
pre-release; never auto-updates Stable users).
## Version-sync gate
A release fails CI unless **five** files carry the same version. Bump them together
with `1-app/scripts/bump-version.sh <X.Y.Z>`:
- `openless-all/app/package.json`
- `openless-all/app/package-lock.json` (root and nested `packages.""`)
- `openless-all/app/src-tauri/tauri.conf.json`
- `openless-all/app/src-tauri/Cargo.toml`
- `openless-all/app/src-tauri/Cargo.lock` (the `name = "openless"` block)
The script takes a plain `X.Y.Z`; for a `-beta` suffix, edit the files by hand.
## Pre-release checklist (for the admin cutting the release)
1. Branch is the intended channel (`beta` for Beta, `main` for Stable).
2. All five version files match (version-sync gate green).
3. CI is green on the commit being tagged.
4. Then, and only then, push the release tag.
## Process summary
1. Land work on `beta` via PRs (open PRs against `beta`, never `main`).
2. For a Stable release, a maintainer merges `beta → main`.
3. An **admin** bumps the version (five-file sync), verifies CI is green, and pushes
the release tag — which is the only thing that triggers the publish/auto-update
pipeline.
@@ -1,541 +0,0 @@
# OpenLess Aura Skin Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Apply an Aura-inspired visual skin to OpenLess by upgrading the shell, glass treatment, radii, typography, and overview surfaces while preserving the current information architecture and page density.
**Architecture:** The implementation stays shallow and visual-only. It starts by adding a contract test that defines the new skin hooks, then updates global tokens and chrome, then reskins the main shell and settings modal, and finally aligns shared atoms and the overview page to the new surface system.
**Tech Stack:** React 18, TypeScript, Vite 5, inline-style component patterns, CSS token files, Node-based contract checks
---
## File Structure
- Modify: `openless-all/app/package.json`
Purpose: register a repeatable Aura skin contract check command.
- Create: `openless-all/app/scripts/aura-skin-contract.test.mjs`
Purpose: assert the new skin tokens and shell hooks exist before visual work is considered complete.
- Modify: `openless-all/app/src/styles/tokens.css`
Purpose: replace the current light-glass token ladder with the new Aura-inspired radii, surfaces, shadows, and typography.
- Modify: `openless-all/app/src/styles/global.css`
Purpose: add the static layered background and shared shell/panel helper classes without introducing animation.
- Modify: `openless-all/app/src/components/FloatingShell.tsx`
Purpose: reskin the app shell, sidebar, version zone, and main console wrapper.
- Modify: `openless-all/app/src/components/SettingsModal.tsx`
Purpose: align the settings modal shell and left rail with the new skin system.
- Modify: `openless-all/app/src/pages/_atoms.tsx`
Purpose: make cards, buttons, pills, and page headers inherit the new radius and surface vocabulary.
- Modify: `openless-all/app/src/pages/Overview.tsx`
Purpose: make the overview page visibly match the new shell without changing its data structure.
### Task 1: Add Aura Skin Contract Check
**Files:**
- Create: `openless-all/app/scripts/aura-skin-contract.test.mjs`
- Modify: `openless-all/app/package.json`
- Test: `openless-all/app/scripts/aura-skin-contract.test.mjs`
- [ ] **Step 1: Write the failing test**
Create `openless-all/app/scripts/aura-skin-contract.test.mjs`:
```js
import { readFile } from 'node:fs/promises';
import assert from 'node:assert/strict';
const root = new URL('../', import.meta.url);
async function read(relPath) {
return readFile(new URL(relPath, root), 'utf8');
}
const [tokens, globalCss, shell, settingsModal, overview] = await Promise.all([
read('src/styles/tokens.css'),
read('src/styles/global.css'),
read('src/components/FloatingShell.tsx'),
read('src/components/SettingsModal.tsx'),
read('src/pages/Overview.tsx'),
]);
assert.match(tokens, /--ol-shell-radius:/, 'tokens.css must define --ol-shell-radius');
assert.match(tokens, /--ol-panel-radius:/, 'tokens.css must define --ol-panel-radius');
assert.match(tokens, /--ol-aura-shadow:/, 'tokens.css must define --ol-aura-shadow');
assert.match(tokens, /--ol-font-display:/, 'tokens.css must define --ol-font-display');
assert.match(globalCss, /\.ol-app-shell-bg\b/, 'global.css must expose .ol-app-shell-bg');
assert.match(globalCss, /\.ol-aura-panel\b/, 'global.css must expose .ol-aura-panel');
assert.doesNotMatch(globalCss, /@keyframes ol-aura-halo/, 'global.css must not add an animated halo');
assert.match(shell, /ol-app-shell-bg/, 'FloatingShell must use the app shell background class');
assert.match(shell, /ol-aura-sidebar/, 'FloatingShell must expose an Aura sidebar hook');
assert.match(shell, /ol-aura-panel/, 'FloatingShell must expose an Aura panel hook');
assert.match(settingsModal, /ol-aura-settings/, 'SettingsModal must expose an Aura settings wrapper');
assert.match(overview, /ol-overview-hero/, 'Overview must expose a high-visibility overview surface hook');
console.log('Aura skin contract OK');
```
- [ ] **Step 2: Register the check in `package.json`**
Modify `openless-all/app/package.json`:
```json
{
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"check:aura-skin": "node scripts/aura-skin-contract.test.mjs",
"check:macos-capsule-spaces": "node scripts/macos-capsule-spaces-contract.test.mjs",
"check:hotkey-injection": "node scripts/check-hotkey-injection.mjs"
}
}
```
- [ ] **Step 3: Run the test to verify it fails**
Run:
```bash
npm run check:aura-skin
```
Expected:
```text
AssertionError [ERR_ASSERTION]: tokens.css must define --ol-shell-radius
```
- [ ] **Step 4: Commit the failing test harness**
```bash
git add openless-all/app/package.json openless-all/app/scripts/aura-skin-contract.test.mjs
git commit -m "test: add aura skin contract"
```
### Task 2: Replace Global Tokens and App Chrome
**Files:**
- Modify: `openless-all/app/src/styles/tokens.css`
- Modify: `openless-all/app/src/styles/global.css`
- Test: `openless-all/app/scripts/aura-skin-contract.test.mjs`
- [ ] **Step 1: Update the token ladder**
Replace the current token center in `openless-all/app/src/styles/tokens.css` with the Aura skin ladder:
```css
:root {
--ol-bg-base: #f3f4f6;
--ol-bg-elevated: #fbfbfc;
--ol-surface: rgba(255, 255, 255, 0.72);
--ol-surface-2: rgba(255, 255, 255, 0.88);
--ol-surface-solid: #ffffff;
--ol-line: rgba(15, 23, 42, 0.08);
--ol-line-strong: rgba(15, 23, 42, 0.14);
--ol-line-soft: rgba(255, 255, 255, 0.55);
--ol-ink: #10131a;
--ol-ink-2: #222938;
--ol-ink-3: rgba(16, 19, 26, 0.64);
--ol-ink-4: rgba(16, 19, 26, 0.42);
--ol-ink-5: rgba(16, 19, 26, 0.24);
--ol-blue: #2f6df6;
--ol-blue-hover: #2458c8;
--ol-blue-soft: rgba(47, 109, 246, 0.10);
--ol-blue-ring: rgba(47, 109, 246, 0.22);
--ol-shell-radius: 32px;
--ol-panel-radius: 28px;
--ol-card-radius: 22px;
--ol-pill-radius: 999px;
--ol-aura-shadow: 0 24px 80px -32px rgba(15, 23, 42, 0.24), 0 12px 36px -18px rgba(15, 23, 42, 0.12);
--ol-aura-shadow-soft: 0 10px 30px -18px rgba(15, 23, 42, 0.16), 0 0 0 0.5px rgba(255, 255, 255, 0.55) inset;
--ol-font-display: "Aptos", "Segoe UI Variable Display", "PingFang SC", "Microsoft YaHei", sans-serif;
--ol-font-sans: "Aptos", "Segoe UI Variable Text", "PingFang SC", "Microsoft YaHei", sans-serif;
--ol-font-mono: "JetBrains Mono", "SF Mono", "Cascadia Code", Consolas, monospace;
}
```
- [ ] **Step 2: Add static Aura shell helpers**
Append shared helpers to `openless-all/app/src/styles/global.css`:
```css
body {
background:
radial-gradient(circle at 18% 12%, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0) 34%),
radial-gradient(circle at 86% 18%, rgba(47, 109, 246, 0.08), rgba(47, 109, 246, 0) 30%),
linear-gradient(180deg, #f6f7fa 0%, #eef1f6 100%);
color: var(--ol-ink);
}
.ol-app-shell-bg {
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0) 38%),
linear-gradient(180deg, rgba(255, 255, 255, 0.68), rgba(245, 247, 251, 0.58));
}
.ol-aura-panel {
background: var(--ol-surface);
backdrop-filter: blur(24px) saturate(150%);
-webkit-backdrop-filter: blur(24px) saturate(150%);
border: 1px solid rgba(255, 255, 255, 0.58);
box-shadow: var(--ol-aura-shadow);
}
.ol-aura-card {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(252, 252, 253, 0.82));
border: 1px solid rgba(255, 255, 255, 0.74);
box-shadow: var(--ol-aura-shadow-soft);
}
```
- [ ] **Step 3: Run the contract check**
Run:
```bash
npm run check:aura-skin
```
Expected:
```text
AssertionError [ERR_ASSERTION]: FloatingShell must use the app shell background class
```
- [ ] **Step 4: Commit the token/chrome layer**
```bash
git add openless-all/app/src/styles/tokens.css openless-all/app/src/styles/global.css
git commit -m "feat: add aura skin tokens and chrome"
```
### Task 3: Reskin the Shell and Settings Modal
**Files:**
- Modify: `openless-all/app/src/components/FloatingShell.tsx`
- Modify: `openless-all/app/src/components/SettingsModal.tsx`
- Test: `openless-all/app/scripts/aura-skin-contract.test.mjs`
- [ ] **Step 1: Apply Aura shell hooks in `FloatingShell.tsx`**
Update the shell wrappers in `openless-all/app/src/components/FloatingShell.tsx`:
```tsx
<div
className="ol-app-shell-bg"
style={{
flex: 1,
position: 'relative',
display: 'flex',
flexDirection: 'column',
minHeight: 0,
paddingTop: os === 'mac' ? 28 : 0,
}}
>
```
Update the sidebar and main panel wrappers:
```tsx
<aside
className="ol-aura-sidebar"
style={{
width: 196,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
padding: '14px 12px 14px',
}}
>
```
```tsx
<main
className="ol-console-main ol-aura-panel"
style={{
flex: 1,
minWidth: 0,
overflow: 'hidden',
borderRadius: 'var(--ol-panel-radius)',
display: 'flex',
flexDirection: 'column',
}}
>
```
- [ ] **Step 2: Rework the sidebar visual details**
Update the brand zone and footer chip in `openless-all/app/src/components/FloatingShell.tsx`:
```tsx
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 10px 16px' }}>
<img
src="AppIcon.png"
alt="OpenLess"
style={{
width: 26,
height: 26,
borderRadius: 8,
boxShadow: '0 10px 24px -16px rgba(47, 109, 246, 0.45)',
}}
/>
<div>
<div style={{ fontSize: 14, fontWeight: 600, fontFamily: 'var(--ol-font-display)' }}>OpenLess</div>
<div style={{ fontSize: 10.5, color: 'var(--ol-ink-4)', fontFamily: 'var(--ol-font-mono)', letterSpacing: '.08em' }}>
VOICE CONSOLE
</div>
</div>
</div>
```
- [ ] **Step 3: Align the settings modal shell**
Update `openless-all/app/src/components/SettingsModal.tsx`:
```tsx
<div
className="ol-aura-settings"
onClick={(e) => e.stopPropagation()}
style={{
width: '100%',
maxWidth: 920,
height: '100%',
maxHeight: 620,
background: 'var(--ol-surface)',
borderRadius: 'var(--ol-shell-radius)',
border: '1px solid rgba(255,255,255,0.62)',
boxShadow: 'var(--ol-aura-shadow)',
display: 'flex',
overflow: 'hidden',
position: 'relative',
}}
>
```
Reskin the left rail:
```tsx
<aside
style={{
width: 214,
flexShrink: 0,
background: 'linear-gradient(180deg, rgba(255,255,255,0.58), rgba(246,248,252,0.72))',
borderRight: '1px solid rgba(255,255,255,0.52)',
padding: '20px 14px',
display: 'flex',
flexDirection: 'column',
gap: 16,
}}
>
```
- [ ] **Step 4: Run the contract check**
Run:
```bash
npm run check:aura-skin
```
Expected:
```text
AssertionError [ERR_ASSERTION]: Overview must expose a high-visibility overview surface hook
```
- [ ] **Step 5: Commit the shell layer**
```bash
git add openless-all/app/src/components/FloatingShell.tsx openless-all/app/src/components/SettingsModal.tsx
git commit -m "feat: reskin aura shell and settings modal"
```
### Task 4: Align Shared Atoms and the Overview Page
**Files:**
- Modify: `openless-all/app/src/pages/_atoms.tsx`
- Modify: `openless-all/app/src/pages/Overview.tsx`
- Test: `openless-all/app/scripts/aura-skin-contract.test.mjs`
- Test: `openless-all/app/package.json`
- [ ] **Step 1: Upgrade the shared atoms**
Modify `openless-all/app/src/pages/_atoms.tsx`:
```tsx
export function PageHeader({ kicker, title, desc, right, titleRight }: PageHeaderProps) {
return (
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 24, marginBottom: 28 }}>
<div style={{ minWidth: 0 }}>
{kicker && (
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '.14em', textTransform: 'uppercase', color: 'var(--ol-ink-4)', marginBottom: 10, fontFamily: 'var(--ol-font-mono)' }}>
{kicker}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<h1 style={{ margin: 0, fontSize: 34, fontWeight: 600, letterSpacing: '-0.04em', color: 'var(--ol-ink)', fontFamily: 'var(--ol-font-display)' }}>
{title}
</h1>
{titleRight}
</div>
{desc && <p style={{ margin: '10px 0 0', fontSize: 13.5, color: 'var(--ol-ink-3)', maxWidth: 680, lineHeight: 1.6 }}>{desc}</p>}
</div>
{right}
</div>
);
}
```
Update `Card` to use the new helper class and radius:
```tsx
export function Card({ children, style, padding = 18, glassy = false, className }: CardProps) {
return (
<div
className={['ol-aura-card', className].filter(Boolean).join(' ')}
style={{
background: glassy ? 'var(--ol-surface)' : 'var(--ol-surface-solid)',
border: '1px solid rgba(255,255,255,0.74)',
borderRadius: 'var(--ol-card-radius)',
padding,
boxShadow: 'var(--ol-aura-shadow-soft)',
...style,
}}
>
{children}
</div>
);
}
```
- [ ] **Step 2: Make the overview page the first complete showcase**
Modify `openless-all/app/src/pages/Overview.tsx`:
```tsx
<PageHeader
kicker="AURA OVERVIEW"
title={t('overview.title')}
desc={t('overview.metricTotalTrend')}
/>
```
Add the overview showcase hooks and higher-visibility providers row:
```tsx
<div
className="ol-overview-hero"
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 14,
marginBottom: 20,
}}
>
```
Elevate the bottom containers:
```tsx
<Card className="ol-overview-hero" padding={20} style={{ display: 'flex', flexDirection: 'column', minHeight: 0 }}>
```
- [ ] **Step 3: Run the Aura contract check**
Run:
```bash
npm run check:aura-skin
```
Expected:
```text
Aura skin contract OK
```
- [ ] **Step 4: Run the production build**
Run:
```bash
npm run build
```
Expected:
```text
vite v5.x building for production...
✓ built in
```
- [ ] **Step 5: Commit the overview alignment**
```bash
git add openless-all/app/src/pages/_atoms.tsx openless-all/app/src/pages/Overview.tsx
git commit -m "feat: apply aura styling to overview surfaces"
```
### Task 5: Final Verification and Manual Review
**Files:**
- Test: `openless-all/app/scripts/aura-skin-contract.test.mjs`
- Test: local dev preview at `http://127.0.0.1:1420`
- [ ] **Step 1: Start the dev server**
Run:
```bash
npm run dev -- --host 127.0.0.1
```
Expected:
```text
Local: http://127.0.0.1:1420/
```
- [ ] **Step 2: Manually verify the shell and overview**
Check these exact visual outcomes:
```text
1. No animated light halo or moving glow exists anywhere in the app shell.
2. The app background shows a static layered light field.
3. Sidebar, main panel, and settings modal all share the same Aura glass language.
4. Overview cards use larger radii and stronger hierarchy than the old skin.
5. The product still reads as light, calm, and easy to scan.
```
- [ ] **Step 3: Re-run automated verification**
Run:
```bash
npm run check:aura-skin && npm run build
```
Expected:
```text
Aura skin contract OK
...build succeeds...
```
- [ ] **Step 4: Final commit**
```bash
git add openless-all/app
git commit -m "feat: ship openless aura skin"
```
@@ -1,254 +0,0 @@
# OpenLess Aura Skin Design
## Goal
Refresh the OpenLess desktop UI by borrowing the visual language of the reference HTML at `iframe-export-1779865710530(1).html` while preserving OpenLess's current information architecture, page density, and reading comfort.
This is a skin redesign, not a product redesign.
## Design Intent
The target look should feel more premium, sculpted, and technical without becoming dark, noisy, or visually tiring.
We will reuse these reference qualities:
- larger radii and softer geometry
- frosted glass layering
- stronger typography hierarchy
- restrained monochrome palette with blue reserved for accents
- clearer separation between shell, panel, card, and utility surfaces
We will explicitly not reuse these reference qualities:
- animated halo or WebGL background
- dark-theme-first presentation
- login-page composition
- decorative technical labels that compete with product content
- large glow effects that reduce legibility
## Scope
This work changes visual skin only.
Included:
- app background treatment
- main shell and console framing
- sidebar navigation skin
- overview cards and list containers
- high-visibility buttons, chips, badges, and status treatments
- settings modal shell and section styling
- global tokens for radii, surface color, glass, shadows, and typography
Excluded:
- navigation structure
- page IA and feature grouping
- product logic, IPC, or state flow
- major layout rewrites inside functional pages
- dark mode implementation
- animations beyond subtle existing transitions
## Recommended Approach
Use a light-theme skin migration that concentrates changes in design tokens, the app shell, and the most visible shared surfaces.
This approach is preferred because it:
- gives the fastest visible transformation
- keeps implementation risk low
- preserves the current UX model
- creates a reusable foundation for later page-by-page polish
## Visual Direction
### Background
Replace the current mostly flat neutral app backdrop with a quiet layered field:
- soft warm-gray to cool-gray base
- one or two static radial highlights for depth
- no motion, glow sweep, halo, or particle treatment
The background should support the interface rather than become the interface.
### Shell
The top-level OpenLess window should read as a frosted control console:
- larger outer radii than today
- stronger edge softness
- slightly deeper shadow stack
- subtle translucent panel feel
- clearer distinction between sidebar zone and content zone while keeping them visually related
The shell should feel continuous and premium, not like separate floating white boxes.
### Surfaces
Introduce a more intentional surface ladder:
1. app backdrop
2. shell glass
3. main console panel
4. elevated cards and grouped containers
5. utility pills, chips, and compact controls
Each level should differ through opacity, blur, border contrast, and shadow depth rather than through aggressive color changes.
### Typography
Adopt the reference's typographic mood without sacrificing Chinese readability.
- primary UI font: modern sans with better personality than the current system-default feel
- mono font: reserved for version labels, technical badges, and small metadata
- page titles: larger, more deliberate, more architectural
- secondary labels: quieter and cleaner
If external web fonts are not already part of the app, prefer safe local or bundled-friendly fallbacks rather than adding fragile runtime font dependencies.
### Color Hierarchy
Keep OpenLess light and readable.
- main palette remains white, off-white, graphite, and soft gray
- blue remains the only expressive accent
- blue is used for active states, focus, selected indicators, and important actions
- avoid broad saturated areas
- avoid neon glow treatments
## Component Strategy
### Sidebar Navigation
Keep the same navigation items and ordering, but restyle them to match the new shell:
- calmer inactive state
- more tactile hover state
- stronger active pill treatment
- cleaner icon and label alignment
- better-integrated version and beta chip zone
The sidebar should feel like part of the same industrial-glass instrument rather than a separate admin rail.
### Overview Page
Do not reorganize the overview information model.
Instead:
- give provider cards more presence
- unify stats cards into the same radius and surface system
- style charts and recent-record containers as elevated glass/solid hybrids
- improve whitespace rhythm and title hierarchy
### Buttons, Chips, and Status
Unify small UI affordances into one design language:
- pill-like compact chips
- more sculpted primary buttons
- restrained secondary buttons
- status accents that rely on color and contrast, not glow
### Settings Modal
Retain the current settings structure and content, but reskin:
- modal container
- section tabs or navigation surfaces
- row grouping
- toggles, chips, and button styles
- footer actions
The modal should feel like it belongs to the same skin system as the main shell.
## Technical Plan
Apply the redesign through three layers.
### 1. Global Tokens
Update `src/styles/tokens.css` to define:
- revised neutral palette
- revised accent ladder
- larger radius system
- updated shadow tokens
- shell and card translucency tokens
- font stack updates
### 2. Global Chrome
Update `src/styles/global.css` and closely related shell-level styling to define:
- background gradients
- shell glass helpers
- optional subtle grain
- scrollbar polish
- global focus and interaction refinement
### 3. High-Visibility Containers
Update the shell and visible page wrappers first:
- `src/components/FloatingShell.tsx`
- settings modal shell
- overview high-visibility containers
- shared visual wrappers where existing pages already inherit common surfaces
This keeps the redesign cohesive while avoiding a broad page-by-page rewrite.
## Risks and Guardrails
### Risk: Readability Regression
Glass and translucency can reduce legibility, especially with Chinese text.
Guardrail:
- keep text surfaces more opaque than the reference
- use blur as support, not as the primary effect
- preserve strong text contrast
### Risk: Incomplete Visual Consistency
If only the shell changes, some inner surfaces may look older than the new frame.
Guardrail:
- prioritize the overview page and settings modal after tokens
- rely on shared tokens so untouched pages still improve passively
### Risk: Over-stylizing
A direct copy of the reference would conflict with OpenLess's utilitarian desktop workflow.
Guardrail:
- no dark-theme conversion
- no decorative labels
- no animated background
- no major layout theatrics
## Verification
The redesign is successful when:
- the app visibly echoes the reference's radii, frosted surfaces, and typographic hierarchy
- OpenLess still feels light, calm, and easy to scan
- navigation and settings behavior remain unchanged
- the overview page looks like part of the new skin instead of the old one
- there is no animated light ring or equivalent visual effect anywhere in the shell
## Implementation Boundaries
The first implementation pass should stop once the following are true:
- shell background is updated
- sidebar skin is updated
- main console panel is updated
- overview cards and containers are visually aligned
- settings modal visually matches the shell
Anything beyond that should be treated as a follow-up polish pass, not folded into the first skin migration.
-1
View File
@@ -16,7 +16,6 @@
"merge:android-v1-manifest": "node scripts/merge-android-v1-manifest.mjs",
"merge:android-overlay-manifest": "node scripts/merge-android-overlay-manifest.mjs",
"copy:android-scaffolding": "node scripts/copy-android-scaffolding.mjs",
"check:aura-skin": "node scripts/aura-skin-contract.test.mjs",
"check:macos-capsule-spaces": "node scripts/macos-capsule-spaces-contract.test.mjs",
"check:hotkey-injection": "node scripts/check-hotkey-injection.mjs"
},
@@ -1,512 +0,0 @@
import { readFile, readdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import assert from 'node:assert/strict';
const root = new URL('../', import.meta.url);
async function read(relPath) {
return readFile(new URL(relPath, root), 'utf8');
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function extractCssBlock(css, selector) {
const escapedSelector = escapeRegExp(selector);
const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([\\s\\S]*?)\\n\\}`));
assert.ok(match, `tokens.css must contain ${selector} block`);
return match[1];
}
function parseCustomProperties(block) {
const tokens = new Map();
const re = /^\s*(--[\w-]+)\s*:\s*([^;]+);/gm;
let match;
while ((match = re.exec(block)) !== null) {
tokens.set(match[1], match[2].trim());
}
return tokens;
}
function resolveTokenValue(name, tokens, stack = new Set()) {
const raw = tokens.get(name);
assert.ok(raw, `missing token ${name}`);
if (!raw.startsWith('var(')) {
return raw;
}
const inner = raw.slice(4, -1).trim();
const refName = inner.split(',')[0].trim();
assert.ok(refName.startsWith('--'), `unsupported var() reference in ${name}: ${raw}`);
if (stack.has(refName)) {
throw new Error(`circular var() reference: ${[...stack, refName].join(' -> ')}`);
}
stack.add(refName);
return resolveTokenValue(refName, tokens, stack);
}
function parseCssColor(value) {
const hexMatch = value.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (hexMatch) {
let hex = hexMatch[1];
if (hex.length === 3) {
hex = hex
.split('')
.map((ch) => ch + ch)
.join('');
}
return {
r: Number.parseInt(hex.slice(0, 2), 16),
g: Number.parseInt(hex.slice(2, 4), 16),
b: Number.parseInt(hex.slice(4, 6), 16),
a: 1,
};
}
const rgbaMatch = value.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i);
if (rgbaMatch) {
return {
r: Number(rgbaMatch[1]),
g: Number(rgbaMatch[2]),
b: Number(rgbaMatch[3]),
a: rgbaMatch[4] === undefined ? 1 : Number(rgbaMatch[4]),
};
}
throw new Error(`unsupported color format: ${value}`);
}
function srgbChannel(value) {
const normalized = value / 255;
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
}
function relativeLuminance({ r, g, b }) {
const R = srgbChannel(r);
const G = srgbChannel(g);
const B = srgbChannel(b);
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}
function contrastRatio(foreground, background) {
const fg = parseCssColor(foreground);
const bg = parseCssColor(background);
assert.equal(fg.a, 1, `foreground must be opaque for contrast checks: ${foreground}`);
assert.equal(bg.a, 1, `background must be opaque for contrast checks: ${background}`);
const lighter = Math.max(relativeLuminance(fg), relativeLuminance(bg));
const darker = Math.min(relativeLuminance(fg), relativeLuminance(bg));
return (lighter + 0.05) / (darker + 0.05);
}
function compositeOverBackground(fgValue, bgValue) {
const fg = parseCssColor(fgValue);
const bg = parseCssColor(bgValue);
if (fg.a === 1) {
return fgValue;
}
const alpha = fg.a;
const r = Math.round(fg.r * alpha + bg.r * (1 - alpha));
const g = Math.round(fg.g * alpha + bg.g * (1 - alpha));
const b = Math.round(fg.b * alpha + bg.b * (1 - alpha));
return `rgb(${r}, ${g}, ${b})`;
}
function contrastRatioOverBackground(foreground, background) {
const effectiveFg = compositeOverBackground(foreground, background);
return contrastRatio(effectiveFg, background);
}
function assertSolidContrast(tokens, label, bgToken, inkToken, minRatio = 4.5) {
const bg = resolveTokenValue(bgToken, tokens);
const ink = resolveTokenValue(inkToken, tokens);
const ratio = contrastRatio(ink, bg);
assert.ok(
ratio >= minRatio,
`${label}: ${inkToken} on ${bgToken} must meet WCAG AA (${ratio.toFixed(2)}:1 < ${minRatio}:1)`,
);
return ratio;
}
function assertMutedContrast(tokens, label, bgToken, inkToken, minRatio = 4.5) {
const bg = resolveTokenValue(bgToken, tokens);
const ink = resolveTokenValue(inkToken, tokens);
const ratio = contrastRatioOverBackground(ink, bg);
assert.ok(
ratio >= minRatio,
`${label}: ${inkToken} on ${bgToken} must meet WCAG AA (${ratio.toFixed(2)}:1 < ${minRatio}:1)`,
);
return ratio;
}
function assertUsesClassName(source, className, message) {
const escapedClassName = escapeRegExp(className);
const patterns = [
new RegExp(`className\\s*=\\s*(?:\\{\\s*)?"(?:[^"]*\\s)?${escapedClassName}(?:\\s[^"]*)?"(?:\\s*\\})?`),
new RegExp(`className\\s*=\\s*(?:\\{\\s*)?'(?:[^']*\\s)?${escapedClassName}(?:\\s[^']*)?'(?:\\s*\\})?`),
new RegExp(`className\\s*=\\s*(?:\\{\\s*)?\`(?:[^\`]*\\s)?${escapedClassName}(?:\\s[^\`]*)?\`(?:\\s*\\})?`),
];
assert.ok(patterns.some((pattern) => pattern.test(source)), message);
}
const srcRoot = fileURLToPath(new URL('src/', root));
async function walkSourceFiles(dirPath, files = []) {
const entries = await readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
await walkSourceFiles(entryPath, files);
continue;
}
if (/\.(tsx?|css)$/.test(entry.name)) {
files.push(path.relative(srcRoot, entryPath).replace(/\\/g, '/'));
}
}
return files;
}
assert.throws(
() => assertUsesClassName('<div>ol-app-shell-bg</div>', 'ol-app-shell-bg', 'sample must require className usage'),
/sample must require className usage/,
);
assert.throws(
() =>
assertUsesClassName(
'<div className="foo-ol-app-shell-bg-bar" />',
'ol-app-shell-bg',
'sample must require an exact class token',
),
/sample must require an exact class token/,
);
assertUsesClassName(
'<div className="foo ol-app-shell-bg bar" />',
'ol-app-shell-bg',
'sample should accept className usage',
);
const [tokens, globalCss, shell, settingsModal, overview, settingsTabs, themeMode, stylePage, sourceFiles, remoteStyle, selectLite, translationPage] = await Promise.all([
read('src/styles/tokens.css'),
read('src/styles/global.css'),
read('src/components/FloatingShell.tsx'),
read('src/components/SettingsModal.tsx'),
read('src/pages/Overview.tsx'),
read('src/pages/settings/tabs.tsx'),
read('src/lib/themeMode.ts'),
read('src/pages/Style.tsx'),
walkSourceFiles(srcRoot),
read('src-tauri/src/remote_server/assets/style.css'),
read('src/components/ui/SelectLite.tsx'),
read('src/pages/Translation.tsx'),
]);
assert.match(tokens, /--ol-shell-radius:/, 'tokens.css must define --ol-shell-radius');
assert.match(tokens, /--ol-panel-radius:/, 'tokens.css must define --ol-panel-radius');
assert.match(tokens, /--ol-aura-shadow:/, 'tokens.css must define --ol-aura-shadow');
assert.match(tokens, /--ol-font-display:/, 'tokens.css must define --ol-font-display');
assert.match(tokens, /--ol-on-accent:/, 'tokens.css must define --ol-on-accent');
assert.match(tokens, /--ol-primary-solid-bg:/, 'tokens.css must define --ol-primary-solid-bg');
assert.match(tokens, /--ol-primary-solid-ink:/, 'tokens.css must define --ol-primary-solid-ink');
assert.match(tokens, /--ol-control-radius:/, 'tokens.css must define --ol-control-radius');
assert.match(tokens, /--ol-accent-solid-bg:/, 'tokens.css must define --ol-accent-solid-bg');
assert.match(tokens, /--ol-accent-solid-bg-hover:/, 'tokens.css must define --ol-accent-solid-bg-hover');
assert.match(tokens, /--ol-accent-solid-ink:/, 'tokens.css must define --ol-accent-solid-ink');
assert.match(tokens, /--ol-danger-solid-bg:/, 'tokens.css must define --ol-danger-solid-bg');
assert.match(tokens, /--ol-danger-solid-ink:/, 'tokens.css must define --ol-danger-solid-ink');
assert.match(
tokens,
/\[data-ol-theme='dark'\][\s\S]*--ol-accent-solid-bg:/,
'tokens.css must define --ol-accent-solid-bg in dark theme',
);
assert.match(
tokens,
/\[data-ol-theme='dark'\][\s\S]*--ol-accent-solid-bg-hover:/,
'tokens.css must define --ol-accent-solid-bg-hover in dark theme',
);
assert.match(
tokens,
/\[data-ol-theme='dark'\][\s\S]*--ol-accent-solid-ink:/,
'tokens.css must define --ol-accent-solid-ink in dark theme',
);
assert.match(
tokens,
/\[data-ol-theme='dark'\][\s\S]*--ol-danger-solid-bg:/,
'tokens.css must define --ol-danger-solid-bg in dark theme',
);
assert.match(
tokens,
/\[data-ol-theme='dark'\][\s\S]*--ol-danger-solid-ink:/,
'tokens.css must define --ol-danger-solid-ink in dark theme',
);
assert.match(
tokens,
/\[data-ol-theme='dark'\][\s\S]*--ol-primary-solid-bg:/,
'tokens.css must define --ol-primary-solid-bg in dark theme',
);
assert.match(
tokens,
/\[data-ol-theme='dark'\][\s\S]*--ol-primary-solid-ink:/,
'tokens.css must define --ol-primary-solid-ink in dark theme',
);
const lightTokens = parseCustomProperties(extractCssBlock(tokens, ':root'));
const darkTokens = parseCustomProperties(extractCssBlock(tokens, "[data-ol-theme='dark']"));
const contrastPairs = [
{
label: 'light accent-solid',
tokens: lightTokens,
bg: '--ol-accent-solid-bg',
ink: '--ol-accent-solid-ink',
},
{
label: 'light primary-solid',
tokens: lightTokens,
bg: '--ol-primary-solid-bg',
ink: '--ol-primary-solid-ink',
},
{
label: 'dark accent-solid',
tokens: darkTokens,
bg: '--ol-accent-solid-bg',
ink: '--ol-accent-solid-ink',
},
{
label: 'dark primary-solid',
tokens: darkTokens,
bg: '--ol-primary-solid-bg',
ink: '--ol-primary-solid-ink',
},
{
label: 'light danger-solid',
tokens: lightTokens,
bg: '--ol-danger-solid-bg',
ink: '--ol-danger-solid-ink',
},
{
label: 'dark danger-solid',
tokens: darkTokens,
bg: '--ol-danger-solid-bg',
ink: '--ol-danger-solid-ink',
},
];
const contrastRatios = {};
for (const pair of contrastPairs) {
contrastRatios[pair.label] = assertSolidContrast(pair.tokens, pair.label, pair.bg, pair.ink);
}
const mutedContrastPairs = [
{
label: 'light ink-4 on surface',
tokens: lightTokens,
bg: '--ol-surface',
ink: '--ol-ink-4',
},
{
label: 'light ink-4 on surface-2',
tokens: lightTokens,
bg: '--ol-surface-2',
ink: '--ol-ink-4',
},
];
const mutedContrastRatios = {};
for (const pair of mutedContrastPairs) {
mutedContrastRatios[pair.label] = assertMutedContrast(pair.tokens, pair.label, pair.bg, pair.ink);
}
const remoteTokens = parseCustomProperties(extractCssBlock(remoteStyle, ':root'));
const remoteMutedContrastPairs = [
{
label: 'remote ink-4 on surface',
tokens: remoteTokens,
bg: '--surface',
ink: '--ink-4',
},
{
label: 'remote ink-4 on surface-2',
tokens: remoteTokens,
bg: '--surface-2',
ink: '--ink-4',
},
];
const remoteMutedContrastRatios = {};
for (const pair of remoteMutedContrastPairs) {
remoteMutedContrastRatios[pair.label] = assertMutedContrast(pair.tokens, pair.label, pair.bg, pair.ink);
}
const olFrostBlock = extractCssBlock(globalCss, '.ol-frost');
assert.doesNotMatch(
olFrostBlock,
/rgba\(\s*255\s*,\s*255\s*,\s*255/i,
'.ol-frost in global.css must not hardcode white rgba background gradients',
);
assert.match(globalCss, /background:\s*var\(--ol-frost-bg\)/, '.ol-frost must use --ol-frost-bg token');
assert.match(
tokens,
/\[data-ol-theme='dark'\][\s\S]*--ol-input-ink:/,
'tokens.css must define --ol-input-ink in dark theme',
);
assert.match(
tokens,
/\[data-ol-theme='dark'\][\s\S]*--ol-capsule-confirm-bg:/,
'tokens.css must define --ol-capsule-confirm-bg in dark theme',
);
assert.match(
tokens,
/\[data-ol-theme='dark'\][\s\S]*--ol-capsule-confirm-ink:/,
'tokens.css must define --ol-capsule-confirm-ink in dark theme',
);
assert.match(tokens, /--ol-select-trigger-bg:/, 'tokens.css must define --ol-select-trigger-bg');
assert.match(tokens, /--ol-select-popover-bg:/, 'tokens.css must define --ol-select-popover-bg');
assert.match(
tokens,
/\[data-ol-theme='dark'\][\s\S]*--ol-select-popover-bg:/,
'tokens.css must define --ol-select-popover-bg in dark theme',
);
assert.match(selectLite, /--ol-select-trigger-bg/, 'SelectLite must use --ol-select-trigger-bg');
assert.match(selectLite, /--ol-select-popover-bg/, 'SelectLite must use --ol-select-popover-bg');
assert.match(selectLite, /--ol-select-option-hover-bg/, 'SelectLite must use --ol-select-option-hover-bg');
assert.doesNotMatch(
selectLite,
/rgba\(\s*252\s*,/,
'SelectLite must not hardcode light popover rgba backgrounds',
);
assert.doesNotMatch(
translationPage,
/background:\s*'#fff'/,
'Translation.tsx must not override SelectLite with hardcoded #fff background',
);
assert.match(globalCss, /\.ol-app-shell-bg\b/, 'global.css must expose .ol-app-shell-bg');
assert.match(globalCss, /\.ol-aura-panel\b/, 'global.css must expose .ol-aura-panel');
assert.doesNotMatch(globalCss, /@keyframes ol-aura-halo/, 'global.css must not add an animated halo');
assert.match(globalCss, /\.ol-aura-card\b/, 'global.css must expose .ol-aura-card');
assert.match(
globalCss,
/\.ol-aura-settings\[data-ol-mobile="true"\]/,
'global.css must scope mobile settings radius to the settings element itself',
);
assertUsesClassName(shell, 'ol-app-shell-bg', 'FloatingShell must use the app shell background class');
assertUsesClassName(shell, 'ol-aura-sidebar', 'FloatingShell must expose an Aura sidebar hook');
assertUsesClassName(shell, 'ol-aura-panel', 'FloatingShell must expose an Aura panel hook');
assertUsesClassName(settingsModal, 'ol-aura-settings', 'SettingsModal must expose an Aura settings wrapper');
assertUsesClassName(overview, 'ol-overview-hero', 'Overview must expose a high-visibility overview surface hook');
assert.match(
settingsTabs,
/import\s+\{[^}]*ThemeSection[^}]*\}\s+from\s+['"]\.\/ThemeSection['"]/,
'tabs.tsx GeneralTab must import ThemeSection',
);
assert.match(settingsTabs, /<ThemeSection\s*\/?>/, 'tabs.tsx GeneralTab must render ThemeSection');
assert.match(
themeMode,
/prefers-color-scheme:\s*dark/,
'themeMode.ts must listen for prefers-color-scheme: dark',
);
assert.match(
themeMode,
/data-ol-theme|olTheme|dataset\.olTheme/,
'themeMode.ts must apply theme via data-ol-theme / dataset.olTheme',
);
const forbiddenStyleCardLightBackgrounds = [
/rgba\(\s*255\s*,\s*255\s*,\s*255/i,
/rgba\(\s*248\s*,\s*250\s*,\s*252/i,
/rgba\(\s*239\s*,\s*246\s*,\s*255/i,
];
for (const pattern of forbiddenStyleCardLightBackgrounds) {
assert.doesNotMatch(
stylePage,
pattern,
'Style.tsx must not hardcode light style-card backgrounds (use --ol-style-* tokens)',
);
}
assert.match(
stylePage,
/--ol-style-card-bg/,
'Style.tsx must reference --ol-style-card-bg for style pack surfaces',
);
assert.match(
stylePage,
/--ol-style-card-ink/,
'Style.tsx must reference --ol-style-card-ink for style pack text',
);
assert.match(
stylePage,
/--ol-style-subtle-bg/,
'Style.tsx must reference --ol-style-subtle-bg for editor subtle surfaces',
);
const illegalCssStringPatterns = [
/color:\s*'var\([^)]+\)';/,
/background:\s*'var\([^)]+\)';/,
];
const forbiddenInlineInkBackground =
/background:\s*'var\(--ol-ink\)'|background:\s*[^,\n;{]+?\?\s*'var\(--ol-ink\)'/;
const forbiddenBlueOnAccentCombo =
/background:[\s\S]{0,200}var\(--ol-blue\)[\s\S]{0,500}?color:[\s\S]{0,200}var\(--ol-on-accent\)|color:[\s\S]{0,200}var\(--ol-on-accent\)[\s\S]{0,500}?background:[\s\S]{0,200}var\(--ol-blue\)/;
const forbiddenErrOnAccentCombo =
/background:[\s\S]{0,200}var\(--ol-err[^)]*\)[\s\S]{0,500}?color:[\s\S]{0,200}var\(--ol-on-accent\)|background:[\s\S]{0,200}var\(--ol-err[^)]*\)[\s\S]{0,500}?color:[\s\S]{0,200}var\(--ol-accent-solid-ink\)|color:[\s\S]{0,200}var\(--ol-on-accent\)[\s\S]{0,500}?background:[\s\S]{0,200}var\(--ol-err[^)]*\)|color:[\s\S]{0,200}var\(--ol-accent-solid-ink\)[\s\S]{0,500}?background:[\s\S]{0,200}var\(--ol-err[^)]*\)/;
for (const relPath of sourceFiles) {
const source = await read(`src/${relPath}`);
for (const pattern of illegalCssStringPatterns) {
assert.doesNotMatch(
source,
pattern,
`src/${relPath} must not use quoted CSS custom properties inside injected CSS strings`,
);
}
if (relPath.endsWith('.tsx')) {
assert.doesNotMatch(
source,
forbiddenInlineInkBackground,
`src/${relPath} must not use --ol-ink as a button/solid background`,
);
assert.doesNotMatch(
source,
forbiddenBlueOnAccentCombo,
`src/${relPath} must not pair background var(--ol-blue) with color var(--ol-on-accent) (use --ol-accent-solid-* tokens)`,
);
assert.doesNotMatch(
source,
forbiddenErrOnAccentCombo,
`src/${relPath} must not pair background var(--ol-err) with color var(--ol-on-accent) or var(--ol-accent-solid-ink) (use --ol-danger-solid-* tokens)`,
);
}
}
console.log('Aura skin contract OK');
console.log(
'Solid contrast ratios:',
Object.fromEntries(
Object.entries(contrastRatios).map(([label, ratio]) => [label, `${ratio.toFixed(2)}:1`]),
),
);
console.log(
'Muted contrast ratios:',
Object.fromEntries(
Object.entries({ ...mutedContrastRatios, ...remoteMutedContrastRatios }).map(([label, ratio]) => [
label,
`${ratio.toFixed(2)}:1`,
]),
),
);
@@ -6,14 +6,15 @@ function assertMatch(source, pattern, name) {
}
}
// 契约函数 show_capsule_window_no_activate 的实现就在编译进二进制的 coordinator.rs 里。
// (coordinator/ime_insertion.rs 曾是它的一份副本,但从未被 mod 进 crate —— 死代码,
// 已删除;契约必须校验真正编译的那份,否则会出现「测试绿、线上坏」的假信心。)
const coordinatorRs = (
await readFile(new URL('../src-tauri/src/coordinator.rs', import.meta.url), 'utf-8')
// 契约函数 show_capsule_window_no_activate 的实现现位于编译进二进制的
// coordinator/capsule_focus.rs(2026-06 板块化重构从 coordinator.rs 迁出,行为不变;
// 函数可见性随迁移改为 pub(super))。契约必须校验真正编译的那份,否则会出现
// 「测试绿、线上坏」的假信心。
const capsuleFocusRs = (
await readFile(new URL('../src-tauri/src/coordinator/capsule_focus.rs', import.meta.url), 'utf-8')
).replace(/\r\n/g, '\n');
const functionMatch = coordinatorRs.match(
/#\[cfg\(target_os = "macos"\)\]\s*(?:pub\(crate\) )?fn show_capsule_window_no_activate[\s\S]*?\n}\n\n#\[cfg\(target_os = "linux"\)\]/,
const functionMatch = capsuleFocusRs.match(
/#\[cfg\(target_os = "macos"\)\]\s*(?:pub\((?:crate|super)\) )?fn show_capsule_window_no_activate[\s\S]*?\n}\n\n#\[cfg\(target_os = "linux"\)\]/,
);
if (!functionMatch) {
@@ -17,7 +17,12 @@ const config = JSON.parse(raw);
const capsuleWindow = config.app.windows.find((window) => window.label === 'capsule');
const mainWindow = config.app.windows.find((window) => window.label === 'main');
const libRs = await readFile(new URL('../src-tauri/src/lib.rs', import.meta.url), 'utf-8');
const coordinatorRs = await readFile(new URL('../src-tauri/src/coordinator.rs', import.meta.url), 'utf-8');
// 板块化重构把胶囊 show/hide/focus/position 等迁到 coordinator/capsule_focus.rs(行为不变)。
// 契约校验编译进二进制的胶囊子系统并集,覆盖留在 coordinator.rs 与迁出的两部分。
const coordinatorRs =
(await readFile(new URL('../src-tauri/src/coordinator.rs', import.meta.url), 'utf-8')) +
'\n' +
(await readFile(new URL('../src-tauri/src/coordinator/capsule_focus.rs', import.meta.url), 'utf-8'));
const capsuleTsx = await readFile(new URL('../src/components/Capsule.tsx', import.meta.url), 'utf-8');
const capsuleLayoutTs = await readFile(new URL('../src/lib/capsuleLayout.ts', import.meta.url), 'utf-8');
const windowChromeTsx = await readFile(new URL('../src/components/WindowChrome.tsx', import.meta.url), 'utf-8');
@@ -242,6 +242,27 @@ pub(crate) fn build_client() -> Result<reqwest::Client> {
builder.build().context("build reqwest client failed")
}
/// 判定一个「已存在」的目标文件是否完整可信,纯函数便于单测(#686)。
/// - 大小一致 → 完整;
/// - 大小不符(截断 / 损坏 / 超大)→ 不完整,应删除重下;
/// - `expected_size == 0`(HF 未给出大小)→ 退回旧行为「存在即信任」,避免对未知大小
/// 的文件反复重下。
fn existing_file_is_complete(actual_size: u64, expected_size: u64) -> bool {
if expected_size == 0 {
return true;
}
actual_size == expected_size
}
/// 读盘取实际大小后按 [`existing_file_is_complete`] 判定。元数据取不到(文件刚被删 / 无权限)
/// 视为不完整。读盘方式与 `partial_actual_size` 一致。
fn dest_file_is_complete(dest: &Path, expected_size: u64) -> bool {
match std::fs::metadata(dest) {
Ok(m) => existing_file_is_complete(m.len(), expected_size),
Err(_) => false,
}
}
async fn run_download(
app: &AppHandle,
model_id: ModelId,
@@ -308,7 +329,9 @@ async fn run_download(
.iter()
.map(|f| {
let d = dir.join(&f.path);
if d.exists() {
// 只把「已存在且大小完整」的文件计入已完成字节,与下面的跳过判定一致:
// 截断/损坏的残留文件会被重下,不应计入进度基线(#686)。
if dest_file_is_complete(&d, f.size) {
f.size
} else {
0
@@ -322,8 +345,18 @@ async fn run_download(
for (idx, file) in info.files.iter().enumerate() {
let dest = dir.join(&file.path);
if dest.exists() {
// 已经下完的(目录里直接存在 dest 文件)跳过;前面 already_done_bytes 已计入
continue;
if dest_file_is_complete(&dest, file.size) {
// 已存在且大小完整 → 跳过;前面 already_done_bytes 已计入。
continue;
}
// 已存在但大小不符(上次下载被中断 / 文件被外部损坏)→ 删除残留重下,
// 否则截断文件会被信任为完整、模型加载时才以含糊错误失败(#686)。
log::warn!(
"[asr-dl] {} exists but size mismatch (expected {}), re-downloading",
file.path,
file.size
);
let _ = std::fs::remove_file(&dest);
}
let url = format!(
"{}/{}/resolve/main/{}",
@@ -998,3 +1031,30 @@ fn emit_cancelled(
},
);
}
#[cfg(test)]
mod tests {
use super::existing_file_is_complete;
#[test]
fn complete_when_size_matches() {
assert!(existing_file_is_complete(1024, 1024));
}
#[test]
fn incomplete_when_truncated() {
assert!(!existing_file_is_complete(512, 1024));
}
#[test]
fn incomplete_when_oversized() {
assert!(!existing_file_is_complete(2048, 1024));
}
#[test]
fn trusts_existence_when_expected_size_unknown() {
// HF 未给大小(size == 0)时退回「存在即信任」,避免反复重下。
assert!(existing_file_is_complete(0, 0));
assert!(existing_file_is_complete(999, 0));
}
}
@@ -57,8 +57,6 @@ pub enum VolcengineASRError {
/// 文案简短,原因在文档里说明,capsule 不堆长引导。
#[error("凭据被拒({0})")]
AuthRejected(u16),
#[error("authentication failed")]
AuthenticationFailed,
#[error("no final result")]
NoFinalResult,
#[error("final result timed out")]
@@ -124,10 +122,6 @@ impl VolcengineStreamingASR {
}
}
pub fn is_connected(&self) -> bool {
self.state.lock().is_connected
}
pub async fn open_session(self: &Arc<Self>) -> Result<(), VolcengineASRError> {
if self.credentials.app_id.is_empty()
|| self.credentials.access_token.is_empty()
@@ -23,6 +23,9 @@ pub fn set_dictation_hotkey(
if let Some(open_app) = prefs.open_app_hotkey.as_ref() {
reject_dictation_open_app_hotkey_overlap(&binding, open_app)?;
}
if let Some(less_computer) = prefs.coding_agent_voice_hotkey.as_ref() {
reject_dictation_less_computer_hotkey_overlap(&binding, less_computer)?;
}
prefs.dictation_hotkey = binding;
sync_dictation_hotkey_legacy_fields(&mut prefs);
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
@@ -48,6 +51,9 @@ pub fn set_translation_hotkey(
if let Some(open_app) = previous.open_app_hotkey.as_ref() {
reject_translation_open_app_hotkey_overlap(&binding, open_app)?;
}
if let Some(less_computer) = previous.coding_agent_voice_hotkey.as_ref() {
reject_translation_less_computer_hotkey_overlap(&binding, less_computer)?;
}
let mut prefs = previous.clone();
prefs.translation_hotkey = binding;
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
@@ -82,6 +88,9 @@ pub fn set_switch_style_hotkey(
if let Some(open_app) = prefs.open_app_hotkey.as_ref() {
reject_switch_style_open_app_hotkey_overlap(binding, open_app)?;
}
if let Some(less_computer) = prefs.coding_agent_voice_hotkey.as_ref() {
reject_less_computer_switch_style_hotkey_overlap(less_computer, binding)?;
}
}
prefs.switch_style_hotkey = binding;
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
@@ -109,6 +118,9 @@ pub fn set_open_app_hotkey(
if let Some(switch_style) = prefs.switch_style_hotkey.as_ref() {
reject_switch_style_open_app_hotkey_overlap(switch_style, binding)?;
}
if let Some(less_computer) = prefs.coding_agent_voice_hotkey.as_ref() {
reject_less_computer_open_app_hotkey_overlap(less_computer, binding)?;
}
}
prefs.open_app_hotkey = binding;
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
@@ -156,6 +168,9 @@ pub fn set_combo_hotkey(coord: CoordinatorState<'_>, binding: ComboBinding) -> R
if let Some(open_app) = prefs.open_app_hotkey.as_ref() {
reject_dictation_open_app_hotkey_overlap(&shortcut, open_app)?;
}
if let Some(less_computer) = prefs.coding_agent_voice_hotkey.as_ref() {
reject_dictation_less_computer_hotkey_overlap(&shortcut, less_computer)?;
}
prefs.custom_combo_hotkey = Some(binding);
prefs.dictation_hotkey = shortcut;
sync_dictation_hotkey_legacy_fields(&mut prefs);
@@ -316,7 +331,7 @@ pub(crate) fn reject_qa_open_app_hotkey_overlap(
reject_hotkey_overlap(qa, open_app, "打开应用快捷键不能和 QA 快捷键相同")
}
fn reject_qa_less_computer_hotkey_overlap(
pub(crate) fn reject_qa_less_computer_hotkey_overlap(
qa: &ShortcutBinding,
less_computer: &ShortcutBinding,
) -> Result<(), String> {
@@ -406,3 +421,57 @@ fn shortcut_bindings_overlap(left: &ShortcutBinding, right: &ShortcutBinding) ->
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(primary: &str) -> ShortcutBinding {
ShortcutBinding {
primary: primary.into(),
modifiers: vec![],
}
}
/// 锁定碰撞矩阵:每个动作键与 Less Computer 键相同都必须被 reject_hotkey_collisions
/// 拒绝。5 个快捷 setter(set_dictation/translation/switch_style/open_app/qa_hotkey)
/// 此前漏校验 coding_agent_voice_hotkey,已接入对应的 less_computer 校验。
#[test]
fn each_action_hotkey_collides_with_less_computer() {
let lc = key("LeftControl");
let mut prefs = UserPreferences {
dictation_hotkey: key("A"),
translation_hotkey: key("B"),
qa_hotkey: Some(key("C")),
switch_style_hotkey: Some(key("D")),
open_app_hotkey: Some(key("E")),
coding_agent_voice_hotkey: Some(lc.clone()),
..Default::default()
};
// 基线全不同 → 通过。
assert!(reject_hotkey_collisions(&prefs).is_ok());
prefs.dictation_hotkey = lc.clone();
assert!(reject_hotkey_collisions(&prefs).is_err());
prefs.dictation_hotkey = key("A");
prefs.translation_hotkey = lc.clone();
assert!(reject_hotkey_collisions(&prefs).is_err());
prefs.translation_hotkey = key("B");
prefs.qa_hotkey = Some(lc.clone());
assert!(reject_hotkey_collisions(&prefs).is_err());
prefs.qa_hotkey = Some(key("C"));
prefs.switch_style_hotkey = Some(lc.clone());
assert!(reject_hotkey_collisions(&prefs).is_err());
prefs.switch_style_hotkey = Some(key("D"));
prefs.open_app_hotkey = Some(lc.clone());
assert!(reject_hotkey_collisions(&prefs).is_err());
prefs.open_app_hotkey = Some(key("E"));
// 复位后再次全不同 → 通过。
assert!(reject_hotkey_collisions(&prefs).is_ok());
}
}
@@ -29,6 +29,9 @@ pub fn set_qa_hotkey(
if let Some(open_app) = prefs.open_app_hotkey.as_ref() {
reject_qa_open_app_hotkey_overlap(binding, open_app)?;
}
if let Some(less_computer) = prefs.coding_agent_voice_hotkey.as_ref() {
reject_qa_less_computer_hotkey_overlap(binding, less_computer)?;
}
}
prefs.qa_hotkey = binding;
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,530 @@
//! ASR engine wiring, credential/permission gates, and release scheduling
//! extracted from `coordinator.rs` (behavior-preserving move).
//!
//! References parent items via `use super::*;`; `pub(super)` so the parent
//! `coordinator` module reaches them through `use asr_wiring::*;`.
use super::*;
#[cfg(any(debug_assertions, test))]
pub(super) fn hotkey_injection_dry_run_enabled() -> bool {
std::env::var_os("OPENLESS_HOTKEY_INJECTION_DRY_RUN").is_some()
}
#[cfg(any(debug_assertions, test))]
pub(super) fn debug_transcript_override_text() -> Option<String> {
let path = std::env::var_os("OPENLESS_DEBUG_TRANSCRIPT_FILE")?;
let text = std::fs::read_to_string(path).ok()?;
let trimmed = text.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
pub(super) fn ensure_microphone_permission(_inner: &Arc<Inner>) -> Result<(), String> {
use crate::permissions::{self, PermissionStatus};
#[cfg(target_os = "windows")]
{
if permissions::windows_microphone_access_explicitly_denied() {
return Err("需要麦克风权限,当前状态: Denied".to_string());
}
return Ok(());
}
let status = permissions::check_microphone();
if matches!(
status,
PermissionStatus::Granted | PermissionStatus::NotApplicable
) {
return Ok(());
}
// 听写路径不抢前台焦点:缺 mic 权限时直接请求系统授权,不再先 show_main_window。
// 用户在设置页手动点“请求权限”仍走 request_microphone_from_foreground,那是显式操作。
// 这里若系统不弹框,后续会通过 capsule error 引导用户主动去权限页处理。详见 #166。
let requested = permissions::request_microphone();
if matches!(
requested,
PermissionStatus::Granted | PermissionStatus::NotApplicable
) {
Ok(())
} else {
Err(format!("需要麦克风权限,当前状态: {requested:?}"))
}
}
pub(super) fn ensure_asr_credentials() -> Result<(), String> {
let active_asr = CredentialsVault::get_active_asr();
// 本地 Qwen3-ASR 没有"凭据"概念,但需要:(a) macOS 平台 (b) 模型已下载。
if crate::asr::local::is_local_qwen3(&active_asr) {
#[cfg(not(target_os = "macos"))]
{
return Err("本地 ASR 当前仅支持 macOS(Windows 见 issue #256)".to_string());
}
#[cfg(target_os = "macos")]
{
return ensure_local_qwen3_model_ready();
}
}
if crate::asr::local::foundry::is_foundry_local_whisper(&active_asr) {
#[cfg(not(target_os = "windows"))]
{
return Err("Foundry Local Whisper 当前仅支持 Windows".to_string());
}
#[cfg(target_os = "windows")]
{
return Ok(());
}
}
if crate::asr::local::sherpa::is_sherpa_onnx_local(&active_asr) {
#[cfg(not(target_os = "windows"))]
{
return Err("sherpa-onnx local ASR 当前仅支持 Windows".to_string());
}
#[cfg(target_os = "windows")]
{
return Ok(());
}
}
if is_whisper_compatible_provider(&active_asr) || is_bailian_provider(&active_asr) {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
.ok()
.flatten()
.unwrap_or_default();
if api_key.trim().is_empty() {
return Err("请先在设置中填写 ASR 服务商 API Key".to_string());
}
return Ok(());
}
let creds = read_volc_credentials();
if creds.app_id.trim().is_empty() || creds.access_token.trim().is_empty() {
Err("请先在设置中填写火山引擎 ASR App Key 和 Access Key".to_string())
} else {
Ok(())
}
}
#[cfg(test)]
pub(super) fn is_keyless_local_asr_provider(id: &str) -> bool {
if crate::asr::local::is_local_qwen3(id) {
return true;
}
#[cfg(target_os = "macos")]
if crate::asr::local::is_apple_speech(id) {
return true;
}
#[cfg(target_os = "windows")]
{
crate::asr::local::foundry::is_foundry_local_whisper(id)
|| crate::asr::local::sherpa::is_sherpa_onnx_local(id)
}
#[cfg(not(target_os = "windows"))]
{
let _ = id;
false
}
}
#[cfg(target_os = "macos")]
pub(super) fn ensure_local_qwen3_model_ready() -> Result<(), String> {
let prefs = || -> Result<crate::types::UserPreferences, String> {
// 这里没法拿到 inner,直接读 preferences.json 即可(Coordinator 写盘后总是同步的)。
crate::persistence::PreferencesStore::new()
.map_err(|e| e.to_string())
.map(|s| s.get())
}()?;
let model_id = crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model)
.ok_or_else(|| format!("未知的本地模型 id: {}", prefs.local_asr_active_model))?;
if !crate::asr::local::models::is_downloaded(model_id) {
return Err(format!(
"本地模型 {} 未下载完整,请到 设置 → 模型设置 中下载",
model_id.as_str()
));
}
Ok(())
}
/// 引擎加载/释放/keepLoadedSecs 变化时主动推给前端,前端 listen
/// `local-asr:engine-changed` 即可零轮询同步 UI(issue #470 / #6)。
/// 只反映 Qwen3 这一路(loaded_model_id / prefs),不碰 Foundry / Sherpa。
/// 仅用桌面端跨平台符号;Android 无本地 ASR 引擎(LocalAsrEngineStatus 不在该 target
/// 编译),单独给 no-op stub(见下),让各调用点在所有平台统一编译。
#[cfg(not(target_os = "android"))]
pub(super) fn emit_local_asr_engine_status(inner: &Arc<Inner>) {
let model_id = inner.local_asr_cache.loaded_model_id();
let keep_loaded_secs = inner.prefs.get().local_asr_keep_loaded_secs;
let status = crate::commands::LocalAsrEngineStatus {
loaded: model_id.is_some(),
model_id,
keep_loaded_secs,
};
if let Some(app) = inner.app.lock().clone() {
let _ = app.emit("local-asr:engine-changed", &status);
}
}
/// Android no-op:该 target 不编译 LocalAsrEngineStatus / 本地 ASR 引擎。issue #470 / #6。
#[cfg(target_os = "android")]
pub(super) fn emit_local_asr_engine_status(_inner: &Arc<Inner>) {}
/// 一次 dictation 结束后,按 prefs.local_asr_keep_loaded_secs 决定何时释放
/// 内存里的 Qwen3-ASR 引擎。0 = 立即释放;其它值 = sleep N 秒后看 last_used。
/// 多次会话叠加多个 sleep 任务,每个独立 check:只要中间又被使用过就跳过释放。
pub(super) fn schedule_local_asr_release(inner: &Arc<Inner>) {
let keep_secs = inner.prefs.get().local_asr_keep_loaded_secs;
let cache = Arc::clone(&inner.local_asr_cache);
if keep_secs == 0 {
cache.release_now();
emit_local_asr_engine_status(inner);
return;
}
let dur = std::time::Duration::from_secs(keep_secs as u64);
let inner = Arc::clone(inner);
tauri::async_runtime::spawn(async move {
tokio::time::sleep(dur).await;
if cache.release_if_idle(dur) {
emit_local_asr_engine_status(&inner);
}
});
}
#[cfg(target_os = "windows")]
pub(super) fn foundry_local_asr_release_keep_secs(inner: &Arc<Inner>) -> u32 {
inner.prefs.get().foundry_local_asr_keep_loaded_secs
}
#[cfg(target_os = "windows")]
#[derive(Clone, Copy)]
pub(super) enum AsrReleaseSession {
Dictation(SessionId),
Qa(SessionId),
}
#[cfg(target_os = "windows")]
pub(super) fn asr_release_session_is_current(inner: &Arc<Inner>, session: AsrReleaseSession) -> bool {
match session {
AsrReleaseSession::Dictation(session_id) => inner.state.lock().session_id == session_id,
AsrReleaseSession::Qa(session_id) => inner.qa_state.lock().session_id == session_id,
}
}
#[cfg(target_os = "windows")]
pub(super) fn schedule_foundry_local_asr_release(inner: &Arc<Inner>, session: AsrReleaseSession) {
let keep_secs = foundry_local_asr_release_keep_secs(inner);
let runtime = Arc::clone(&inner.foundry_local_runtime);
let inner = Arc::clone(inner);
tauri::async_runtime::spawn(async move {
if keep_secs > 0 {
tokio::time::sleep(std::time::Duration::from_secs(keep_secs as u64)).await;
}
if !asr_release_session_is_current(&inner, session) {
return;
}
if let Err(error) = runtime.release_now().await {
log::warn!("[foundry-asr] scheduled release failed: {error:#}");
}
});
}
#[cfg(target_os = "windows")]
pub(super) fn sherpa_onnx_release_keep_secs(inner: &Arc<Inner>) -> u32 {
inner.prefs.get().sherpa_onnx_keep_loaded_secs
}
/// 与 `schedule_foundry_local_asr_release` 同形:session_id 老旧则不释放,
/// 避免下一轮 session 立即重加载同一个 offline batch 模型。
#[cfg(target_os = "windows")]
pub(super) fn schedule_sherpa_onnx_release(inner: &Arc<Inner>, session: AsrReleaseSession) {
let keep_secs = sherpa_onnx_release_keep_secs(inner);
let runtime = Arc::clone(&inner.sherpa_onnx_runtime);
let inner = Arc::clone(inner);
tauri::async_runtime::spawn(async move {
if keep_secs > 0 {
tokio::time::sleep(std::time::Duration::from_secs(keep_secs as u64)).await;
}
if !asr_release_session_is_current(&inner, session) {
return;
}
if let Err(error) = runtime.release_now().await {
log::warn!("[sherpa-asr] scheduled release failed: {error:#}");
}
});
}
#[cfg(target_os = "macos")]
pub(super) async fn build_local_qwen3(
inner: &Arc<Inner>,
) -> anyhow::Result<Arc<crate::asr::local::LocalQwenAsr>> {
let prefs = inner.prefs.get();
let model_id = crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model)
.ok_or_else(|| anyhow::anyhow!("未知本地模型 id: {}", prefs.local_asr_active_model))?;
let dir = crate::asr::local::models::model_dir(model_id)?;
let app = inner
.app
.lock()
.clone()
.ok_or_else(|| anyhow::anyhow!("AppHandle 未绑定"))?;
// 走缓存:如果已有同 id 的引擎在内存里就直接复用,避免每次会话都重加载
// 1.2GB+ 模型。第一次加载阻塞数秒,spawn_blocking 不卡 tokio runtime。
let cache = Arc::clone(&inner.local_asr_cache);
let mid = model_id.as_str().to_string();
let engine = tauri::async_runtime::spawn_blocking(move || cache.get_or_load(&mid, &dir))
.await
.map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
// 加载完成(含缓存命中刷新 last_used)后推一次状态,前端零轮询更新「已加载」。
emit_local_asr_engine_status(inner);
Ok(Arc::new(crate::asr::local::LocalQwenAsr::new(app, engine)))
}
#[cfg(target_os = "macos")]
pub(super) fn build_apple_speech() -> Arc<crate::asr::local::AppleSpeechAsr> {
Arc::new(crate::asr::local::AppleSpeechAsr::new())
}
/// `whisper` 是 OpenAI 原生;`siliconflow` / `zhipu` / `groq` 都暴露
/// OpenAI 兼容的 `/audio/transcriptions`,统一走 `WhisperBatchASR`。
/// 新增 OpenAI 兼容 ASR 时只需在这里加一项。
///
/// 注:DashScope 的 Qwen3-ASR-Flash 不在此列——它用 MultiModalConversation
/// (messages=[{content:[{audio:...}]}]) 协议,不是 Whisper multipart,需要
/// 单独 ASR 客户端,留给 V2。
pub(super) fn is_whisper_compatible_provider(id: &str) -> bool {
matches!(
id,
"whisper" | "siliconflow" | "zhipu" | "groq" | "openrouter"
)
}
/// 该 provider 的请求体编码方式。OpenRouter 的 `/audio/transcriptions` 是
/// `application/json` + base64 音频(issue #582),其余兼容厂商沿用 multipart。
pub(super) fn whisper_request_format(provider_id: &str) -> crate::asr::whisper::AsrRequestFormat {
match provider_id {
"openrouter" => crate::asr::whisper::AsrRequestFormat::OpenRouterJson,
_ => crate::asr::whisper::AsrRequestFormat::Multipart,
}
}
/// 该 provider 的 `/audio/transcriptions` 是否支持 `response_format=verbose_json`
/// 并返回带 `no_speech_prob` / `avg_logprob` / `compression_ratio` 的 segments,
/// 用于幻听过滤。
///
/// - `whisper`(OpenAI)/ `groq`:原生 Whisper,完整支持,过滤有效。
/// - `siliconflow`:模型是 SenseVoice / TeleSpeech,文档无 `response_format`,
/// 发送 verbose_json 可能被拒,**保持关闭**走旧的 `json`。
/// - `zhipu`(GLM-ASR):虽接受 verbose_json,但不产出上述指标,过滤是空转;
/// 为最小化行为变更,这里也**保持关闭**,仅对确证有收益的 whisper/groq 开启。
pub(super) fn whisper_supports_verbose_json(provider_id: &str) -> bool {
matches!(provider_id, "whisper" | "groq")
}
pub(super) fn is_bailian_provider(id: &str) -> bool {
id == crate::asr::bailian::PROVIDER_ID
}
pub(super) fn is_mimo_provider(id: &str) -> bool {
id == crate::asr::mimo::PROVIDER_ID
}
pub(super) fn apply_chinese_script_preference(text: &str, pref: ChineseScriptPreference) -> String {
if text.is_empty() {
return String::new();
}
let config = match pref {
ChineseScriptPreference::Simplified => Some(BuiltinConfig::T2s),
ChineseScriptPreference::Traditional => Some(BuiltinConfig::S2t),
ChineseScriptPreference::Auto => None,
};
let Some(config) = config else {
return text.to_string();
};
match OpenCC::from_config(config) {
Ok(converter) => converter.convert(text),
Err(err) => {
log::warn!("[coord] OpenCC init failed, skip script conversion: {err}");
text.to_string()
}
}
}
pub(super) enum QaAsrStart {
Volcengine {
asr: Arc<VolcengineStreamingASR>,
bridge: Arc<DeferredAsrBridge>,
},
Bailian {
asr: Arc<BailianRealtimeASR>,
bridge: Arc<DeferredAsrBridge>,
},
Ready {
active: ActiveAsr,
consumer: Arc<dyn crate::recorder::AudioConsumer>,
},
}
impl QaAsrStart {
pub(super) fn active_asr(&self) -> ActiveAsr {
match self {
QaAsrStart::Volcengine { asr, .. } => ActiveAsr::Volcengine(Arc::clone(asr)),
QaAsrStart::Bailian { asr, .. } => ActiveAsr::Bailian(Arc::clone(asr)),
QaAsrStart::Ready { active, .. } => active.clone(),
}
}
pub(super) fn recorder_consumer(&self) -> Arc<dyn crate::recorder::AudioConsumer> {
match self {
QaAsrStart::Volcengine { bridge, .. } => Arc::clone(bridge) as _,
QaAsrStart::Bailian { bridge, .. } => Arc::clone(bridge) as _,
QaAsrStart::Ready { consumer, .. } => Arc::clone(consumer),
}
}
pub(super) async fn open_streaming_session(&self) -> Result<(), String> {
match self {
QaAsrStart::Volcengine { asr, bridge } => {
asr.open_session().await.map_err(|e| e.to_string())?;
let target: Arc<dyn crate::asr::AudioConsumer> = Arc::clone(asr) as _;
let flushed = bridge.attach(target);
log::info!("[coord] QA ASR connected; flushed {flushed} deferred audio bytes");
Ok(())
}
QaAsrStart::Bailian { asr, bridge } => {
asr.open_session().await.map_err(|e| e.to_string())?;
let target: Arc<dyn crate::asr::AudioConsumer> = Arc::clone(asr) as _;
let flushed = bridge.attach(target);
log::info!(
"[coord] QA Bailian ASR connected; flushed {flushed} deferred audio bytes"
);
Ok(())
}
QaAsrStart::Ready { .. } => Ok(()),
}
}
}
pub(super) async fn build_qa_asr_start(inner: &Arc<Inner>, active_asr: &str) -> Result<QaAsrStart, String> {
#[cfg(target_os = "windows")]
if foundry::is_foundry_local_whisper(active_asr) {
let prefs = inner.prefs.get();
let model_alias = if foundry::model_alias_is_known(&prefs.foundry_local_asr_model) {
prefs.foundry_local_asr_model.clone()
} else {
foundry::DEFAULT_MODEL_ALIAS.to_string()
};
let language_hint = prefs.foundry_local_asr_language_hint.trim().to_string();
let language_hint = if language_hint.is_empty() {
None
} else {
Some(language_hint)
};
let local = Arc::new(FoundryLocalWhisperAsr::new(
Arc::clone(&inner.foundry_local_runtime),
model_alias,
prefs.foundry_local_runtime_source.clone(),
language_hint,
));
let active = ActiveAsr::FoundryLocalWhisper(Arc::clone(&local));
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
return Ok(QaAsrStart::Ready { active, consumer });
}
#[cfg(target_os = "windows")]
if sherpa::is_sherpa_onnx_local(active_asr) {
let prefs = inner.prefs.get();
let model_alias = if sherpa::model_alias_is_known(&prefs.sherpa_onnx_model) {
prefs.sherpa_onnx_model.clone()
} else {
sherpa::DEFAULT_MODEL_ALIAS.to_string()
};
let language_hint = prefs.sherpa_onnx_language_hint.trim().to_string();
let language_hint = if language_hint.is_empty() {
None
} else {
Some(language_hint)
};
let token_handler = inner.app.lock().clone().map(|app| {
Arc::new(move |piece: String| {
if let Err(error) = app.emit("local-asr-token", piece) {
log::warn!("[sherpa-asr] emit token failed: {error}");
}
}) as crate::asr::local::sherpa_provider::SherpaTokenHandler
});
let local = SherpaOnnxAsr::new_for_model(
Arc::clone(&inner.sherpa_onnx_runtime),
model_alias,
language_hint,
token_handler,
)
.await
.map_err(|e| format!("sherpa-onnx init failed: {e}"))?;
let local = Arc::new(local);
let active = ActiveAsr::SherpaOnnxLocal(Arc::clone(&local));
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
return Ok(QaAsrStart::Ready { active, consumer });
}
#[cfg(target_os = "macos")]
if crate::asr::local::is_local_qwen3(active_asr) {
let local = build_local_qwen3(inner)
.await
.map_err(|e| format!("local ASR init failed: {e}"))?;
let active = ActiveAsr::Local(Arc::clone(&local));
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
return Ok(QaAsrStart::Ready { active, consumer });
}
#[cfg(target_os = "macos")]
if crate::asr::local::is_apple_speech(active_asr) {
let local = build_apple_speech();
let active = ActiveAsr::AppleSpeech(Arc::clone(&local));
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
return Ok(QaAsrStart::Ready { active, consumer });
}
match active_asr_provider_kind(active_asr) {
ActiveAsrProviderKind::Bailian => Ok(QaAsrStart::Bailian {
asr: Arc::new(BailianRealtimeASR::new(read_bailian_credentials())),
bridge: Arc::new(DeferredAsrBridge::new()),
}),
ActiveAsrProviderKind::Mimo => {
let (api_key, base_url, model) = read_mimo_credentials();
let mimo = Arc::new(MimoBatchASR::new(api_key, base_url, model));
let active = ActiveAsr::Mimo(Arc::clone(&mimo));
let consumer: Arc<dyn crate::recorder::AudioConsumer> = mimo;
Ok(QaAsrStart::Ready { active, consumer })
}
ActiveAsrProviderKind::WhisperCompatible => {
let (api_key, base_url, model) = read_whisper_credentials();
let whisper_prompt =
crate::asr::whisper::build_prompt_from_phrases(&enabled_phrases(inner));
let whisper = Arc::new(
WhisperBatchASR::new(
api_key,
base_url,
model,
whisper_prompt,
batch_asr_chunk_limit_ms(active_asr),
whisper_supports_verbose_json(active_asr),
)
.with_request_format(whisper_request_format(active_asr)),
);
let active = ActiveAsr::Whisper(Arc::clone(&whisper));
let consumer: Arc<dyn crate::recorder::AudioConsumer> = whisper;
Ok(QaAsrStart::Ready { active, consumer })
}
ActiveAsrProviderKind::Volcengine => Ok(QaAsrStart::Volcengine {
asr: Arc::new(VolcengineStreamingASR::new(
read_volc_credentials(),
enabled_hotwords(inner),
)),
bridge: Arc::new(DeferredAsrBridge::new()),
}),
}
}
@@ -0,0 +1,647 @@
//! Focus-target capture and capsule-window presentation extracted from
//! `coordinator.rs` (behavior-preserving move).
//!
//! External focus/frontmost-app capture, capsule window show/hide/position,
//! and `emit_capsule`. References parent items via `use super::*;`; `pub(super)`
//! so the parent and sibling submodules reach them through `use capsule_focus::*;`.
use super::*;
/// 与 capture_focus_target 类似,但前台窗口属于本进程(即用户停在 QA / capsule / main
/// 等自家窗口)时返回 None,让 caller 区分"用户没切到别处" vs "用户切到了另一个真正的
/// 外部 app"。issue #466 多轮场景下用来刷新 qa_focus_target。
#[cfg(target_os = "windows")]
pub(super) fn capture_external_focus_target() -> Option<usize> {
use windows::Win32::System::Threading::GetCurrentProcessId;
use windows::Win32::UI::WindowsAndMessaging::{GetForegroundWindow, GetWindowThreadProcessId};
unsafe {
let hwnd = GetForegroundWindow();
if hwnd.0.is_null() {
return None;
}
let mut pid: u32 = 0;
GetWindowThreadProcessId(hwnd, Some(&mut pid));
if pid == GetCurrentProcessId() {
return None;
}
Some(hwnd.0 as usize)
}
}
#[cfg(not(target_os = "windows"))]
pub(super) fn capture_external_focus_target() -> Option<usize> {
None
}
#[cfg(target_os = "windows")]
pub(super) fn capture_focus_target() -> Option<usize> {
use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow;
let foreground = unsafe { GetForegroundWindow() };
if foreground.0.is_null() {
None
} else {
Some(foreground.0 as usize)
}
}
#[cfg(not(target_os = "windows"))]
pub(super) fn capture_focus_target() -> Option<usize> {
None
}
/// 捕获用户开始 dictation 时的前台 app 标签("localizedName (bundle.id)"),用作 LLM
/// polish/translate 的上下文前提,让模型按 app 调风格。详见 issue #116。
///
/// macOS 走 NSWorkspace.frontmostApplication(公开 API,无需额外权限);
/// Windows 复用前台 HWND 拿窗口标题;Linux/其他平台返回 None。
#[cfg(target_os = "macos")]
pub(super) fn capture_frontmost_app() -> Option<String> {
use objc2::msg_send;
use objc2::runtime::{AnyClass, AnyObject};
unsafe {
let cls = AnyClass::get("NSWorkspace")?;
let workspace: *mut AnyObject = msg_send![cls, sharedWorkspace];
if workspace.is_null() {
return None;
}
let app: *mut AnyObject = msg_send![workspace, frontmostApplication];
if app.is_null() {
return None;
}
let name_obj: *mut AnyObject = msg_send![app, localizedName];
let bundle_obj: *mut AnyObject = msg_send![app, bundleIdentifier];
let name = nsstring_to_string(name_obj);
let bundle = nsstring_to_string(bundle_obj);
match (name, bundle) {
(Some(n), Some(b)) => Some(format!("{n} ({b})")),
(Some(n), None) => Some(n),
(None, Some(b)) => Some(b),
(None, None) => None,
}
}
}
#[cfg(target_os = "macos")]
unsafe fn nsstring_to_string(ns_string: *mut objc2::runtime::AnyObject) -> Option<String> {
use objc2::msg_send;
if ns_string.is_null() {
return None;
}
let utf8: *const std::os::raw::c_char = unsafe { msg_send![ns_string, UTF8String] };
if utf8.is_null() {
return None;
}
let cstr = unsafe { std::ffi::CStr::from_ptr(utf8) };
let s = cstr.to_string_lossy().into_owned();
if s.is_empty() {
None
} else {
Some(s)
}
}
#[cfg(target_os = "windows")]
pub(super) fn capture_frontmost_app() -> Option<String> {
use windows::Win32::UI::WindowsAndMessaging::{
GetForegroundWindow, GetWindowTextLengthW, GetWindowTextW,
};
unsafe {
let hwnd = GetForegroundWindow();
if hwnd.0.is_null() {
return None;
}
let len = GetWindowTextLengthW(hwnd);
if len <= 0 {
return None;
}
let mut buf = vec![0u16; (len + 1) as usize];
let copied = GetWindowTextW(hwnd, &mut buf);
if copied <= 0 {
return None;
}
let title = String::from_utf16_lossy(&buf[..copied as usize]);
if title.is_empty() {
None
} else {
Some(title)
}
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub(super) fn capture_frontmost_app() -> Option<String> {
None
}
#[cfg(target_os = "windows")]
pub(super) fn restore_focus_target_if_possible(target: Option<usize>) -> bool {
use std::ffi::c_void;
use windows::Win32::Foundation::HWND;
use windows::Win32::UI::WindowsAndMessaging::{
GetForegroundWindow, IsIconic, IsWindow, SetForegroundWindow, ShowWindow, SW_RESTORE,
};
let Some(raw_target) = target else {
log::warn!("[coord] no original Windows insertion target captured");
return false;
};
let hwnd = HWND(raw_target as *mut c_void);
if hwnd.0.is_null() {
return false;
}
if !unsafe { IsWindow(hwnd).as_bool() } {
log::warn!("[coord] original Windows insertion target is no longer a valid window");
return false;
}
let foreground = unsafe { GetForegroundWindow() };
if foreground == hwnd {
return true;
}
if unsafe { IsIconic(hwnd).as_bool() } {
let _ = unsafe { ShowWindow(hwnd, SW_RESTORE) };
}
let _ = unsafe { SetForegroundWindow(hwnd) };
std::thread::sleep(std::time::Duration::from_millis(60));
let foreground = unsafe { GetForegroundWindow() };
if foreground != hwnd {
log::warn!("[coord] failed to restore original Windows insertion target before paste");
return false;
}
true
}
#[cfg(not(target_os = "windows"))]
pub(super) fn restore_focus_target_if_possible(_target: Option<usize>) -> bool {
true
}
#[cfg(target_os = "windows")]
pub(super) fn windows_hwnd_is_present(hwnd: windows::Win32::Foundation::HWND) -> bool {
hwnd != windows::Win32::Foundation::HWND::default()
}
#[cfg(target_os = "windows")]
pub(super) fn capture_ime_submit_target() -> Option<ImeSubmitTarget> {
use windows::Win32::UI::WindowsAndMessaging::{
GetForegroundWindow, GetGUIThreadInfo, GetWindowThreadProcessId, GUITHREADINFO,
};
let foreground = unsafe { GetForegroundWindow() };
if !windows_hwnd_is_present(foreground) {
return None;
}
let mut foreground_process_id = 0;
let foreground_thread_id =
unsafe { GetWindowThreadProcessId(foreground, Some(&mut foreground_process_id)) };
if foreground_thread_id == 0 {
return None;
}
let mut gui_info = GUITHREADINFO {
cbSize: std::mem::size_of::<GUITHREADINFO>() as u32,
..Default::default()
};
let target_window = if unsafe { GetGUIThreadInfo(foreground_thread_id, &mut gui_info).is_ok() }
&& windows_hwnd_is_present(gui_info.hwndFocus)
{
gui_info.hwndFocus
} else {
foreground
};
let mut process_id = 0;
let thread_id = unsafe { GetWindowThreadProcessId(target_window, Some(&mut process_id)) };
if process_id == 0 || thread_id == 0 {
return None;
}
Some(ImeSubmitTarget {
process_id,
thread_id,
})
}
// Windows topmost overlay 的已知 OS 级限制(issue #457):
// `SetWindowPos(HWND_TOPMOST)` 让 capsule 在普通桌面合成、最大化窗口、borderless
// windowed fullscreen 上正常叠加;但**对独占全屏(exclusive fullscreen)DirectX /
// OpenGL 应用无效** —— 那条路径绕过桌面合成器,标准 topmost 窗口不参与合成 →
// 用户看不见 capsule。这是 OS 层面的限制,用户空间无法绕过(除非接入 DirectX
// overlay,工程量与风险都不在 surgical 修复范围内)。
//
// 用户侧 workaround:把游戏切到 borderless windowed fullscreen(Minecraft Java 默认
// 即是;F11 在不同版本表现不一致,按设置里的「全屏」选项决定)。
//
// 相关 UIPI 限制:若游戏以管理员身份运行而 OpenLess 不是,`WH_KEYBOARD_LL` 收不到
// 游戏的按键 → hotkey 完全不触发。这里跟 SetWindowPos 路径无关,但同源不可绕过。
#[cfg(target_os = "windows")]
pub(super) fn show_capsule_window_no_activate<R: tauri::Runtime>(
_app: &AppHandle<R>,
window: &tauri::WebviewWindow<R>,
) -> bool {
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use windows::Win32::Foundation::HWND;
use windows::Win32::UI::WindowsAndMessaging::{
SetWindowPos, ShowWindow, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE,
SWP_SHOWWINDOW, SW_SHOWNOACTIVATE,
};
let Ok(handle) = window.window_handle() else {
// #470 诊断 v2:Win32 show 路径最可能的暗点之一。此前静默 return,
// 无法观测「胶囊完全不显示」是否卡在这里。
log::warn!(
"[capsule] no_activate failed: window_handle() unavailable — Win32 show skipped"
);
return false;
};
let RawWindowHandle::Win32(raw) = handle.as_raw() else {
log::warn!("[capsule] no_activate failed: non-Win32 RawWindowHandle — Win32 show skipped");
return false;
};
let hwnd = HWND(raw.hwnd.get() as *mut _);
let _ = unsafe { ShowWindow(hwnd, SW_SHOWNOACTIVATE) };
let _ = unsafe {
SetWindowPos(
hwnd,
HWND_TOPMOST,
0,
0,
0,
0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW,
)
};
true
}
#[cfg(target_os = "macos")]
pub(super) fn show_capsule_window_no_activate<R: tauri::Runtime>(
_app: &AppHandle<R>,
window: &tauri::WebviewWindow<R>,
) -> bool {
use objc2::msg_send;
use objc2::runtime::AnyObject;
let Ok(handle) = window.ns_window() else {
return false;
};
let ns_window = handle as *mut AnyObject;
if ns_window.is_null() {
return false;
}
// emit_capsule 已经把窗口操作 marshal 到 Tauri 主线程;这里不能调用
// window.show()/set_focus()/NSApp.activate,否则 AeroSpace 会把 workspace 切回
// OpenLess 主窗口所在空间。直接用 orderFrontRegardless 做无激活展示。
//
// collectionBehavior 一次性写绝对值(与 show_less_computer_glow 的 273 同款),
// 不再走 Tauri 的 set_visible_on_all_workspaces:那个调用会把 collectionBehavior
// 经事件循环延后再写一遍,盖掉这里手动加的 FULL_SCREEN_AUXILIARY(→ 全屏 app 上不
// 叠加);而把新 bit OR 到旧的 Managed 上又是 Apple 文档明确互斥的非法组合
// (CanJoinAllSpaces / Managed / Transient 三选一,→ 切桌面跟随不稳)。glow 窗口从不
// 调它、直接写绝对值,跨 Space + 全屏都正常 —— 胶囊对齐它。
// - CAN_JOIN_ALL_SPACES:出现在所有桌面/Space,切桌面/全屏时跟随。
// - FULL_SCREEN_AUXILIARY:被允许进入全屏 app 的 Space。
// - STATIONARY:Mission Control / Exposé 时不跟着乱飞。
// 外加 setLevel(25):光有 FULL_SCREEN_AUXILIARY 只是「被允许」进全屏 Space,但窗口层级
// 若停在 alwaysOnTop 的浮动层(~3) 仍会被全屏 app 的窗口盖住而看不见;抬到菜单栏(24)之上
// 的 25(与 show_less_computer_glow 同款)才能真正叠在全屏之上。
unsafe {
const CAN_JOIN_ALL_SPACES: usize = 1 << 0;
const STATIONARY: usize = 1 << 4;
const FULL_SCREEN_AUXILIARY: usize = 1 << 8;
let behavior = CAN_JOIN_ALL_SPACES | STATIONARY | FULL_SCREEN_AUXILIARY;
let _: () = msg_send![ns_window, setLevel: 25i64];
let _: () = msg_send![ns_window, setCollectionBehavior: behavior];
let _: () = msg_send![ns_window, orderFrontRegardless];
}
true
}
#[cfg(target_os = "linux")]
pub(super) fn show_capsule_window_no_activate<R: tauri::Runtime>(
_app: &AppHandle<R>,
_window: &tauri::WebviewWindow<R>,
) -> bool {
true
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
pub(super) fn show_capsule_window_no_activate<R: tauri::Runtime>(
_app: &AppHandle<R>,
_window: &tauri::WebviewWindow<R>,
) -> bool {
false
}
#[cfg(target_os = "windows")]
pub(super) fn hide_capsule_window_if_present() {
use std::iter::once;
use windows::core::PCWSTR;
use windows::Win32::Foundation::HWND;
use windows::Win32::UI::WindowsAndMessaging::{
FindWindowW, SetWindowPos, ShowWindow, HWND_NOTOPMOST, SWP_HIDEWINDOW, SWP_NOACTIVATE,
SWP_NOMOVE, SWP_NOSIZE, SW_HIDE,
};
let title: Vec<u16> = "OpenLess Capsule".encode_utf16().chain(once(0)).collect();
let hwnd = match unsafe { FindWindowW(PCWSTR::null(), PCWSTR(title.as_ptr())) } {
Ok(hwnd) => hwnd,
Err(_) => return,
};
if hwnd == HWND::default() || hwnd.0.is_null() {
return;
}
let _ = unsafe { ShowWindow(hwnd, SW_HIDE) };
let _ = unsafe {
SetWindowPos(
hwnd,
HWND_NOTOPMOST,
0,
0,
0,
0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_HIDEWINDOW,
)
};
}
#[cfg(not(target_os = "windows"))]
pub(super) fn hide_capsule_window_if_present() {}
pub(super) fn emit_capsule(
inner: &Arc<Inner>,
state: CapsuleState,
level: f32,
elapsed_ms: u64,
message: Option<String>,
inserted_chars: Option<u32>,
) {
// 在 app 句柄校验之前记录,便于无 GUI 的测试断言「按下热键 → 弹了哪种胶囊」。
*inner.last_capsule_state.lock() = Some(state);
let app_opt = inner.app.lock().clone();
let Some(app) = app_opt else { return };
let translation = inner.translation_modifier_seen.load(Ordering::SeqCst);
let operating = inner.state.lock().voice_agent;
let payload = CapsulePayload {
state,
level,
elapsed_ms,
message,
inserted_chars,
translation,
operating,
};
#[cfg(target_os = "android")]
crate::android::notify_capsule_state(&payload);
// visible / translation 是「这一帧 capsule:state event 的 payload」内容 ——
// 必须在 call-site(即音频线程触发 emit_capsule 时)就算定,否则 main thread
// 闭包里读到的将是「下一帧」的 state,跟实际下发给 JS 的 payload 不一致。
let visible = !matches!(state, CapsuleState::Idle);
// Linux: 通过 fcitx5 插件在候选词列表下方显示听写状态,不干扰输入法预编辑。
// 只在文本变化时调用 DBus,避免录音中 ~30Hz 的音频电平回调重复调用。
#[cfg(target_os = "linux")]
{
use std::sync::Mutex;
static LAST_AUX: Mutex<Option<String>> = Mutex::new(None);
let aux = match state {
CapsuleState::Idle => None,
CapsuleState::Recording => Some("🎤 收音中..."),
CapsuleState::Transcribing => Some("🔄 识别中..."),
CapsuleState::Polishing => Some("✨ 润色中..."),
CapsuleState::Done => Some("✅ 已插入"),
CapsuleState::Cancelled => Some("— 已取消"),
CapsuleState::Error => Some("❌ 出错"),
};
let mut last = LAST_AUX.lock().unwrap();
if aux != last.as_deref() {
*last = aux.map(String::from);
// 代数计数器:每次状态变化 +1,retry 线程只在自己代数仍为最新时生效。
// 避免 Recording→Idle→Recording 快速切换时多个 retry 重复触发。
static RETRY_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
// fetch_add 返回旧值,所以 latest_gen > gen+1 才表示"在我之后又发生了变更"。
let gen = RETRY_GEN.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
match aux {
Some(t) => {
log::info!("[capsule] set_aux_down: {t} gen={gen}");
let text = t.to_string();
std::thread::spawn(move || {
let current = LAST_AUX.lock().unwrap().clone();
if current.as_deref() != Some(&text) {
log::info!(
"[capsule] set_aux_down skipped: state changed to {current:?}"
);
return;
}
if let Err(e) = crate::linux_fcitx::set_aux_down(&text) {
log::warn!("[capsule] set_aux_down failed: {e}");
}
});
// 终态(Done/Cancelled/Error)3 秒后自动清除,避免一直跟随焦点。
if matches!(
state,
CapsuleState::Done | CapsuleState::Cancelled | CapsuleState::Error
) {
let text = t.to_string();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(3));
let latest_gen = RETRY_GEN.load(std::sync::atomic::Ordering::SeqCst);
if latest_gen > gen + 1 {
return;
}
let current = LAST_AUX.lock().unwrap().clone();
if current.as_deref() != Some(&text) {
return;
}
log::info!("[capsule] auto-clear terminal state: {text}");
let _ = crate::linux_fcitx::set_aux_down("");
*LAST_AUX.lock().unwrap() = None;
});
}
}
None => {
log::info!("[capsule] clear_aux_down gen={gen}");
std::thread::spawn(move || {
let latest_gen = RETRY_GEN.load(std::sync::atomic::Ordering::SeqCst);
if latest_gen > gen + 1 {
log::info!(
"[capsule] clear_aux_down skipped: gen {gen}, latest {latest_gen}"
);
return;
}
let current = LAST_AUX.lock().unwrap().clone();
if current.is_some() {
log::info!(
"[capsule] clear_aux_down skipped: state changed to {current:?}"
);
return;
}
if let Err(e) = crate::linux_fcitx::clear_aux_down() {
log::warn!("[capsule] clear_aux_down failed: {e}");
}
});
}
}
}
}
// emit_capsule 会被 cpal process_callback(音频回调线程)调用 ~30 Hz —— 在该
// 线程上调用 NSWindow / HWND API 会撞 macOS dispatch_assert_queue_fail SIGTRAP
// 或者 Win32 SendMessage 死锁。把 window.show/hide + 位置调整 marshal 到主线程;
// app.emit_to 走 Tauri 内部事件总线,本身线程安全,保留同步调用。详见 audit 3.2.2。
//
// show_capsule(用户偏好)在主线程执行时再读 —— 用户可以在录音过程中改设置,
// 闭包入队到真正跑之间窗口上限是一两帧(~16-33ms),用最新值消除 stale-pref
// 闪烁。pr_agent 关注点 — 见 audit follow-up。
let inner_for_main = Arc::clone(inner);
let app_for_main = app.clone();
let _ = app.run_on_main_thread(move || {
let Some(window) = app_for_main.get_webview_window("capsule") else {
// #470 诊断 v2:比 A/B/C 更靠前的暗点 A0 —— capsule webview 句柄取不到
// (窗口未创建/已销毁)。此前静默 return,无法观测。一次性 warn。
if !CAPSULE_WINDOW_MISSING_LOGGED.swap(true, Ordering::SeqCst) {
log::warn!(
"[capsule] capsule webview window not found — emit_capsule show path skipped (state={})",
capsule_state_log_name(state)
);
}
return;
};
let show_capsule = inner_for_main.prefs.get().show_capsule;
// Linux: 不操作胶囊窗口(不 show/hide,不 reposition)。
// 文字通过 fcitx5 插件直接 commit,用户始终在目标 app 中。
#[cfg(target_os = "linux")]
{
return;
}
#[cfg(not(target_os = "linux"))]
{
// 三平台统一:Done / Cancelled / Error 状态保留 ~1.5s toast
// (schedule_capsule_idle 之后会回 Idle 隐藏)。
// Windows 上 linger 的真实问题(截图选中 / 死区 / 拖拽卡顿)由 #140 加的
// `hide_capsule_window_if_present()` Win32 hard-hide 在 visible=false 分支
// 处理,不依赖把 Done/Cancelled/Error 打成 invisible。详见 PR #140 评论。
maybe_position_capsule_bottom_center(&inner_for_main, &window, translation);
if show_capsule && visible {
// 用户报"看不到胶囊"时第一时间能在 log 里确认:胶囊路径有跑、show_capsule
// 开关是 true、当前进入 visible 帧 —— 排除 prefs 没存住 / emit_capsule 没触
// 发 / state 一直 Idle 这几类常见 root cause。issue #470。
if !CAPSULE_FIRST_SHOW_LOGGED.swap(true, Ordering::SeqCst) {
log::info!(
"[capsule] first show this session: show_capsule=true visible=true state={}",
capsule_state_log_name(state)
);
}
show_capsule_window_for_recording(&app_for_main, &window);
// macOS/Windows 优先走 no-activate show,避免录音胶囊抢走当前工作 app 焦点。
// 若 fallback 到 show(),OpenLess 已是前台 app 时再把 key window 还给 main。
#[cfg(target_os = "macos")]
crate::restore_main_window_key_if_active(&app_for_main);
} else {
// show_capsule 开关被用户关掉但本次确实想显示(visible=true)的情况:
// 一次性 info log,让用户报"胶囊没显示"时能在日志里一眼看到根因 —— 维护者
// 不必再让用户"去打开设置确认"。issue #470。
if !show_capsule
&& visible
&& !CAPSULE_SUPPRESSED_BY_TOGGLE_LOGGED.swap(true, Ordering::SeqCst)
{
log::info!(
"[capsule] suppressed by user toggle: show_capsule=false visible=true state={}",
capsule_state_log_name(state)
);
}
hide_capsule_window_if_present();
let _ = window.hide();
}
}
});
let _ = app.emit_to("capsule", "capsule:state", &payload);
// 主窗口也需要 capsule:state 事件:AudioCueListener 用它触发录音提示音。
// Linux 上胶囊隐藏时提示音仍应工作,所以同时发给 main 窗口。
let _ = app.emit_to("main", "capsule:state", &payload);
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct CapsuleLayoutState {
translation_active: bool,
monitor_x: i32,
monitor_y: i32,
monitor_width: u32,
monitor_height: u32,
scale_bits: u64,
}
/// 返回胶囊「应该摆放到的显示器」的标识信息。
///
/// 它看的显示器必须和 `position_capsule_bottom_center` 实际定位用的一致:
/// Windows 看「正在输入的 App 所在显示器」,其它平台看胶囊自己的显示器。
/// 这是「是否需要重新定位」去重缓存(`maybe_position_capsule_bottom_center`)
/// 的 key,如果这里看错了显示器,就会出现「输入焦点移到另一块屏、胶囊却没
/// 跟过去」的 bug。
pub(super) fn capsule_layout_snapshot<R: tauri::Runtime>(
window: &tauri::WebviewWindow<R>,
translation_active: bool,
) -> Option<CapsuleLayoutState> {
// Windows:以「正在输入的 App 所在显示器」为基准。若用胶囊自己的
// current_monitor,输入焦点切到另一块屏时胶囊仍在原屏 → 误判「没变化」
// → 跳过重新定位。
#[cfg(target_os = "windows")]
{
if let Some(mon) = crate::foreground_window_monitor() {
return Some(CapsuleLayoutState {
translation_active,
monitor_x: mon.left,
monitor_y: mon.top,
monitor_width: (mon.right - mon.left).max(0) as u32,
monitor_height: (mon.bottom - mon.top).max(0) as u32,
scale_bits: mon.scale.to_bits(),
});
}
// 仅当 Win32 取不到前台显示器时,落回下面的 current_monitor。
}
let monitor = window.current_monitor().ok().flatten()?;
Some(CapsuleLayoutState {
translation_active,
monitor_x: monitor.position().x,
monitor_y: monitor.position().y,
monitor_width: monitor.size().width,
monitor_height: monitor.size().height,
scale_bits: monitor.scale_factor().to_bits(),
})
}
pub(super) fn maybe_position_capsule_bottom_center<R: tauri::Runtime>(
inner: &Arc<Inner>,
window: &tauri::WebviewWindow<R>,
translation_active: bool,
) {
let Some(next) = capsule_layout_snapshot(window, translation_active) else {
return;
};
{
let last = inner.capsule_layout.lock();
if last.as_ref() == Some(&next) {
return;
}
}
if crate::position_capsule_bottom_center(window, translation_active).is_ok() {
let mut last = inner.capsule_layout.lock();
*last = Some(next);
}
}
@@ -2406,6 +2406,16 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
) {
log::error!("[coord] history append failed: {e}");
}
// 远程输入:把本次最终文字回传给手机端。remote_server 的 WS handler 订阅了
// "remote:result"(mod.rs:614),但此前全仓从未 emit,导致手机结果区永远空(#691)。
// 与上面的 vocab:updated 同模式:无手机连接时无人转发 = 无害空操作。
if !polished.trim().is_empty() {
if let Some(app) = inner.app.lock().clone() {
let _ = app.emit("remote:result", polished.clone());
}
}
let done_message = if tsf_required_insert_failed {
Some("TSF 未上屏,已禁止非 TSF 兜底".to_string())
} else {
@@ -2505,6 +2515,12 @@ fn append_typed_prefix(target: &mut String, delta: &str, typed_chars: usize) ->
appended
}
/// 多轮上下文最多回看的历史轮数。时间窗口(polish_context_window_minutes)只限"多久内",
/// 不限"多少条"——5 分钟内堆积几十条历史时,全部前置进 LLM 会让输入 token 暴涨、首字延迟
/// (TTFT)显著变长,影响全体用户(#678)。取最近 2 轮即可保留代词/续写所需的对话连续性,
/// 同时把上下文 token 控制在常数量级。sessions 为 newest-first,`.take` 即取最近若干轮。
const MAX_POLISH_CONTEXT_TURNS: usize = 2;
fn eligible_polish_context_turns(
sessions: Vec<DictationSession>,
active_style_pack_id: &str,
@@ -2533,6 +2549,8 @@ fn eligible_polish_context_turns(
Some((s.raw_transcript, s.final_text))
}
})
// 限制条数:sessions newest-first,过滤后取最近 MAX_POLISH_CONTEXT_TURNS 轮(#678)。
.take(MAX_POLISH_CONTEXT_TURNS)
.collect()
}
@@ -2585,6 +2603,28 @@ mod tests {
}
}
#[test]
fn polish_context_caps_at_max_turns_keeping_most_recent() {
// sessions newest-first:超过上限时只保留最近 MAX_POLISH_CONTEXT_TURNS 轮(#678)。
let sessions = vec![
history_session("t1", "raw1", "final1", Some("pack.id"), false, None),
history_session("t2", "raw2", "final2", Some("pack.id"), false, None),
history_session("t3", "raw3", "final3", Some("pack.id"), false, None),
history_session("t4", "raw4", "final4", Some("pack.id"), false, None),
];
let turns = eligible_polish_context_turns(sessions, "pack.id", false);
assert_eq!(turns.len(), super::MAX_POLISH_CONTEXT_TURNS);
assert_eq!(
turns,
vec![
("raw1".to_string(), "final1".to_string()),
("raw2".to_string(), "final2".to_string()),
]
);
}
#[test]
fn polish_context_resets_when_active_style_pack_changes() {
let sessions = vec![
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,362 @@
//! Polish / translate orchestration extracted from `coordinator.rs`
//! (behavior-preserving move).
//!
//! The streaming/one-shot polish entry points and the polish+translate combiner.
//! References parent items via `use super::*;`; `pub(super)` so the parent and
//! sibling submodules (e.g. `dictation`) reach them through `use polish_flow::*;`.
use super::*;
/// 润色文本;失败时返回原文 + 失败原因,调用方据此弹错误胶囊 + 写历史 error_code。
/// 之前固定返回 String,调用方拿不到失败信号 → 用户感知"为什么风格设置没生效"。issue #57。
/// 流式润色的三态结果。让上层(dictation pipeline)能区分「已经流出去了」、
/// 「降级到一次性」和「真失败了走 raw 兜底」三种 case。
pub enum StreamingPolishOutcome {
/// 流式润色成功,`String` 是已经一边流一边交给 `on_delta` 的全部文本(用于写
/// history、做词条命中统计)。调用方不应再 `inserter.insert(&text)`,因为字符
/// 已经通过键盘事件落到光标处。
Streamed(String),
/// 当前配置不支持流式:用户没开 streaming_insert / Gemini provider / Codex
/// provider / Raw 模式 / 翻译模式 / 不是 macOS。调用方应回到现有的
/// `polish_or_passthrough` 一次性路径,跟历史行为完全一致。
UnsupportedFallback,
/// 流式过程中失败(HTTP / 解析 / 空流等)。`String` 是失败原因,调用方应当
/// 走 raw 兜底(同 `polish_or_passthrough` 失败分支的语义)。
Failed(String),
}
/// 流式润色入口。在不支持流式的所有 case 都返回 `UnsupportedFallback`,让调用方
/// 透明降级。不修改任何持久化 / 焦点 / 光标状态。
///
/// `on_delta` 每收到一个 SSE chunk 就被调用一次(同步),调用方负责把 chunk 实际
/// 模拟键盘事件落到光标 —— 见 `coordinator/dictation.rs` 的流式分支。
/// `should_cancel` 用户取消时返回 true,立即 break SSE 读循环避免烧 quota。
pub async fn polish_or_passthrough_streaming<F, C>(
raw: &RawTranscript,
mode: PolishMode,
hotwords: &[String],
style_system_prompt: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
llm_thinking_enabled: bool,
front_app: Option<&str>,
prior_turns: &[(String, String)],
on_delta: F,
should_cancel: C,
) -> StreamingPolishOutcome
where
F: Fn(&str) + Send + Sync,
C: Fn() -> bool + Send + Sync,
{
if mode == PolishMode::Raw && !raw_mode_uses_llm(style_system_prompt) {
log::info!("[coord] streaming polish skipped: mode=Raw, fall back to one-shot");
return StreamingPolishOutcome::UnsupportedFallback;
}
let active_llm = CredentialsVault::get_active_llm();
if active_llm == "gemini" {
log::info!(
"[coord] streaming polish skipped: active LLM provider=gemini (v1 not implemented), fall back to one-shot"
);
return StreamingPolishOutcome::UnsupportedFallback;
}
let provider = match build_active_llm_provider(llm_thinking_enabled) {
Ok(p) => p,
Err(e) => {
log::error!("[coord] streaming polish: build provider failed: {e}");
return StreamingPolishOutcome::Failed(e.to_string());
}
};
if !provider.supports_streaming_polish() {
log::info!(
"[coord] streaming polish skipped: provider does not support streaming (likely codex OAuth), fall back to one-shot"
);
return StreamingPolishOutcome::UnsupportedFallback;
}
log::info!(
"[coord] streaming polish START: provider=openai-compatible mode={:?} raw_chars={} prior_turns={}",
mode,
raw.text.chars().count(),
prior_turns.len()
);
match provider
.polish_streaming(
&raw.text,
mode,
hotwords,
style_system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
prior_turns,
on_delta,
should_cancel,
)
.await
{
Ok(text) => {
log::info!(
"[coord] streaming polish OK: final_chars={}",
text.chars().count()
);
StreamingPolishOutcome::Streamed(text)
}
Err(e) => {
let reason = e.to_string();
log::error!("[coord] streaming polish FAILED: {reason}");
StreamingPolishOutcome::Failed(reason)
}
}
}
pub(super) async fn polish_or_passthrough(
raw: &RawTranscript,
mode: PolishMode,
hotwords: &[String],
style_system_prompt: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
llm_thinking_enabled: bool,
front_app: Option<&str>,
prior_turns: &[(String, String)],
) -> (String, Option<String>) {
if mode == PolishMode::Raw && !raw_mode_uses_llm(style_system_prompt) {
return (raw.text.clone(), None);
}
match polish_text(
&raw.text,
mode,
hotwords,
style_system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
llm_thinking_enabled,
front_app,
prior_turns,
)
.await
{
Ok(s) => (s, None),
Err(e) => {
let reason = e.to_string();
log::error!("[coord] polish failed, falling back to raw: {reason}");
(raw.text.clone(), Some(reason))
}
}
}
pub(super) async fn polish_text(
raw: &str,
mode: PolishMode,
hotwords: &[String],
style_system_prompt: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
llm_thinking_enabled: bool,
front_app: Option<&str>,
prior_turns: &[(String, String)],
) -> anyhow::Result<String> {
// 谷歌 Gemini 分支:所有 LLM provider 共用 ark.* 凭据槽,唯独 Gemini 走原生
// generateContent / 自带 thinkingConfig 控制;其余 provider 走 OpenAI
// 兼容协议,并在该路径里按 provider/channel 下发对应的思考开关。
let active_llm = CredentialsVault::get_active_llm();
if active_llm == "gemini" {
let (api_key, model, base_url) = read_gemini_credentials()?;
let provider = GeminiProvider::new(
GeminiConfig::new(api_key, model, base_url).with_thinking_enabled(llm_thinking_enabled),
);
return Ok(provider
.polish(
raw,
mode,
hotwords,
style_system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
prior_turns,
)
.await?);
}
let provider = build_active_llm_provider(llm_thinking_enabled)?;
Ok(provider
.polish(
raw,
mode,
hotwords,
style_system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
prior_turns,
)
.await?)
}
/// 专用翻译(仅翻译、不润色、单轮)。现作为"润色+翻译"合成调用解析失败时的兜底——
/// 模型没按两段格式输出时,退回这里拿一段干净译文,而不是把畸形输出当译文插入。
pub(super) async fn translate_text(
raw: &str,
target_language: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
llm_thinking_enabled: bool,
front_app: Option<&str>,
) -> anyhow::Result<String> {
// 见 polish_text 顶部注释——同样的 Gemini / OpenAI-compatible 路由逻辑。
let active_llm = CredentialsVault::get_active_llm();
if active_llm == "gemini" {
let (api_key, model, base_url) = read_gemini_credentials()?;
let provider = GeminiProvider::new(
GeminiConfig::new(api_key, model, base_url).with_thinking_enabled(llm_thinking_enabled),
);
return Ok(provider
.translate_to(
raw,
target_language,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
)
.await?);
}
let provider = build_active_llm_provider(llm_thinking_enabled)?;
Ok(provider
.translate_to(
raw,
target_language,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
)
.await?)
}
/// "润色+翻译"单次调用的两段哨兵。模型按 `SRC\n源文\nTGT\n译文` 输出,解析器据此切分。
/// 这两个串必须与 build_polish_translate_system_prompt 写给模型的完全一致。
pub(super) const POLISH_TRANSLATE_SRC_MARKER: &str = "[[OPENLESS_POLISHED_SOURCE]]";
pub(super) const POLISH_TRANSLATE_TGT_MARKER: &str = "[[OPENLESS_TRANSLATION]]";
/// 合成"先润色源文、再翻译"的系统提示词:在原翻译 prompt 之上追加"额外输出润色后源文"
/// 与严格两段格式(覆盖原 prompt 末尾的"只输出译文")。译文仍是要插入用户光标的主产物,
/// 故完整保留原翻译规则;润色后的源文只作对话上下文用,轻量清理即可。
pub(super) fn build_polish_translate_system_prompt(target_language: &str) -> String {
let base = crate::polish::prompts::translate_system_prompt(target_language);
format!(
"{base}\n\n\
# 额外输出:润色后的源文(仅用于对话上下文,不展示给用户)\n\
在译文之前,先把上面的原始转写**按它本来的语言**润色一遍:去掉口癖(嗯 / 那个 / um)、\
补必要标点、纠正明显的识别错误,但**不翻译、不改写风格、不增删意思**。\n\n\
# 输出格式(覆盖上面\u{201C}只输出译文\u{201D}的说明,严格遵守)\n\
严格按下面两段输出,两个标记必须原样出现、各占一行,标记之外不要有任何多余文字:\n\
{src}\n\
(这里放润色后的源文,保持原语言)\n\
{tgt}\n\
(这里放翻译成\u{300C}{lang}\u{300D}的译文)",
base = base,
src = POLISH_TRANSLATE_SRC_MARKER,
tgt = POLISH_TRANSLATE_TGT_MARKER,
lang = target_language,
)
}
/// 解析"润色+翻译"单次调用输出 → Some((润色后源文, 译文))。
/// 找到译文标记且译文非空 → Some((源文, 译文)):源文标记缺失 / 源文段为空时源文为 None,
/// 译文取标记之后的干净正文。**没有译文标记、或译文段为空(模型截断 / 只吐了标记)→ None**,
/// 表示没拿到可信译文,交由调用方退回专用翻译——避免把空串当"成功译文"插进光标而丢字。
pub(super) fn split_polish_translate_output(raw: &str) -> Option<(Option<String>, String)> {
let tgt_idx = raw.find(POLISH_TRANSLATE_TGT_MARKER)?;
let translation = raw[tgt_idx + POLISH_TRANSLATE_TGT_MARKER.len()..]
.trim()
.to_string();
if translation.is_empty() {
return None;
}
let before_tgt = &raw[..tgt_idx];
let source = before_tgt
.find(POLISH_TRANSLATE_SRC_MARKER)
.map(|i| {
before_tgt[i + POLISH_TRANSLATE_SRC_MARKER.len()..]
.trim()
.to_string()
})
.filter(|s| !s.is_empty());
Some((source, translation))
}
/// 翻译路径——单次 LLM 调用同时润色源文 + 翻译。和 polish 一样失败时返回原文 + 失败原因,
/// 避免"不丢字"约定被违反(CLAUDE.md)。返回 (要插入的译文, 润色后源文供上下文用, 失败原因)。
#[allow(clippy::too_many_arguments)]
pub(super) async fn polish_and_translate_or_passthrough(
raw: &RawTranscript,
target_language: &str,
mode: PolishMode,
hotwords: &[String],
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
llm_thinking_enabled: bool,
front_app: Option<&str>,
prior_turns: &[(String, String)],
) -> (String, Option<String>, Option<String>) {
let system_prompt = build_polish_translate_system_prompt(target_language);
match polish_text(
&raw.text,
mode,
hotwords,
&system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
llm_thinking_enabled,
front_app,
prior_turns,
)
.await
{
Ok(out) => match split_polish_translate_output(&out) {
Some((source, translation)) => (translation, source, None),
None => {
// 模型没按两段格式输出:退回专用翻译拿一段干净译文,避免把畸形输出插进光标。
// 此时无可信源文,这条翻译历史不参与后续普通润色上下文。
log::warn!(
"[coord] polish+translate output missing markers; falling back to plain translate"
);
match translate_text(
&raw.text,
target_language,
working_languages,
chinese_script_preference,
output_language_preference,
llm_thinking_enabled,
front_app,
)
.await
{
Ok(translation) => (translation, None, None),
Err(e) => {
let reason = e.to_string();
log::error!("[coord] fallback translate failed, using raw: {reason}");
(raw.text.clone(), None, Some(reason))
}
}
}
},
Err(e) => {
let reason = e.to_string();
log::error!("[coord] polish+translate failed, falling back to raw: {reason}");
(raw.text.clone(), None, Some(reason))
}
}
}
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -92,8 +92,8 @@ impl TextInserter {
pub fn insert(
&self,
text: &str,
restore_clipboard_after_paste: bool,
paste_shortcut: PasteShortcut,
_restore_clipboard_after_paste: bool,
_paste_shortcut: PasteShortcut,
) -> InsertStatus {
if text.is_empty() {
return InsertStatus::CopiedFallback;
+74 -7
View File
@@ -2362,6 +2362,12 @@ pub(crate) struct ForegroundMonitor {
pub(crate) top: i32,
pub(crate) right: i32,
pub(crate) bottom: i32,
/// 工作区矩形(physical px,去掉任务栏)。多端一致:胶囊优先夹到工作区内,
/// 避免压住任务栏。取不到时回退为整屏矩形。issue #470。
pub(crate) work_left: i32,
pub(crate) work_top: i32,
pub(crate) work_right: i32,
pub(crate) work_bottom: i32,
/// 该显示器的有效 DPI 缩放(1.0 = 96dpi)。
pub(crate) scale: f64,
}
@@ -2399,6 +2405,10 @@ pub(crate) fn foreground_window_monitor() -> Option<ForegroundMonitor> {
top: mi.rcMonitor.top,
right: mi.rcMonitor.right,
bottom: mi.rcMonitor.bottom,
work_left: mi.rcWork.left,
work_top: mi.rcWork.top,
work_right: mi.rcWork.right,
work_bottom: mi.rcWork.bottom,
scale: (dpi_x as f64 / 96.0).max(0.1),
})
}
@@ -2431,15 +2441,24 @@ pub(crate) fn position_capsule_bottom_center<R: tauri::Runtime>(
let offset_from_bottom =
(capsule_visual_height(translation_active) + 80.0 + bounds.bottom_inset) * scale;
let y = ((mon.bottom as f64) - offset_from_bottom).round() as i32;
let clamped_y = y.max(mon.top);
// #470 诊断 v2:当前只夹了上边(.max(mon.top)),未夹下/左/右。多显示器、
// 负坐标或异常 DPI 下胶囊可能被算到屏幕外却无任何观测。记录显示器几何与
// 最终落点,用于证伪/证实「胶囊定位到屏幕外」(C 子嫌疑)。
// #470:四边都夹到「工作区」内(去掉任务栏),保证整窗可见。GetMonitorInfoW
// 取不到 rcWork 时(理论上不会,rcWork 总随 rcMonitor 一同填)退回整屏矩形。
let (work_l, work_t, work_r, work_b) =
if mon.work_right > mon.work_left && mon.work_bottom > mon.work_top {
(mon.work_left, mon.work_top, mon.work_right, mon.work_bottom)
} else {
(mon.left, mon.top, mon.right, mon.bottom)
};
let (clamped_x, clamped_y) =
clamp_to_monitor(x, y, phys_w, phys_h, work_l, work_t, work_r, work_b);
log::debug!(
"[capsule] win position: mon=({},{})..({},{}) scale={:.2} size=({}x{}) -> x={} y={} clamped_y={}",
mon.left, mon.top, mon.right, mon.bottom, scale, phys_w, phys_h, x, y, clamped_y
"[capsule] win position: mon=({},{})..({},{}) work=({},{})..({},{}) scale={:.2} size=({}x{}) -> raw=({},{}) clamped=({},{})",
mon.left, mon.top, mon.right, mon.bottom,
work_l, work_t, work_r, work_b,
scale, phys_w, phys_h, x, y, clamped_x, clamped_y
);
window.set_position(PhysicalPosition::new(x, clamped_y))?;
window.set_position(PhysicalPosition::new(clamped_x, clamped_y))?;
return Ok(());
}
// 仅当 Win32 取不到前台显示器时,落回下面的 current_monitor 逻辑。
@@ -2730,6 +2749,54 @@ mod tests {
assert_eq!(pos, (610.0, -176.0));
}
// ---- #470: capsule 四边 clamp(纯函数,合成多显示器 / 负原点 / 1.5x DPI 输入)----
#[test]
fn clamp_to_monitor_leaves_on_screen_position_untouched() {
// 1080p 主屏正中偏下,整窗本就可见 → 原样返回。
let (x, y) = clamp_to_monitor(800, 900, 264, 126, 0, 0, 1920, 1040);
assert_eq!((x, y), (800, 900));
}
#[test]
fn clamp_to_monitor_pulls_back_off_screen_right_and_bottom() {
// x/y 算到了屏幕右下外侧 → 收回到「右下角减去窗口尺寸」,整窗仍可见。
let (x, y) = clamp_to_monitor(2000, 1200, 264, 126, 0, 0, 1920, 1040);
assert_eq!((x, y), (1920 - 264, 1040 - 126));
// 整窗右/下边界都落在 area 内。
assert!(x + 264 <= 1920);
assert!(y + 126 <= 1040);
}
#[test]
fn clamp_to_monitor_pushes_into_negative_origin_left_monitor() {
// 副屏在主屏左侧(负 X 原点),落点算到了副屏左外侧 → 夹回 area_left。
// 1.5x DPI 下尺寸偏大,但 area 仍宽于窗口,左上角夹到 (-2560, top)。
let (x, y) = clamp_to_monitor(-3000, -100, 294, 138, -2560, 0, 0, 1440);
assert_eq!(x, -2560);
assert_eq!(y, 0);
// 右/下仍在 area 内。
assert!(x >= -2560 && x + 294 <= 0);
assert!(y >= 0 && y + 138 <= 1440);
}
#[test]
fn clamp_to_monitor_respects_work_area_above_taskbar() {
// 工作区底部 = 1040(任务栏占了 1040..1080)。落点本在任务栏区域(y=1030),
// 应被夹到「工作区底 - 窗口高」之上,胶囊整窗不压任务栏。
let (_x, y) = clamp_to_monitor(800, 1030, 264, 126, 0, 0, 1920, 1040);
assert_eq!(y, 1040 - 126);
assert!(y + 126 <= 1040);
}
#[test]
fn clamp_to_monitor_degrades_gracefully_when_window_wider_than_area() {
// 病态输入:area 比窗口还窄(罕见,但要保证不 panic、不溢出为负超界)。
// max_x 钳到 area_left,clamp 把左上角收回 area_left。
let (x, y) = clamp_to_monitor(500, 500, 800, 600, 0, 0, 400, 300);
assert_eq!((x, y), (0, 0));
}
#[test]
fn oversized_log_rotates_to_single_archive() {
let dir = std::env::temp_dir().join(format!("openless-log-rotate-{}", std::process::id()));
@@ -467,8 +467,3 @@ pub use platform::windows_microphone_access_explicitly_denied;
pub fn windows_microphone_access_explicitly_denied() -> bool {
false
}
/// 兼容老调用:startup 时主动弹 Accessibility 框。
pub fn request_accessibility_with_prompt(_prompt: bool) -> bool {
matches!(request_accessibility(), PermissionStatus::Granted)
}
@@ -1170,10 +1170,6 @@ impl HistoryStore {
self.read_locked()
}
pub fn append(&self, session: DictationSession) -> Result<()> {
self.append_with_retention(session, 0, None)
}
/// `retention_days == 0` 跟旧 append 行为一致(不按时间清理)。
/// `> 0` 时在写入新条目后顺手把超过 N 天的会话裁掉,写入时就完成清理,
/// 不需要后台轮询。最后再受条数上限约束:
+31 -520
View File
@@ -4,7 +4,6 @@
//! 提示词在 `prompts` 模块中维护:使用 `# 角色 / # 任务 / # 通用规则 / # 输出 / # 示例`
//! 段落式结构,每个 mode 有独立的 1-shot 示例。重写背景见 issue #47。
use std::borrow::Cow;
use std::collections::HashMap;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
@@ -16,6 +15,11 @@ use thiserror::Error;
use crate::types::{ChineseScriptPreference, OutputLanguagePreference, PolishMode, QaChatMessage};
mod output_cleaning;
mod prompt_compose;
pub(crate) use output_cleaning::*;
pub(crate) use prompt_compose::*;
const DEFAULT_TEMPERATURE: f32 = 0.3;
const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
const BODY_PREVIEW_LIMIT: usize = 200;
@@ -299,10 +303,6 @@ impl OpenAICompatibleLLMProvider {
Self { config, client }
}
pub fn config(&self) -> &OpenAICompatibleConfig {
&self.config
}
pub async fn polish(
&self,
raw_text: &str,
@@ -924,10 +924,6 @@ impl CodexOAuthLLMProvider {
Self { config, client }
}
pub fn config(&self) -> &CodexOAuthConfig {
&self.config
}
pub async fn polish(
&self,
raw_text: &str,
@@ -1250,6 +1246,17 @@ pub(crate) fn http_client_builder(base_url: &str, timeout_secs: u64) -> reqwest:
}
}
/// 判定一个「TCP 握手 / 请求写出」阶段的网络错误是否可安全重试。
///
/// 只对 connect / request 这两类「服务端必然没收到」的失败重试,且**必须排除超时**:
/// reqwest 会把「请求体写出阶段超时」归类为 `is_request()`(有时同时 `is_timeout()`),
/// 若只判 `is_connect() || is_request()` 会让这类超时先命中重试臂,重发已发出的非幂等
/// 请求 → 重复 LLM completion + 双重计费,与本函数文档意图相悖(#680)。抽成纯函数便于
/// 单测覆盖(reqwest::Error 无法在测试里构造任意 flag 组合)。
fn should_retry_transient(is_connect: bool, is_request: bool, is_timeout: bool) -> bool {
(is_connect || is_request) && !is_timeout
}
/// 发请求 + 网络抖动 retry:**只**对 `is_connect()` / `is_request()` 这两类「服务端
/// 必然没收到」的失败重试一次。`is_timeout()` 故意**不**重试——超时时服务端可能已经
/// 在处理请求并扣计费(LLM completion 是非幂等动作),重试会导致重复 billing + 重复
@@ -1277,7 +1284,7 @@ async fn send_with_transient_retry(
};
match initial.send().await {
Ok(r) => Ok(r),
Err(e) if e.is_connect() || e.is_request() => {
Err(e) if should_retry_transient(e.is_connect(), e.is_request(), e.is_timeout()) => {
log::warn!(
"[llm] send transient failure, retry in {}ms: {}",
RETRY_DELAY_MS,
@@ -1656,287 +1663,6 @@ fn openai_chat_reasoning_effort(model: &str, thinking_enabled: bool) -> Option<&
}
}
/// 把 working_languages + front_app 拼成 system prompt 头部前提:
/// # 上下文
/// 用户的工作语言:…
/// 当前前台应用:…(请按这个 app 的常见沟通风格调整语气)
///
/// 两个字段都空时返回 None,调用方就不拼前缀。详见 issue #4 / #116。
fn context_premise(
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
front_app: Option<&str>,
) -> Option<String> {
let langs: Vec<&str> = working_languages
.iter()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
let app = front_app.map(str::trim).filter(|s| !s.is_empty());
let script_line = match chinese_script_preference {
ChineseScriptPreference::Simplified => Some(
"中文输出偏好:简体中文。若最终输出包含中文,请统一使用简体字形(不要混用繁体)。"
.to_string(),
),
ChineseScriptPreference::Traditional => Some(
"中文输出偏好:繁体中文。若最终输出包含中文,请统一使用繁体字形(不要混用简体)。"
.to_string(),
),
ChineseScriptPreference::Auto => None,
};
let output_language_line = match output_language_preference {
OutputLanguagePreference::ZhCn => {
Some("最终输出语言偏好:简体中文。若回答可用中文表达,请优先使用简体中文。".to_string())
}
OutputLanguagePreference::ZhTw => {
Some("最終輸出語言偏好:繁體中文。若回答可用中文表達,請優先使用繁體中文。".to_string())
}
OutputLanguagePreference::En => Some(
"Output language preference: English. Prefer English when producing the final answer."
.to_string(),
),
OutputLanguagePreference::Ja => Some(
"出力言語の優先設定:日本語。最終回答は可能な限り日本語で出力してください。"
.to_string(),
),
OutputLanguagePreference::Ko => {
Some("출력 언어 선호: 한국어. 최종 답변은 가능하면 한국어로 작성해 주세요.".to_string())
}
OutputLanguagePreference::Auto => None,
};
if langs.is_empty() && app.is_none() && script_line.is_none() && output_language_line.is_none()
{
return None;
}
let mut lines = vec!["# 上下文".to_string()];
if !langs.is_empty() {
lines.push(format!(
"用户的工作语言:{}。处理任何文本时请把这一前提带进考虑(识别专名、判定语气、决定写法)。",
langs.join("、")
));
}
if let Some(name) = app {
lines.push(format!(
"当前前台应用:{name}。请按这个应用的常见沟通风格调整语气——例如邮件类 app 偏正式、聊天类 app 偏口语、IDE / 文档类 app 偏技术或结构化。\u{4E0D}主动加入与用户原意无关的客套话。"
));
}
if let Some(line) = script_line {
lines.push(line);
}
if let Some(line) = output_language_line {
lines.push(line);
}
Some(lines.join("\n"))
}
/// 把 polish 输入参数装配成 `(system_prompt, user_prompt)` 二元组。
///
/// 抽出来是为了让 OpenAI 兼容客户端 (本文件) 和谷歌原生 Gemini 客户端
/// (`llm_gemini.rs`) 共享同一套 prompt 装配规则——不再担心两路 LLM
/// 在 `system_prompt` 拼接顺序、context_premise 注入时机、
/// polish_context_instruction 追加条件上慢慢漂移。
pub(crate) fn compose_polish_prompts(
raw_text: &str,
_mode: PolishMode,
hotwords: &[String],
style_system_prompt: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
front_app: Option<&str>,
has_prior_turns: bool,
) -> (String, String) {
let mut system_prompt = compose_system_prompt(style_system_prompt, hotwords);
if let Some(premise) = context_premise(
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
) {
system_prompt = format!("{}\n\n{}", premise, system_prompt);
}
// issue #609 F-02:在 system prompt 末尾追加对抗式防御措辞,明确信封内文本是
// 数据而非指令。纵深防御,非硬保证。
system_prompt = format!(
"{}\n\n{}",
system_prompt,
prompts::polish_injection_defense()
);
// 多轮上下文模式:把"上一轮的指令是什么、不要复读上一轮答案"明确写进
// system prompt,配合 chat structure 让 LLM 自然不重复历史输出。
if has_prior_turns {
system_prompt = format!(
"{}\n\n{}",
system_prompt,
prompts::polish_context_instruction()
);
}
let user_prompt = prompts::user_prompt(raw_text);
(system_prompt, user_prompt)
}
/// 翻译路径的 `(system_prompt, user_prompt)` 装配——和 polish 一样供两路 LLM 客户端共用。
/// 翻译模式以 `target_language` 为唯一输出语言约束,OutputLanguagePreference 在这里被
/// 强制设为 Auto 以避免 UI 偏好(如 ja)与 target_language(如 en)冲突。
pub(crate) fn assemble_polish_system_prompt(
style_system_prompt: &str,
hotwords: &[String],
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
front_app: Option<&str>,
has_prior_turns: bool,
) -> PolishSystemPromptAssembly {
let (effective_system_prompt, _) = compose_polish_prompts(
"",
PolishMode::Light,
hotwords,
style_system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
has_prior_turns,
);
let context_premise = context_premise(
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
)
.unwrap_or_default();
let hotword_block = compose_hotword_block_preview(hotwords);
let history_instruction = if has_prior_turns {
prompts::polish_context_instruction().to_string()
} else {
String::new()
};
let includes_hotword_block = !hotword_block.is_empty();
let includes_context_premise = !context_premise.is_empty();
PolishSystemPromptAssembly {
context_premise,
hotword_block,
history_instruction,
effective_system_prompt,
includes_context_premise,
includes_hotword_block,
includes_history_instruction: has_prior_turns,
}
}
pub(crate) fn compose_translate_prompts(
raw_text: &str,
target_language: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
front_app: Option<&str>,
) -> (String, String) {
let mut system_prompt = prompts::translate_system_prompt(target_language);
if let Some(premise) = context_premise(
working_languages,
chinese_script_preference,
OutputLanguagePreference::Auto,
front_app,
) {
system_prompt = format!("{}\n\n{}", premise, system_prompt);
}
let user_prompt = prompts::user_prompt(raw_text);
(system_prompt, user_prompt)
}
/// QA 划词问答的 system_prompt 装配。两路 LLM 客户端共用。
pub(crate) fn compose_qa_system_prompt(
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
front_app: Option<&str>,
) -> String {
let mut system_prompt = prompts::qa_system_prompt();
if let Some(premise) = context_premise(
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
) {
system_prompt = format!("{}\n\n{}", premise, system_prompt);
}
system_prompt
}
/// 构建「热词 + 错别字纠错」模块文本: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<String> = hotwords
.iter()
.map(|h| h.trim().to_string())
.filter(|h| !h.is_empty())
.collect();
if cleaned.is_empty() {
return "# 热词与纠错(系统内置)\n\
你接到的转写来自 ASR,可能含错别字 / 同音误识别 / 形近词。\
按上下文自动纠回正确字面:常见模式如「跟目录 / 根木鹿」→「根目录」、\
「代码厂」→「代码仓」、「编一编」→「编译」、英文短词同音(如 VIP / ZIP)按上下文判断、\
带次版本号产品名(GPT-5.6 不省略成 GPT-5)。\
人名 / 品牌名 / 含义会变化的词原样保留,不强行改字。"
.to_string();
}
let bullets = cleaned
.iter()
.map(|h| format!("- {}", h))
.collect::<Vec<_>>()
.join("\n");
format!(
"# 热词与纠错(系统内置)\n\
你接到的转写来自 ASR,可能含错别字。用户希望以下写法在输出中保持准确;\
当转写中出现这些词的同音或形近误识别时,优先按上述写法输出,不做无关词的机械替换:\n\
{bullets}\n\
\n\
上面热词的纠偏指令优先于通用规则 2 的「原样保留」——当转写词是热词的同音 / 形近误识别\
(例:转写出「VIP」而热词里有「ZIP」),就按热词写法输出,不要因为它看起来像英文专有名词\
或中英混输而保留误识别结果。\n\
\n\
转写中其它 ASR 错别字按上下文自动纠回正确字面:常见模式如「跟目录 / 根木鹿」→「根目录」、\
英文短词同音(如 VIP / ZIP)按上下文判断、带次版本号产品名(GPT-5.6 不省略成 GPT-5)。\
人名 / 品牌名 / 含义会变化的词原样保留。",
bullets = bullets
)
}
/// 系统提示词组装:先把内置 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 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<String, LLMError> {
let json: Value = serde_json::from_str(body)
@@ -1956,196 +1682,6 @@ fn extract_assistant_content(body: &str) -> Result<String, LLMError> {
Ok(clean_polish_output(content))
}
/// Best-effort cleanup of common LLM "introduction" prefixes and markdown fences.
///
/// Matches a small set of known leading phrases (`根据您给的内容...`, `整理如下...`, etc.)
/// and strips them. We don't have the `regex` crate, so we use prefix checks plus
/// an iterative trim — if the model stacks two boilerplate sentences we'll still
/// strip both.
///
/// `pub(crate)` because `llm_gemini` 也要在它自己的解析路径上跑同一套清洗,
/// 否则 polish prompt 已经禁用的"以下是整理后的内容"前缀只在 OpenAI 兼容路径生效。
pub(crate) fn clean_polish_output(content: &str) -> String {
let without_thinking = strip_thinking_blocks(content);
let trimmed = without_thinking.trim();
let stripped = strip_markdown_fence(trimmed);
let mut output = stripped.to_string();
loop {
let before_len = output.len();
output = strip_leading_boilerplate(&output).to_string();
output = output.trim_start().to_string();
if output.len() == before_len {
break;
}
}
output.trim().to_string()
}
/// Strip model reasoning blocks so only the final polished text is inserted.
///
/// Thinking-capable OpenAI-compatible models commonly return their reasoning in
/// `<think>...</think>` before the final answer. Match only explicit `think`
/// tags, with optional attributes and ASCII casing variants, so normal prose is
/// left untouched.
fn strip_thinking_blocks(text: &str) -> Cow<'_, str> {
let mut cursor = 0;
let mut output: Option<String> = None;
while let Some((open_start, open_end)) = find_think_open(&text[cursor..]) {
let open_start = cursor + open_start;
let open_end = cursor + open_end;
let Some((_, close_end)) = find_think_close(&text[open_end..]) else {
break;
};
let close_end = open_end + close_end;
output
.get_or_insert_with(|| String::with_capacity(text.len()))
.push_str(&text[cursor..open_start]);
cursor = close_end;
}
match output {
Some(mut output) => {
output.push_str(&text[cursor..]);
Cow::Owned(output)
}
None => Cow::Borrowed(text),
}
}
fn find_think_open(text: &str) -> Option<(usize, usize)> {
let mut cursor = 0;
while let Some(offset) = text[cursor..].find('<') {
let start = cursor + offset;
if let Some(end) = parse_think_open_at(text, start) {
return Some((start, end));
}
cursor = start + '<'.len_utf8();
}
None
}
fn find_think_close(text: &str) -> Option<(usize, usize)> {
let mut cursor = 0;
while let Some(offset) = text[cursor..].find('<') {
let start = cursor + offset;
if let Some(end) = parse_think_close_at(text, start) {
return Some((start, end));
}
cursor = start + '<'.len_utf8();
}
None
}
fn parse_think_open_at(text: &str, start: usize) -> Option<usize> {
let tag_start = start + '<'.len_utf8();
if text.as_bytes().get(tag_start) == Some(&b'/') {
return None;
}
parse_think_tag_end(text, tag_start, true)
}
fn parse_think_close_at(text: &str, start: usize) -> Option<usize> {
let slash = start + '<'.len_utf8();
if text.as_bytes().get(slash) != Some(&b'/') {
return None;
}
parse_think_tag_end(text, slash + '/'.len_utf8(), false)
}
fn parse_think_tag_end(text: &str, tag_start: usize, allow_attributes: bool) -> Option<usize> {
let tag_end = tag_start.checked_add("think".len())?;
if tag_end > text.len() || !text[tag_start..tag_end].eq_ignore_ascii_case("think") {
return None;
}
let next = text.as_bytes().get(tag_end).copied()?;
if next == b'>' {
return Some(tag_end + 1);
}
if !next.is_ascii_whitespace() {
return None;
}
if allow_attributes {
return text[tag_end..].find('>').map(|offset| tag_end + offset + 1);
}
let suffix = &text[tag_end..];
let trimmed = suffix.trim_start_matches(|c: char| c.is_ascii_whitespace());
if trimmed.starts_with('>') {
Some(text.len() - trimmed.len() + 1)
} else {
None
}
}
fn strip_markdown_fence(text: &str) -> &str {
if !(text.starts_with("```") && text.ends_with("```")) {
return text;
}
let mut lines: Vec<&str> = text.lines().collect();
if lines.len() < 2 {
return text;
}
lines.remove(0);
lines.pop();
// Re-borrow as &str by stitching is impossible without alloc; fallback to
// returning the original slice if the cheap path can't strip.
// Find the byte offsets of the first newline and the last fence to slice in place.
let after_first_line = match text.find('\n') {
Some(i) => i + 1,
None => return text,
};
let before_last_fence = match text.rfind("```") {
Some(i) => i,
None => return text,
};
if before_last_fence <= after_first_line {
return text;
}
text[after_first_line..before_last_fence].trim_matches(['\n', ' ', '\t', '\r'].as_ref())
}
/// Known introduction phrases that some models prepend even when prompted not to.
const LEADING_BOILERPLATE_PREFIXES: &[&str] = &[
"根据您给的内容",
"根据您提供的内容",
"根据你给的内容",
"根据你提供的内容",
"以下是整理后的内容",
"以下是优化后的内容",
"以下为整理后的内容",
"以下是结构化整理后的内容",
"我整理如下",
"我已整理如下",
"整理如下",
"优化如下",
"结构化整理如下",
];
const BOILERPLATE_END_CHARS: &[char] = &['。', ':', ':', ',', ',', '\n'];
fn strip_leading_boilerplate(text: &str) -> &str {
for prefix in LEADING_BOILERPLATE_PREFIXES {
if let Some(after_prefix) = text.strip_prefix(prefix) {
// Trim characters after the prefix up to (and including) the first
// sentence-ending punctuation or newline.
for (idx, c) in after_prefix.char_indices() {
if BOILERPLATE_END_CHARS.contains(&c) {
let cut = prefix.len() + idx + c.len_utf8();
return &text[cut..];
}
}
// No terminator: drop the prefix only.
return after_prefix;
}
}
text
}
pub mod prompts {
use crate::types::PolishMode;
@@ -2464,6 +2000,20 @@ mod tests {
static CODEX_AUTH_FIXTURE_COUNTER: AtomicU64 = AtomicU64::new(0);
static ENV_LOCK: StdMutex<()> = StdMutex::new(());
#[test]
fn retries_connect_or_request_only_when_not_timeout() {
// connect / request 失败(非超时)→ 服务端必然没收到,重试安全。
assert!(should_retry_transient(true, false, false));
assert!(should_retry_transient(false, true, false));
// 请求体写出阶段超时(reqwest 归类 is_request + is_timeout)→ 服务端可能已扣费,
// 不重试,避免重复 LLM completion 与双重计费(#680)。
assert!(!should_retry_transient(false, true, true));
assert!(!should_retry_transient(true, false, true));
// 纯超时 / 其它错误也不重试。
assert!(!should_retry_transient(false, false, true));
assert!(!should_retry_transient(false, false, false));
}
struct EnvSnapshot {
values: Vec<(&'static str, Option<OsString>)>,
}
@@ -2862,45 +2412,6 @@ mod tests {
);
}
#[test]
fn clean_polish_output_strips_think_tag_block() {
let content =
"<think>先分析用户意图。\n这里可能很长。</think>\n\n请明天上午十点提醒我开会。";
assert_eq!(clean_polish_output(content), "请明天上午十点提醒我开会。");
}
#[test]
fn clean_polish_output_strips_think_tag_with_attributes_and_case() {
let content = r#"<THINK reason="true">hidden</THINK>
最终文本。"#;
assert_eq!(clean_polish_output(content), "最终文本。");
}
#[test]
fn clean_polish_output_strips_multiple_think_blocks() {
let content = "<think>one</think>第一句。<think>two</think>第二句。";
assert_eq!(clean_polish_output(content), "第一句。第二句。");
}
#[test]
fn strip_thinking_blocks_ignores_non_think_and_unclosed_tags() {
assert!(matches!(
strip_thinking_blocks("普通文本"),
Cow::Borrowed(_)
));
assert_eq!(
strip_thinking_blocks("<thinking>保留</thinking>正文"),
"<thinking>保留</thinking>正文"
);
assert_eq!(
strip_thinking_blocks("<think>未闭合正文"),
"<think>未闭合正文"
);
}
#[test]
fn openai_chat_body_adds_reasoning_effort_for_openai_reasoning_model() {
let provider = OpenAICompatibleLLMProvider::new(
@@ -0,0 +1,243 @@
//! LLM polish output sanitization extracted from `polish.rs`
//! (behavior-preserving move).
//!
//! Strips model `<think>` blocks, markdown fences, and known boilerplate
//! prefixes. `clean_polish_output` stays `pub(crate)` (also used by `llm_gemini`)
//! and is re-exported from `polish`.
use std::borrow::Cow;
/// Best-effort cleanup of common LLM "introduction" prefixes and markdown fences.
///
/// Matches a small set of known leading phrases (`根据您给的内容...`, `整理如下...`, etc.)
/// and strips them. We don't have the `regex` crate, so we use prefix checks plus
/// an iterative trim — if the model stacks two boilerplate sentences we'll still
/// strip both.
///
/// `pub(crate)` because `llm_gemini` 也要在它自己的解析路径上跑同一套清洗,
/// 否则 polish prompt 已经禁用的"以下是整理后的内容"前缀只在 OpenAI 兼容路径生效。
pub(crate) fn clean_polish_output(content: &str) -> String {
let without_thinking = strip_thinking_blocks(content);
let trimmed = without_thinking.trim();
let stripped = strip_markdown_fence(trimmed);
let mut output = stripped.to_string();
loop {
let before_len = output.len();
output = strip_leading_boilerplate(&output).to_string();
output = output.trim_start().to_string();
if output.len() == before_len {
break;
}
}
output.trim().to_string()
}
/// Strip model reasoning blocks so only the final polished text is inserted.
///
/// Thinking-capable OpenAI-compatible models commonly return their reasoning in
/// `<think>...</think>` before the final answer. Match only explicit `think`
/// tags, with optional attributes and ASCII casing variants, so normal prose is
/// left untouched.
pub(super) fn strip_thinking_blocks(text: &str) -> Cow<'_, str> {
let mut cursor = 0;
let mut output: Option<String> = None;
while let Some((open_start, open_end)) = find_think_open(&text[cursor..]) {
let open_start = cursor + open_start;
let open_end = cursor + open_end;
let Some((_, close_end)) = find_think_close(&text[open_end..]) else {
break;
};
let close_end = open_end + close_end;
output
.get_or_insert_with(|| String::with_capacity(text.len()))
.push_str(&text[cursor..open_start]);
cursor = close_end;
}
match output {
Some(mut output) => {
output.push_str(&text[cursor..]);
Cow::Owned(output)
}
None => Cow::Borrowed(text),
}
}
pub(super) fn find_think_open(text: &str) -> Option<(usize, usize)> {
let mut cursor = 0;
while let Some(offset) = text[cursor..].find('<') {
let start = cursor + offset;
if let Some(end) = parse_think_open_at(text, start) {
return Some((start, end));
}
cursor = start + '<'.len_utf8();
}
None
}
pub(super) fn find_think_close(text: &str) -> Option<(usize, usize)> {
let mut cursor = 0;
while let Some(offset) = text[cursor..].find('<') {
let start = cursor + offset;
if let Some(end) = parse_think_close_at(text, start) {
return Some((start, end));
}
cursor = start + '<'.len_utf8();
}
None
}
pub(super) fn parse_think_open_at(text: &str, start: usize) -> Option<usize> {
let tag_start = start + '<'.len_utf8();
if text.as_bytes().get(tag_start) == Some(&b'/') {
return None;
}
parse_think_tag_end(text, tag_start, true)
}
pub(super) fn parse_think_close_at(text: &str, start: usize) -> Option<usize> {
let slash = start + '<'.len_utf8();
if text.as_bytes().get(slash) != Some(&b'/') {
return None;
}
parse_think_tag_end(text, slash + '/'.len_utf8(), false)
}
pub(super) fn parse_think_tag_end(text: &str, tag_start: usize, allow_attributes: bool) -> Option<usize> {
let tag_end = tag_start.checked_add("think".len())?;
if tag_end > text.len() || !text[tag_start..tag_end].eq_ignore_ascii_case("think") {
return None;
}
let next = text.as_bytes().get(tag_end).copied()?;
if next == b'>' {
return Some(tag_end + 1);
}
if !next.is_ascii_whitespace() {
return None;
}
if allow_attributes {
return text[tag_end..].find('>').map(|offset| tag_end + offset + 1);
}
let suffix = &text[tag_end..];
let trimmed = suffix.trim_start_matches(|c: char| c.is_ascii_whitespace());
if trimmed.starts_with('>') {
Some(text.len() - trimmed.len() + 1)
} else {
None
}
}
pub(super) fn strip_markdown_fence(text: &str) -> &str {
if !(text.starts_with("```") && text.ends_with("```")) {
return text;
}
let mut lines: Vec<&str> = text.lines().collect();
if lines.len() < 2 {
return text;
}
lines.remove(0);
lines.pop();
// Re-borrow as &str by stitching is impossible without alloc; fallback to
// returning the original slice if the cheap path can't strip.
// Find the byte offsets of the first newline and the last fence to slice in place.
let after_first_line = match text.find('\n') {
Some(i) => i + 1,
None => return text,
};
let before_last_fence = match text.rfind("```") {
Some(i) => i,
None => return text,
};
if before_last_fence <= after_first_line {
return text;
}
text[after_first_line..before_last_fence].trim_matches(['\n', ' ', '\t', '\r'].as_ref())
}
/// Known introduction phrases that some models prepend even when prompted not to.
pub(super) const LEADING_BOILERPLATE_PREFIXES: &[&str] = &[
"根据您给的内容",
"根据您提供的内容",
"根据你给的内容",
"根据你提供的内容",
"以下是整理后的内容",
"以下是优化后的内容",
"以下为整理后的内容",
"以下是结构化整理后的内容",
"我整理如下",
"我已整理如下",
"整理如下",
"优化如下",
"结构化整理如下",
];
pub(super) const BOILERPLATE_END_CHARS: &[char] = &['。', ':', ':', ',', ',', '\n'];
pub(super) fn strip_leading_boilerplate(text: &str) -> &str {
for prefix in LEADING_BOILERPLATE_PREFIXES {
if let Some(after_prefix) = text.strip_prefix(prefix) {
// Trim characters after the prefix up to (and including) the first
// sentence-ending punctuation or newline.
for (idx, c) in after_prefix.char_indices() {
if BOILERPLATE_END_CHARS.contains(&c) {
let cut = prefix.len() + idx + c.len_utf8();
return &text[cut..];
}
}
// No terminator: drop the prefix only.
return after_prefix;
}
}
text
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clean_polish_output_strips_think_tag_block() {
let content =
"<think>先分析用户意图。\n这里可能很长。</think>\n\n请明天上午十点提醒我开会。";
assert_eq!(clean_polish_output(content), "请明天上午十点提醒我开会。");
}
#[test]
fn clean_polish_output_strips_think_tag_with_attributes_and_case() {
let content = r#"<THINK reason="true">hidden</THINK>
最终文本。"#;
assert_eq!(clean_polish_output(content), "最终文本。");
}
#[test]
fn clean_polish_output_strips_multiple_think_blocks() {
let content = "<think>one</think>第一句。<think>two</think>第二句。";
assert_eq!(clean_polish_output(content), "第一句。第二句。");
}
#[test]
fn strip_thinking_blocks_ignores_non_think_and_unclosed_tags() {
assert!(matches!(
strip_thinking_blocks("普通文本"),
Cow::Borrowed(_)
));
assert_eq!(
strip_thinking_blocks("<thinking>保留</thinking>正文"),
"<thinking>保留</thinking>正文"
);
assert_eq!(
strip_thinking_blocks("<think>未闭合正文"),
"<think>未闭合正文"
);
}
}
@@ -0,0 +1,290 @@
//! Polish / translate / QA system-prompt composition extracted from `polish.rs`
//! (behavior-preserving move).
//!
//! Builds the context premise, hotword block, and the assembled system/user
//! prompts. References the parent `prompts` module and `PolishSystemPromptAssembly`
//! via `use super::*;`. `pub(crate)` fns are re-exported from `polish`.
use super::*;
/// 把 working_languages + front_app 拼成 system prompt 头部前提:
/// # 上下文
/// 用户的工作语言:…
/// 当前前台应用:…(请按这个 app 的常见沟通风格调整语气)
///
/// 两个字段都空时返回 None,调用方就不拼前缀。详见 issue #4 / #116。
pub(super) fn context_premise(
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
front_app: Option<&str>,
) -> Option<String> {
let langs: Vec<&str> = working_languages
.iter()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
let app = front_app.map(str::trim).filter(|s| !s.is_empty());
let script_line = match chinese_script_preference {
ChineseScriptPreference::Simplified => Some(
"中文输出偏好:简体中文。若最终输出包含中文,请统一使用简体字形(不要混用繁体)。"
.to_string(),
),
ChineseScriptPreference::Traditional => Some(
"中文输出偏好:繁体中文。若最终输出包含中文,请统一使用繁体字形(不要混用简体)。"
.to_string(),
),
ChineseScriptPreference::Auto => None,
};
let output_language_line = match output_language_preference {
OutputLanguagePreference::ZhCn => {
Some("最终输出语言偏好:简体中文。若回答可用中文表达,请优先使用简体中文。".to_string())
}
OutputLanguagePreference::ZhTw => {
Some("最終輸出語言偏好:繁體中文。若回答可用中文表達,請優先使用繁體中文。".to_string())
}
OutputLanguagePreference::En => Some(
"Output language preference: English. Prefer English when producing the final answer."
.to_string(),
),
OutputLanguagePreference::Ja => Some(
"出力言語の優先設定:日本語。最終回答は可能な限り日本語で出力してください。"
.to_string(),
),
OutputLanguagePreference::Ko => {
Some("출력 언어 선호: 한국어. 최종 답변은 가능하면 한국어로 작성해 주세요.".to_string())
}
OutputLanguagePreference::Auto => None,
};
if langs.is_empty() && app.is_none() && script_line.is_none() && output_language_line.is_none()
{
return None;
}
let mut lines = vec!["# 上下文".to_string()];
if !langs.is_empty() {
lines.push(format!(
"用户的工作语言:{}。处理任何文本时请把这一前提带进考虑(识别专名、判定语气、决定写法)。",
langs.join("、")
));
}
if let Some(name) = app {
lines.push(format!(
"当前前台应用:{name}。请按这个应用的常见沟通风格调整语气——例如邮件类 app 偏正式、聊天类 app 偏口语、IDE / 文档类 app 偏技术或结构化。\u{4E0D}主动加入与用户原意无关的客套话。"
));
}
if let Some(line) = script_line {
lines.push(line);
}
if let Some(line) = output_language_line {
lines.push(line);
}
Some(lines.join("\n"))
}
/// 把 polish 输入参数装配成 `(system_prompt, user_prompt)` 二元组。
///
/// 抽出来是为了让 OpenAI 兼容客户端 (本文件) 和谷歌原生 Gemini 客户端
/// (`llm_gemini.rs`) 共享同一套 prompt 装配规则——不再担心两路 LLM
/// 在 `system_prompt` 拼接顺序、context_premise 注入时机、
/// polish_context_instruction 追加条件上慢慢漂移。
pub(crate) fn compose_polish_prompts(
raw_text: &str,
_mode: PolishMode,
hotwords: &[String],
style_system_prompt: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
front_app: Option<&str>,
has_prior_turns: bool,
) -> (String, String) {
let mut system_prompt = compose_system_prompt(style_system_prompt, hotwords);
if let Some(premise) = context_premise(
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
) {
system_prompt = format!("{}\n\n{}", premise, system_prompt);
}
// issue #609 F-02:在 system prompt 末尾追加对抗式防御措辞,明确信封内文本是
// 数据而非指令。纵深防御,非硬保证。
system_prompt = format!(
"{}\n\n{}",
system_prompt,
prompts::polish_injection_defense()
);
// 多轮上下文模式:把"上一轮的指令是什么、不要复读上一轮答案"明确写进
// system prompt,配合 chat structure 让 LLM 自然不重复历史输出。
if has_prior_turns {
system_prompt = format!(
"{}\n\n{}",
system_prompt,
prompts::polish_context_instruction()
);
}
let user_prompt = prompts::user_prompt(raw_text);
(system_prompt, user_prompt)
}
/// 翻译路径的 `(system_prompt, user_prompt)` 装配——和 polish 一样供两路 LLM 客户端共用。
/// 翻译模式以 `target_language` 为唯一输出语言约束,OutputLanguagePreference 在这里被
/// 强制设为 Auto 以避免 UI 偏好(如 ja)与 target_language(如 en)冲突。
pub(crate) fn assemble_polish_system_prompt(
style_system_prompt: &str,
hotwords: &[String],
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
front_app: Option<&str>,
has_prior_turns: bool,
) -> PolishSystemPromptAssembly {
let (effective_system_prompt, _) = compose_polish_prompts(
"",
PolishMode::Light,
hotwords,
style_system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
has_prior_turns,
);
let context_premise = context_premise(
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
)
.unwrap_or_default();
let hotword_block = compose_hotword_block_preview(hotwords);
let history_instruction = if has_prior_turns {
prompts::polish_context_instruction().to_string()
} else {
String::new()
};
let includes_hotword_block = !hotword_block.is_empty();
let includes_context_premise = !context_premise.is_empty();
PolishSystemPromptAssembly {
context_premise,
hotword_block,
history_instruction,
effective_system_prompt,
includes_context_premise,
includes_hotword_block,
includes_history_instruction: has_prior_turns,
}
}
pub(crate) fn compose_translate_prompts(
raw_text: &str,
target_language: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
front_app: Option<&str>,
) -> (String, String) {
let mut system_prompt = prompts::translate_system_prompt(target_language);
if let Some(premise) = context_premise(
working_languages,
chinese_script_preference,
OutputLanguagePreference::Auto,
front_app,
) {
system_prompt = format!("{}\n\n{}", premise, system_prompt);
}
let user_prompt = prompts::user_prompt(raw_text);
(system_prompt, user_prompt)
}
/// QA 划词问答的 system_prompt 装配。两路 LLM 客户端共用。
pub(crate) fn compose_qa_system_prompt(
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
front_app: Option<&str>,
) -> String {
let mut system_prompt = prompts::qa_system_prompt();
if let Some(premise) = context_premise(
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
) {
system_prompt = format!("{}\n\n{}", premise, system_prompt);
}
system_prompt
}
/// 构建「热词 + 错别字纠错」模块文本:agent-style 措辞,把模型当成接到一段 ASR 转写
/// 的写作助手,明确告诉它「输入可能有错别字,按这个列表 + 上下文修正」。
///
/// 内置 default prompt 里的 `{{HOTWORDS}}` 占位符被这段文本替换;用户自定义 prompt
/// 没占位符时 compose_system_prompt 兜底拼到末尾。
///
/// 这段文本 100% 对齐 compose_hotword_block_preview,让 Style Pack 设置页的预览跟
/// 实际发给 LLM 的 prompt 一致。
pub(super) fn build_hotword_block(hotwords: &[String]) -> String {
let cleaned: Vec<String> = hotwords
.iter()
.map(|h| h.trim().to_string())
.filter(|h| !h.is_empty())
.collect();
if cleaned.is_empty() {
return "# 热词与纠错(系统内置)\n\
你接到的转写来自 ASR,可能含错别字 / 同音误识别 / 形近词。\
按上下文自动纠回正确字面:常见模式如「跟目录 / 根木鹿」→「根目录」、\
「代码厂」→「代码仓」、「编一编」→「编译」、英文短词同音(如 VIP / ZIP)按上下文判断、\
带次版本号产品名(GPT-5.6 不省略成 GPT-5)。\
人名 / 品牌名 / 含义会变化的词原样保留,不强行改字。"
.to_string();
}
let bullets = cleaned
.iter()
.map(|h| format!("- {}", h))
.collect::<Vec<_>>()
.join("\n");
format!(
"# 热词与纠错(系统内置)\n\
你接到的转写来自 ASR,可能含错别字。用户希望以下写法在输出中保持准确;\
当转写中出现这些词的同音或形近误识别时,优先按上述写法输出,不做无关词的机械替换:\n\
{bullets}\n\
\n\
上面热词的纠偏指令优先于通用规则 2 的「原样保留」——当转写词是热词的同音 / 形近误识别\
(例:转写出「VIP」而热词里有「ZIP」),就按热词写法输出,不要因为它看起来像英文专有名词\
或中英混输而保留误识别结果。\n\
\n\
转写中其它 ASR 错别字按上下文自动纠回正确字面:常见模式如「跟目录 / 根木鹿」→「根目录」、\
英文短词同音(如 VIP / ZIP)按上下文判断、带次版本号产品名(GPT-5.6 不省略成 GPT-5)。\
人名 / 品牌名 / 含义会变化的词原样保留。",
bullets = bullets
)
}
/// 系统提示词组装:先把内置 default prompt 的 `{{HOTWORDS}}` 占位符替换为实际热词块;
/// 用户自定义 prompt 没占位符时 fallback 行为:
/// - hotwords 非空 → 末尾追加热词块(兼容历史 prompt 仍能拿到热词)
/// - hotwords 空 → 不附加任何东西(用户决定自己 prompt 的内容,不强行注入)
pub(super) 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 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))
}
pub(super) fn compose_hotword_block_preview(hotwords: &[String]) -> String {
// Style Pack 设置页的预览 100% 跟 system prompt 用同一段文本,避免「设置里看到一段、
// 实际发给 LLM 是另一段」的不一致。空热词时返回纯错别字纠错指南。
build_hotword_block(hotwords)
}
-4
View File
@@ -234,10 +234,6 @@ impl StyleSystemPrompts {
}
}
pub fn is_default_for_mode(&self, mode: PolishMode) -> bool {
self.for_mode(mode) == StyleSystemPrompts::default().for_mode(mode)
}
pub fn with_legacy_custom_prompts(mut self, legacy: &CustomStylePrompts) -> Self {
const LEGACY_CUSTOM_PROMPT_MARKER: &str = "\n\n# 用户自定义附加要求\n";
for mode in [
+1 -1
View File
@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { detectOS, type OS } from './WindowChrome';
import {
@@ -218,7 +218,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
{/* Sidebar — desktop / wide only */}
{!mobile && (
<aside
className="ol-aura-sidebar"
className="ol-sidebar-surface"
style={{
width: 188,
flexShrink: 0,
@@ -347,11 +347,11 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
{/* Main content — Linux 禁用透明窗口后使用不透明面;mobile 全宽无玻璃层。 */}
<div style={{ flex: 1, minWidth: 0, padding: mobile ? 0 : '4px 8px 8px 0', display: 'flex' }}>
<main
className="ol-console-main ol-aura-panel"
className="ol-console-main ol-panel-surface"
style={{
flex: 1, minWidth: 0,
overflow: 'hidden',
background: useOpaqueMain ? 'var(--ol-panel-bg)' : 'var(--ol-aura-surface)',
background: useOpaqueMain ? 'var(--ol-panel-bg)' : 'var(--ol-panel-glass-bg)',
backdropFilter: useOpaqueMain ? 'none' : 'blur(18px) saturate(170%)',
WebkitBackdropFilter: useOpaqueMain ? 'none' : 'blur(18px) saturate(170%)',
borderRadius: 'var(--ol-r-lg)',
@@ -93,7 +93,7 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
}}>
<div
className="ol-aura-settings"
className="ol-settings-surface"
data-ol-mobile={mobile ? 'true' : undefined}
onClick={(e) => e.stopPropagation()}
style={{
-1
View File
@@ -1,7 +1,6 @@
// Theme preference: localStorage boot cache + Rust UserPreferences.theme_mode authority.
// Applies via html[data-ol-theme] + CSS variables — no React re-render for color changes.
import { detectOS } from '../components/WindowChrome';
import { getSettings, isTauri } from './ipc';
import { syncWindowsCaptionTheme } from './platform';
import type { ThemeMode } from './types';
+55 -7
View File
@@ -1,7 +1,7 @@
// History.tsx — 接 Tauri 后端 list_history / delete_history_entry / clear_history。
// 真实数据来自 ~/Library/Application Support/OpenLess/history.json。
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from '../components/Icon';
import { detectOS } from '../components/WindowChrome';
@@ -40,6 +40,8 @@ export function History() {
const FILTERS = useFilters();
const MODE_LABEL = useModeLabel();
const [filter, setFilter] = useState<'all' | PolishMode>('all');
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [items, setItems] = useState<DictationSession[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -84,10 +86,39 @@ export function History() {
void refresh();
}, [refresh]);
const filtered = useMemo(
() => (filter === 'all' ? items : items.filter(s => s.mode === filter)),
[items, filter],
);
const searchInputRef = useRef<HTMLInputElement>(null);
const searchShortcut = os === 'mac' ? '⌘K' : 'Ctrl+K';
// 搜索词防抖:随输入实时更新 query,300ms 后落到 debouncedQuery 再过滤,
// 避免每个按键都重算整张列表(与 Marketplace 同模式)。
useEffect(() => {
const id = window.setTimeout(() => setDebouncedQuery(query), 300);
return () => window.clearTimeout(id);
}, [query]);
// ⌘K / Ctrl+K 聚焦搜索框(设计稿提示的快捷键)。
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
searchInputRef.current?.focus();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
const filtered = useMemo(() => {
const byMode = filter === 'all' ? items : items.filter(s => s.mode === filter);
const q = debouncedQuery.trim().toLowerCase();
if (!q) return byMode;
// 按原始转写 + 润色后文本匹配关键词,覆盖用户能想起的两种内容。
return byMode.filter(
s =>
s.rawTranscript.toLowerCase().includes(q) ||
s.finalText.toLowerCase().includes(q),
);
}, [items, filter, debouncedQuery]);
const item = useMemo(
() => filtered.find(s => s.id === selectedId) || filtered[0],
[filtered, selectedId],
@@ -191,7 +222,22 @@ export function History() {
background: 'var(--ol-surface-2)', color: 'var(--ol-ink-3)',
}}>
<Icon name="search" size={12} />
<span style={{ flex: 1 }}>{t('history.summary', { total: items.length, shown: filtered.length })}</span>
<input
ref={searchInputRef}
type="search"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('history.searchPlaceholder', { shortcut: searchShortcut })}
aria-label={t('history.searchPlaceholder', { shortcut: searchShortcut })}
style={{
flex: 1, minWidth: 0,
outline: 'none', border: 0, background: 'transparent',
fontSize: 12, color: 'var(--ol-ink-1)', fontFamily: 'inherit',
}}
/>
</div>
<div style={{ marginTop: 6, fontSize: 11, color: 'var(--ol-ink-4)' }}>
{t('history.summary', { total: items.length, shown: filtered.length })}
</div>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginTop: 10 }}>
{FILTERS.map(f => (
@@ -223,7 +269,9 @@ export function History() {
)}
{!loading && !loadError && filtered.length === 0 && (
<div style={{ padding: 16, fontSize: 12, color: 'var(--ol-ink-4)' }}>
{t('history.empty', { trigger: prefs ? formatComboLabel(prefs.dictationHotkey) : '' })}
{debouncedQuery.trim()
? t('history.searchNoMatch', { query: debouncedQuery.trim() })
: t('history.empty', { trigger: prefs ? formatComboLabel(prefs.dictationHotkey) : '' })}
</div>
)}
{!loadError && filtered.map(s => (
@@ -0,0 +1,551 @@
// Presentational sub-components for the Local ASR page, extracted from
// LocalAsr/index.tsx (behavior-preserving move). All are props-driven and
// stateless beyond local render memoization.
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import {
type FoundryPrepareProgress,
type LocalAsrDownloadProgress,
type LocalAsrModelStatus,
type LocalAsrTestResult,
} from "../../lib/localAsr"
import { Btn, Card, Pill } from "../_atoms"
import { formatBytes } from "./helpers"
import type { RemoteSize } from "./types"
export function FoundryPrepareProgressBlock({
progress,
modelCached,
cancelRequested,
}: {
progress: FoundryPrepareProgress | null
modelCached: boolean
cancelRequested: boolean
}) {
const { t } = useTranslation()
const stages = [
{ phase: "runtime", label: t("localAsr.foundryPrepareRuntime") },
{ phase: "model", label: t("localAsr.foundryPrepareModel") },
{ phase: "load", label: t("localAsr.foundryPrepareLoad") },
] as const
const currentIndex = progress
? stages.findIndex((stage) => stage.phase === progress.phase)
: -1
return (
<div
style={{
padding: "10px 12px",
borderRadius: 8,
background: "rgba(0,0,0,0.035)",
display: "flex",
flexDirection: "column",
gap: 9,
}}
>
{stages.map((stage, index) => {
const finished =
progress?.phase === "finished" || currentIndex > index
const skippedCachedModel =
stage.phase === "model" &&
modelCached &&
(progress?.phase === "load" ||
progress?.phase === "finished")
const active = progress?.phase === stage.phase
const failed = progress?.phase === "failed"
const percent =
finished || skippedCachedModel
? 100
: active
? Math.max(0, Math.min(100, progress?.percent ?? 0))
: 0
const detail = skippedCachedModel
? t("localAsr.foundryPrepareModelSkipped")
: active
? progress?.label
: finished
? t("localAsr.foundryPrepareDone")
: t("localAsr.foundryPrepareWaiting")
return (
<div key={stage.phase}>
<div
style={{
display: "flex",
justifyContent: "space-between",
gap: 12,
marginBottom: 5,
}}
>
<span
style={{
fontSize: 12,
color: "var(--ol-ink-2)",
fontWeight: 600,
}}
>
{stage.label}
</span>
<span
style={{
fontSize: 11,
color: "var(--ol-ink-4)",
}}
>
{failed
? t("localAsr.failed")
: `${Math.round(percent)}%`}
</span>
</div>
<div
style={{
height: 6,
borderRadius: 3,
overflow: "hidden",
background: "rgba(0,0,0,0.08)",
}}
>
<div
style={{
height: "100%",
width: `${percent}%`,
background: failed
? "#d04545"
: "var(--ol-accent-blue, #2c5cff)",
transition: "width 120ms linear",
}}
/>
</div>
<div
style={{
fontSize: 11,
color: "var(--ol-ink-4)",
marginTop: 4,
}}
>
{detail}
</div>
</div>
)
})}
{cancelRequested && (
<div
style={{
fontSize: 11.5,
color: "#8a5a00",
lineHeight: 1.5,
}}
>
{t("localAsr.foundryCancelBestEffort")}
</div>
)}
{progress?.phase === "failed" && progress.error && (
<div
style={{
fontSize: 11.5,
color: "#9b2c2c",
lineHeight: 1.5,
}}
>
{progress.error}
</div>
)}
</div>
)
}
export function DownloadProgressBlock({
progress,
remoteSize,
cancelRequested,
}: {
progress?: LocalAsrDownloadProgress
remoteSize?: RemoteSize
cancelRequested: boolean
}) {
const { t } = useTranslation()
const downloadedBytes = progress?.bytesDownloaded ?? 0
const totalBytes = progress?.bytesTotal ?? remoteSize?.totalBytes ?? 0
const ratio = totalBytes > 0 ? Math.min(1, downloadedBytes / totalBytes) : 0
const failed = progress?.phase === "failed"
return (
<div
style={{
padding: "10px 12px",
borderRadius: 8,
background: "rgba(0,0,0,0.035)",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
gap: 12,
}}
>
<span
style={{
fontSize: 12,
color: "var(--ol-ink-2)",
fontWeight: 600,
}}
>
{t("localAsr.foundryPrepareModel")}
</span>
<span style={{ fontSize: 11, color: "var(--ol-ink-4)" }}>
{failed
? t("localAsr.failed")
: `${Math.round(ratio * 100)}%`}
</span>
</div>
<div
style={{
height: 6,
borderRadius: 3,
overflow: "hidden",
background: "rgba(0,0,0,0.08)",
}}
>
<div
style={{
height: "100%",
width: `${ratio * 100}%`,
background: failed
? "#d04545"
: "var(--ol-accent-blue, #2c5cff)",
transition: "width 120ms linear",
}}
/>
</div>
<div style={{ fontSize: 11, color: "var(--ol-ink-4)" }}>
{failed
? `${t("localAsr.failed")}: ${progress?.error ?? ""}`
: `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` +
(progress?.file ? ` · ${progress.file}` : "")}
</div>
{cancelRequested && (
<div
style={{
fontSize: 11.5,
color: "#8a5a00",
lineHeight: 1.5,
}}
>
{t("localAsr.foundryCancelRequested")}
</div>
)}
</div>
)
}
export interface ModelRowProps {
model: LocalAsrModelStatus
modelDir: string
remoteSize?: RemoteSize
progress?: LocalAsrDownloadProgress
isActive: boolean
engineAvailable: boolean
disabled: boolean
testing: boolean
testResult?: LocalAsrTestResult | { error: string }
onDownload: () => void
onCancel: () => void
onDelete: () => void
onReveal: () => void
onSetActive: () => void
onTest: () => void
}
export function ModelRow({
model,
modelDir,
remoteSize,
progress,
isActive,
engineAvailable,
disabled,
testing,
testResult,
onDownload,
onCancel,
onDelete,
onReveal,
onSetActive,
onTest,
}: ModelRowProps) {
const { t } = useTranslation()
const isDownloading = useMemo(
() => progress?.phase === "started" || progress?.phase === "progress",
[progress?.phase],
)
const downloadedBytes = progress?.bytesDownloaded ?? model.downloadedBytes
const totalBytes = progress?.bytesTotal ?? remoteSize?.totalBytes ?? 0
const ratio = totalBytes > 0 ? Math.min(1, downloadedBytes / totalBytes) : 0
// 进度条要保留:有 partial 残留(downloadedBytes>0 但未完整)就一直显示,
// 让用户看到上次下到哪里了,再点下载会从那里续。
const hasPartial = !model.isDownloaded && model.downloadedBytes > 0
const showProgress =
isDownloading || progress?.phase === "failed" || hasPartial
const sizeLabel = remoteSize?.loading
? t("localAsr.sizeLoading")
: remoteSize?.error
? t("localAsr.sizeUnknown")
: remoteSize && remoteSize.totalBytes > 0
? `${formatBytes(remoteSize.totalBytes)} · ${remoteSize.fileCount} ${t("localAsr.files")}`
: t("localAsr.sizeUnknown")
return (
<Card>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 16,
}}
>
<div style={{ minWidth: 0 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 4,
}}
>
<div
style={{
fontSize: 14,
fontWeight: 600,
color: "var(--ol-ink)",
}}
>
{model.id}
</div>
{isActive && (
<Pill tone="blue" size="sm">
{t("localAsr.activeBadge")}
</Pill>
)}
{model.isDownloaded && (
<Pill tone="ok" size="sm">
{t("localAsr.downloadedBadge")}
</Pill>
)}
</div>
<div style={{ fontSize: 12, color: "var(--ol-ink-3)" }}>
{model.hfRepo} · {sizeLabel}
</div>
<div
style={{
fontSize: 11,
color: "var(--ol-ink-4)",
marginTop: 4,
wordBreak: "break-all",
}}
>
{t("localAsr.modelDir")}:{" "}
<code>{modelDir || "—"}</code>
</div>
{showProgress && (
<div style={{ marginTop: 10, maxWidth: 420 }}>
<div
style={{
height: 6,
borderRadius: 3,
background: "rgba(0,0,0,0.06)",
overflow: "hidden",
}}
>
<div
style={{
width: `${ratio * 100}%`,
height: "100%",
background:
progress?.phase === "failed"
? "#d04545"
: "var(--ol-accent-blue, #2c5cff)",
transition: "width 120ms linear",
}}
/>
</div>
<div
style={{
fontSize: 11,
color: "var(--ol-ink-4)",
marginTop: 6,
}}
>
{progress?.phase === "failed"
? `${t("localAsr.failed")}: ${progress.error ?? ""}`
: `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` +
(progress?.file
? ` · ${progress.file}`
: "")}
</div>
</div>
)}
</div>
<div
style={{
display: "flex",
gap: 8,
flexShrink: 0,
flexWrap: "wrap",
justifyContent: "flex-end",
maxWidth: 360,
}}
>
{model.isDownloaded ? (
<>
{!isActive && (
<Btn
variant="blue"
size="sm"
disabled={disabled || !engineAvailable}
onClick={onSetActive}
>
{t("localAsr.setActive")}
</Btn>
)}
<Btn
variant="primary"
size="sm"
disabled={
disabled || testing || !engineAvailable
}
onClick={onTest}
>
{testing
? t("localAsr.testRunning")
: t("localAsr.test")}
</Btn>
<Btn
variant="ghost"
size="sm"
disabled={disabled || testing}
onClick={onDelete}
>
{t("localAsr.delete")}
</Btn>
<Btn
variant="ghost"
size="sm"
disabled={disabled}
onClick={onReveal}
>
{t("localAsr.revealDir")}
</Btn>
</>
) : isDownloading ? (
<Btn variant="ghost" size="sm" onClick={onCancel}>
{t("localAsr.cancel")}
</Btn>
) : (
<>
<Btn
variant="primary"
size="sm"
disabled={disabled || !engineAvailable}
onClick={onDownload}
>
{hasPartial
? t("localAsr.resume")
: t("localAsr.download")}
</Btn>
{hasPartial && (
<Btn
variant="ghost"
size="sm"
disabled={disabled}
onClick={onDelete}
>
{t("localAsr.delete")}
</Btn>
)}
<Btn
variant="ghost"
size="sm"
disabled={disabled}
onClick={onReveal}
>
{t("localAsr.revealDir")}
</Btn>
</>
)}
</div>
</div>
{testResult && <TestResultBlock result={testResult} />}
</Card>
)
}
export function TestResultBlock({
result,
}: {
result: LocalAsrTestResult | { error: string }
}) {
const { t } = useTranslation()
const hasError = "error" in result
return (
<div
style={{
marginTop: 12,
padding: "10px 12px",
background: hasError
? "rgba(255, 220, 220, 0.5)"
: "rgba(0, 0, 0, 0.04)",
borderRadius: 8,
fontSize: 12.5,
color: hasError ? "#9b2c2c" : "var(--ol-ink-2)",
lineHeight: 1.6,
}}
>
{hasError ? (
<div>
<strong>{t("localAsr.testFailed")}: </strong>
{result.error}
</div>
) : (
<div
style={{ display: "flex", flexDirection: "column", gap: 4 }}
>
<div
style={{
fontSize: 11,
color: "var(--ol-ink-4)",
letterSpacing: ".04em",
textTransform: "uppercase",
}}
>
{t("localAsr.testHeading")}
</div>
<div>
<span style={{ color: "var(--ol-ink-4)" }}>
{t("localAsr.testExpected")}:{" "}
</span>
{result.expectedText}
</div>
<div>
<span style={{ color: "var(--ol-ink-4)" }}>
{t("localAsr.testActual")}:{" "}
</span>
<strong>{result.transcribedText || "(空)"}</strong>
</div>
<div style={{ fontSize: 11, color: "var(--ol-ink-4)" }}>
{t("localAsr.testStats", {
audio: (result.audioMs / 1000).toFixed(1),
load: (result.loadMs / 1000).toFixed(1),
transcribe: (result.transcribeMs / 1000).toFixed(1),
backend: result.backend,
})}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,68 @@
// Pure UI helpers extracted from LocalAsr/index.tsx (behavior-preserving move).
// Alias/language-hint guards, platform detection, and size formatting used by the
// Local ASR page and its sub-components.
import {
FOUNDRY_LOCAL_ASR_MODELS,
SHERPA_ONNX_ASR_MODELS,
type FoundryLocalAsrLanguageHint,
type FoundryLocalAsrModelAlias,
type FoundryRuntimeSource,
type SherpaOnnxLanguageHint,
type SherpaOnnxModelAlias,
} from "../../lib/localAsr"
export function isFoundryAlias(value: string): value is FoundryLocalAsrModelAlias {
return FOUNDRY_LOCAL_ASR_MODELS.some((model) => model.alias === value)
}
export function isSherpaAlias(value: string): value is SherpaOnnxModelAlias {
return SHERPA_ONNX_ASR_MODELS.some((model) => model.alias === value)
}
export function normalizeFoundryLanguageHintForUi(
value: string,
): FoundryLocalAsrLanguageHint {
return value === "zh" || value === "en" ? value : ""
}
export function normalizeSherpaLanguageHintForUi(
value: string,
): SherpaOnnxLanguageHint {
return value === "zh" ||
value === "en" ||
value === "ja" ||
value === "ko" ||
value === "yue"
? value
: ""
}
export function normalizeFoundryRuntimeSourceForUi(
value: string,
): FoundryRuntimeSource {
return value === "nuget" || value === "ort-nightly" ? value : "auto"
}
export function isWindowsLikePlatform(): boolean {
const nav = navigator as Navigator & {
userAgentData?: { platform?: string }
}
const platform =
nav.userAgentData?.platform || navigator.platform || navigator.userAgent
return /win/i.test(platform)
}
export function formatFoundrySizeMb(
fileSizeMb: number | null | undefined,
): string | null {
if (typeof fileSizeMb !== "number" || fileSizeMb <= 0) return null
return Math.round(fileSizeMb).toLocaleString()
}
export function formatBytes(n: number): string {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(0)} MB`
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`
}
@@ -16,7 +16,7 @@ import {
type ReactNode,
} from "react"
import { useTranslation } from "react-i18next"
import { isTauri, setActiveAsrProvider } from "../lib/ipc"
import { isTauri, setActiveAsrProvider } from "../../lib/ipc"
import {
FOUNDRY_LOCAL_ASR_MODELS,
SHERPA_ONNX_ASR_MODELS,
@@ -77,11 +77,27 @@ import {
type SherpaOnnxLanguageHint,
type SherpaOnnxModelAlias,
type SherpaPrepareProgress,
} from "../lib/localAsr"
import { useHotkeySettings } from "../state/HotkeySettingsContext"
import { detectOS } from "../components/WindowChrome"
import { SelectLite } from "../components/ui/SelectLite"
import { Btn, Card, PageHeader, Pill } from "./_atoms"
} from "../../lib/localAsr"
import { useHotkeySettings } from "../../state/HotkeySettingsContext"
import { detectOS } from "../../components/WindowChrome"
import { SelectLite } from "../../components/ui/SelectLite"
import { Btn, Card, PageHeader, Pill } from "../_atoms"
import {
formatBytes,
formatFoundrySizeMb,
isFoundryAlias,
isSherpaAlias,
isWindowsLikePlatform,
normalizeFoundryLanguageHintForUi,
normalizeFoundryRuntimeSourceForUi,
normalizeSherpaLanguageHintForUi,
} from "./helpers"
import {
DownloadProgressBlock,
FoundryPrepareProgressBlock,
ModelRow,
} from "./components"
import type { RemoteSize } from "./types"
// Foundry Local Whisper 后端只在 Windows 编译实体(foundry_local_sdk 仅 Windows),
// 非 Windows 平台 runtime 是 stub 永远 unavailable。前端这一页对应的卡片、状态拉取、
@@ -94,13 +110,6 @@ import { Btn, Card, PageHeader, Pill } from "./_atoms"
const IS_WINDOWS = detectOS() === "win"
const IS_MAC = detectOS() === "mac"
interface RemoteSize {
totalBytes: number
fileCount: number
loading: boolean
error: string | null
}
interface LocalAsrProps {
/// `embedded=true` 表示作为子组件嵌入「高级」设置页(Settings → Advanced);
/// 此时跳过外层 page padding/height、PageHeader 与独立警告 Card —— 这些由
@@ -2905,593 +2914,8 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
)
}
function FoundryPrepareProgressBlock({
progress,
modelCached,
cancelRequested,
}: {
progress: FoundryPrepareProgress | null
modelCached: boolean
cancelRequested: boolean
}) {
const { t } = useTranslation()
const stages = [
{ phase: "runtime", label: t("localAsr.foundryPrepareRuntime") },
{ phase: "model", label: t("localAsr.foundryPrepareModel") },
{ phase: "load", label: t("localAsr.foundryPrepareLoad") },
] as const
const currentIndex = progress
? stages.findIndex((stage) => stage.phase === progress.phase)
: -1
// Presentational sub-components (FoundryPrepareProgressBlock, DownloadProgressBlock,
// ModelRow, TestResultBlock) live in ./components — imported at the top of this file.
return (
<div
style={{
padding: "10px 12px",
borderRadius: 8,
background: "rgba(0,0,0,0.035)",
display: "flex",
flexDirection: "column",
gap: 9,
}}
>
{stages.map((stage, index) => {
const finished =
progress?.phase === "finished" || currentIndex > index
const skippedCachedModel =
stage.phase === "model" &&
modelCached &&
(progress?.phase === "load" ||
progress?.phase === "finished")
const active = progress?.phase === stage.phase
const failed = progress?.phase === "failed"
const percent =
finished || skippedCachedModel
? 100
: active
? Math.max(0, Math.min(100, progress?.percent ?? 0))
: 0
const detail = skippedCachedModel
? t("localAsr.foundryPrepareModelSkipped")
: active
? progress?.label
: finished
? t("localAsr.foundryPrepareDone")
: t("localAsr.foundryPrepareWaiting")
return (
<div key={stage.phase}>
<div
style={{
display: "flex",
justifyContent: "space-between",
gap: 12,
marginBottom: 5,
}}
>
<span
style={{
fontSize: 12,
color: "var(--ol-ink-2)",
fontWeight: 600,
}}
>
{stage.label}
</span>
<span
style={{
fontSize: 11,
color: "var(--ol-ink-4)",
}}
>
{failed
? t("localAsr.failed")
: `${Math.round(percent)}%`}
</span>
</div>
<div
style={{
height: 6,
borderRadius: 3,
overflow: "hidden",
background: "rgba(0,0,0,0.08)",
}}
>
<div
style={{
height: "100%",
width: `${percent}%`,
background: failed
? "#d04545"
: "var(--ol-accent-blue, #2c5cff)",
transition: "width 120ms linear",
}}
/>
</div>
<div
style={{
fontSize: 11,
color: "var(--ol-ink-4)",
marginTop: 4,
}}
>
{detail}
</div>
</div>
)
})}
{cancelRequested && (
<div
style={{
fontSize: 11.5,
color: "#8a5a00",
lineHeight: 1.5,
}}
>
{t("localAsr.foundryCancelBestEffort")}
</div>
)}
{progress?.phase === "failed" && progress.error && (
<div
style={{
fontSize: 11.5,
color: "#9b2c2c",
lineHeight: 1.5,
}}
>
{progress.error}
</div>
)}
</div>
)
}
function DownloadProgressBlock({
progress,
remoteSize,
cancelRequested,
}: {
progress?: LocalAsrDownloadProgress
remoteSize?: RemoteSize
cancelRequested: boolean
}) {
const { t } = useTranslation()
const downloadedBytes = progress?.bytesDownloaded ?? 0
const totalBytes = progress?.bytesTotal ?? remoteSize?.totalBytes ?? 0
const ratio = totalBytes > 0 ? Math.min(1, downloadedBytes / totalBytes) : 0
const failed = progress?.phase === "failed"
return (
<div
style={{
padding: "10px 12px",
borderRadius: 8,
background: "rgba(0,0,0,0.035)",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
gap: 12,
}}
>
<span
style={{
fontSize: 12,
color: "var(--ol-ink-2)",
fontWeight: 600,
}}
>
{t("localAsr.foundryPrepareModel")}
</span>
<span style={{ fontSize: 11, color: "var(--ol-ink-4)" }}>
{failed
? t("localAsr.failed")
: `${Math.round(ratio * 100)}%`}
</span>
</div>
<div
style={{
height: 6,
borderRadius: 3,
overflow: "hidden",
background: "rgba(0,0,0,0.08)",
}}
>
<div
style={{
height: "100%",
width: `${ratio * 100}%`,
background: failed
? "#d04545"
: "var(--ol-accent-blue, #2c5cff)",
transition: "width 120ms linear",
}}
/>
</div>
<div style={{ fontSize: 11, color: "var(--ol-ink-4)" }}>
{failed
? `${t("localAsr.failed")}: ${progress?.error ?? ""}`
: `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` +
(progress?.file ? ` · ${progress.file}` : "")}
</div>
{cancelRequested && (
<div
style={{
fontSize: 11.5,
color: "#8a5a00",
lineHeight: 1.5,
}}
>
{t("localAsr.foundryCancelRequested")}
</div>
)}
</div>
)
}
interface ModelRowProps {
model: LocalAsrModelStatus
modelDir: string
remoteSize?: RemoteSize
progress?: LocalAsrDownloadProgress
isActive: boolean
engineAvailable: boolean
disabled: boolean
testing: boolean
testResult?: LocalAsrTestResult | { error: string }
onDownload: () => void
onCancel: () => void
onDelete: () => void
onReveal: () => void
onSetActive: () => void
onTest: () => void
}
function ModelRow({
model,
modelDir,
remoteSize,
progress,
isActive,
engineAvailable,
disabled,
testing,
testResult,
onDownload,
onCancel,
onDelete,
onReveal,
onSetActive,
onTest,
}: ModelRowProps) {
const { t } = useTranslation()
const isDownloading = useMemo(
() => progress?.phase === "started" || progress?.phase === "progress",
[progress?.phase],
)
const downloadedBytes = progress?.bytesDownloaded ?? model.downloadedBytes
const totalBytes = progress?.bytesTotal ?? remoteSize?.totalBytes ?? 0
const ratio = totalBytes > 0 ? Math.min(1, downloadedBytes / totalBytes) : 0
// 进度条要保留:有 partial 残留(downloadedBytes>0 但未完整)就一直显示,
// 让用户看到上次下到哪里了,再点下载会从那里续。
const hasPartial = !model.isDownloaded && model.downloadedBytes > 0
const showProgress =
isDownloading || progress?.phase === "failed" || hasPartial
const sizeLabel = remoteSize?.loading
? t("localAsr.sizeLoading")
: remoteSize?.error
? t("localAsr.sizeUnknown")
: remoteSize && remoteSize.totalBytes > 0
? `${formatBytes(remoteSize.totalBytes)} · ${remoteSize.fileCount} ${t("localAsr.files")}`
: t("localAsr.sizeUnknown")
return (
<Card>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 16,
}}
>
<div style={{ minWidth: 0 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 4,
}}
>
<div
style={{
fontSize: 14,
fontWeight: 600,
color: "var(--ol-ink)",
}}
>
{model.id}
</div>
{isActive && (
<Pill tone="blue" size="sm">
{t("localAsr.activeBadge")}
</Pill>
)}
{model.isDownloaded && (
<Pill tone="ok" size="sm">
{t("localAsr.downloadedBadge")}
</Pill>
)}
</div>
<div style={{ fontSize: 12, color: "var(--ol-ink-3)" }}>
{model.hfRepo} · {sizeLabel}
</div>
<div
style={{
fontSize: 11,
color: "var(--ol-ink-4)",
marginTop: 4,
wordBreak: "break-all",
}}
>
{t("localAsr.modelDir")}:{" "}
<code>{modelDir || "—"}</code>
</div>
{showProgress && (
<div style={{ marginTop: 10, maxWidth: 420 }}>
<div
style={{
height: 6,
borderRadius: 3,
background: "rgba(0,0,0,0.06)",
overflow: "hidden",
}}
>
<div
style={{
width: `${ratio * 100}%`,
height: "100%",
background:
progress?.phase === "failed"
? "#d04545"
: "var(--ol-accent-blue, #2c5cff)",
transition: "width 120ms linear",
}}
/>
</div>
<div
style={{
fontSize: 11,
color: "var(--ol-ink-4)",
marginTop: 6,
}}
>
{progress?.phase === "failed"
? `${t("localAsr.failed")}: ${progress.error ?? ""}`
: `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` +
(progress?.file
? ` · ${progress.file}`
: "")}
</div>
</div>
)}
</div>
<div
style={{
display: "flex",
gap: 8,
flexShrink: 0,
flexWrap: "wrap",
justifyContent: "flex-end",
maxWidth: 360,
}}
>
{model.isDownloaded ? (
<>
{!isActive && (
<Btn
variant="blue"
size="sm"
disabled={disabled || !engineAvailable}
onClick={onSetActive}
>
{t("localAsr.setActive")}
</Btn>
)}
<Btn
variant="primary"
size="sm"
disabled={
disabled || testing || !engineAvailable
}
onClick={onTest}
>
{testing
? t("localAsr.testRunning")
: t("localAsr.test")}
</Btn>
<Btn
variant="ghost"
size="sm"
disabled={disabled || testing}
onClick={onDelete}
>
{t("localAsr.delete")}
</Btn>
<Btn
variant="ghost"
size="sm"
disabled={disabled}
onClick={onReveal}
>
{t("localAsr.revealDir")}
</Btn>
</>
) : isDownloading ? (
<Btn variant="ghost" size="sm" onClick={onCancel}>
{t("localAsr.cancel")}
</Btn>
) : (
<>
<Btn
variant="primary"
size="sm"
disabled={disabled || !engineAvailable}
onClick={onDownload}
>
{hasPartial
? t("localAsr.resume")
: t("localAsr.download")}
</Btn>
{hasPartial && (
<Btn
variant="ghost"
size="sm"
disabled={disabled}
onClick={onDelete}
>
{t("localAsr.delete")}
</Btn>
)}
<Btn
variant="ghost"
size="sm"
disabled={disabled}
onClick={onReveal}
>
{t("localAsr.revealDir")}
</Btn>
</>
)}
</div>
</div>
{testResult && <TestResultBlock result={testResult} />}
</Card>
)
}
function TestResultBlock({
result,
}: {
result: LocalAsrTestResult | { error: string }
}) {
const { t } = useTranslation()
const hasError = "error" in result
return (
<div
style={{
marginTop: 12,
padding: "10px 12px",
background: hasError
? "rgba(255, 220, 220, 0.5)"
: "rgba(0, 0, 0, 0.04)",
borderRadius: 8,
fontSize: 12.5,
color: hasError ? "#9b2c2c" : "var(--ol-ink-2)",
lineHeight: 1.6,
}}
>
{hasError ? (
<div>
<strong>{t("localAsr.testFailed")}: </strong>
{result.error}
</div>
) : (
<div
style={{ display: "flex", flexDirection: "column", gap: 4 }}
>
<div
style={{
fontSize: 11,
color: "var(--ol-ink-4)",
letterSpacing: ".04em",
textTransform: "uppercase",
}}
>
{t("localAsr.testHeading")}
</div>
<div>
<span style={{ color: "var(--ol-ink-4)" }}>
{t("localAsr.testExpected")}:{" "}
</span>
{result.expectedText}
</div>
<div>
<span style={{ color: "var(--ol-ink-4)" }}>
{t("localAsr.testActual")}:{" "}
</span>
<strong>{result.transcribedText || "(空)"}</strong>
</div>
<div style={{ fontSize: 11, color: "var(--ol-ink-4)" }}>
{t("localAsr.testStats", {
audio: (result.audioMs / 1000).toFixed(1),
load: (result.loadMs / 1000).toFixed(1),
transcribe: (result.transcribeMs / 1000).toFixed(1),
backend: result.backend,
})}
</div>
</div>
)}
</div>
)
}
function isFoundryAlias(value: string): value is FoundryLocalAsrModelAlias {
return FOUNDRY_LOCAL_ASR_MODELS.some((model) => model.alias === value)
}
function isSherpaAlias(value: string): value is SherpaOnnxModelAlias {
return SHERPA_ONNX_ASR_MODELS.some((model) => model.alias === value)
}
function normalizeFoundryLanguageHintForUi(
value: string,
): FoundryLocalAsrLanguageHint {
return value === "zh" || value === "en" ? value : ""
}
function normalizeSherpaLanguageHintForUi(
value: string,
): SherpaOnnxLanguageHint {
return value === "zh" ||
value === "en" ||
value === "ja" ||
value === "ko" ||
value === "yue"
? value
: ""
}
function normalizeFoundryRuntimeSourceForUi(
value: string,
): FoundryRuntimeSource {
return value === "nuget" || value === "ort-nightly" ? value : "auto"
}
function isWindowsLikePlatform(): boolean {
const nav = navigator as Navigator & {
userAgentData?: { platform?: string }
}
const platform =
nav.userAgentData?.platform || navigator.platform || navigator.userAgent
return /win/i.test(platform)
}
function formatFoundrySizeMb(
fileSizeMb: number | null | undefined,
): string | null {
if (typeof fileSizeMb !== "number" || fileSizeMb <= 0) return null
return Math.round(fileSizeMb).toLocaleString()
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(0)} MB`
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`
}
// Pure UI helpers (alias/language-hint guards, platform detection, size
// formatting) live in ./helpers — imported at the top of this file.
@@ -0,0 +1,8 @@
// Shared types for the Local ASR page and its sub-components.
export interface RemoteSize {
totalBytes: number
fileCount: number
loading: boolean
error: string | null
}
@@ -12,7 +12,6 @@
// dev 上传需要 prefs.marketplaceDevLogin(GitHub login 风格)—— 空时上传按钮 disabled。
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAutoAnimate } from '@formkit/auto-animate/react';
import { AnimatePresence, motion } from 'framer-motion';
import { useTranslation } from 'react-i18next';
import { Icon } from '../components/Icon';
@@ -43,7 +42,6 @@ type SortMode = 'popular' | 'new' | 'liked';
export function Marketplace() {
const { t } = useTranslation();
const { prefs, updatePrefs } = useHotkeySettings();
const [listRef] = useAutoAnimate<HTMLDivElement>({ duration: 300, easing: 'cubic-bezier(0.175, 0.885, 0.32, 1.275)' });
// 启动时尝试读缓存:上次默认视图(popular + 空 query)的列表,秒呈现。后台 refresh 校准。
const [items, setItems] = useState<MarketplaceListItem[]>(() => readMarketplaceListCache() ?? []);
@@ -56,7 +54,6 @@ export function Marketplace() {
const [detail, setDetail] = useState<MarketplaceDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [actionMsg, setActionMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
const dismissActionMsg = useCallback(() => setActionMsg(null), []);
const [showUpload, setShowUpload] = useState(false);
const [uploadOriginPackId, setUploadOriginPackId] = useState<string | null>(null);
-4
View File
@@ -210,16 +210,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;
useEffect(() => {
if (!selectedPack) {
+4 -4
View File
@@ -193,23 +193,23 @@ html[data-ol-platform="android"] .ol-frost::before {
background: var(--ol-app-shell-bg);
}
.ol-aura-panel {
.ol-panel-surface {
background: var(--ol-panel-bg);
border: 0.5px solid var(--ol-panel-border);
box-shadow: var(--ol-panel-shadow);
}
.ol-aura-card {
.ol-card-surface {
background: var(--ol-card-bg);
border: 0.5px solid var(--ol-card-border);
box-shadow: var(--ol-card-shadow);
}
.ol-aura-settings {
.ol-settings-surface {
background: var(--ol-settings-content-bg);
}
.ol-aura-settings[data-ol-mobile="true"] {
.ol-settings-surface[data-ol-mobile="true"] {
border-radius: var(--ol-panel-radius) var(--ol-panel-radius) 0 0;
}
+12 -29
View File
@@ -1,4 +1,4 @@
/* OpenLess design tokens with Aura-specific extensions. */
/* OpenLess design tokens. */
:root {
/* QaPanel(划词追问)保留新代码引用的 6 个 solid/radius token —— 旧 UI 回退后
@@ -30,22 +30,15 @@
--ol-line-strong: rgba(0, 0, 0, 0.14);
--ol-line-soft: rgba(0, 0, 0, 0.05);
/* Aura palette */
--ol-bg-base: #f3f4f6;
--ol-bg-elevated: #fbfbfc;
--ol-aura-surface: rgba(255, 255, 255, 0.72);
--ol-aura-surface-2: rgba(255, 255, 255, 0.88);
--ol-aura-surface-solid: #ffffff;
--ol-aura-line: rgba(15, 23, 42, 0.08);
--ol-aura-line-strong: rgba(15, 23, 42, 0.14);
--ol-aura-line-soft: rgba(255, 255, 255, 0.55);
/* Ink */
--ol-ink: #0a0a0b;
--ol-ink-2: #2a2a2d;
--ol-ink-3: rgba(10, 10, 11, 0.62);
/* AA 修复:AURA 回退把 alpha 从 0.58 降到 0.42,浅色 surface 上的弱化文字跌到
2.88:1(< WCAG AA 4.5:1)。恢复回退前的 0.58(≈5.1:1,仍比 --ol-ink-3 浅)。 */
/* AA 修复:曾把 alpha 从 0.58 降到 0.42,浅色 surface 上的弱化文字跌到
2.88:1(< WCAG AA 4.5:1)。恢复为 0.58(≈5.1:1,仍比 --ol-ink-3 浅)。 */
--ol-ink-4: rgba(10, 10, 11, 0.58);
--ol-ink-5: rgba(10, 10, 11, 0.24);
@@ -63,10 +56,6 @@
--ol-glass-blur: 20px;
--ol-glass-blur-strong: 36px;
/* Aura glass */
--ol-aura-glass-border: rgba(255, 255, 255, 0.58);
--ol-aura-glass-blur: 24px;
/* Frost grain texture used by the .ol-frost helpers in global.css. */
--ol-frost-grain: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='100'%20height='100'%3E%3Cfilter%20id='g'%3E%3CfeTurbulence%20type='fractalNoise'%20baseFrequency='0.95'%20numOctaves='1'%20stitchTiles='stitch'/%3E%3CfeColorMatrix%20type='matrix'%20values='0.34%200.34%200.34%200%200%200.34%200.34%200.34%200%200%200.34%200.34%200.34%200%200%200%200%200%200%201'/%3E%3C/filter%3E%3Crect%20width='100'%20height='100'%20filter='url(%23g)'/%3E%3C/svg%3E");
@@ -80,8 +69,6 @@
--ol-shadow-md: 0 1px 2px rgba(15, 17, 22, 0.05), 0 6px 24px -12px rgba(15, 17, 22, 0.10), 0 0 0 0.5px rgba(0, 0, 0, 0.04);
--ol-shadow-lg: 0 20px 60px -20px rgba(15, 17, 22, 0.18), 0 8px 32px -16px rgba(15, 17, 22, 0.10), 0 0 0 0.5px rgba(0, 0, 0, 0.06);
--ol-shadow-xl: 0 40px 120px -40px rgba(15, 17, 22, 0.30), 0 24px 60px -24px rgba(15, 17, 22, 0.15), 0 0 0 0.5px rgba(0, 0, 0, 0.06);
--ol-aura-shadow: 0 24px 80px -32px rgba(15, 23, 42, 0.24), 0 12px 36px -18px rgba(15, 23, 42, 0.12);
--ol-aura-shadow-soft: 0 10px 30px -18px rgba(15, 23, 42, 0.16), 0 0 0 0.5px rgba(255, 255, 255, 0.55) inset;
/* Radii */
--ol-shell-radius: 32px;
@@ -120,12 +107,15 @@
--ol-linux-titlebar-bg: rgba(245, 245, 247, 0.85);
--ol-linux-titlebar-border: rgba(0, 0, 0, 0.08);
--ol-panel-bg: var(--ol-aura-surface);
--ol-panel-border: var(--ol-aura-glass-border);
--ol-panel-shadow: var(--ol-aura-shadow);
--ol-panel-bg: rgba(255, 255, 255, 0.72);
--ol-panel-border: rgba(255, 255, 255, 0.58);
--ol-panel-shadow: 0 24px 80px -32px rgba(15, 23, 42, 0.24), 0 12px 36px -18px rgba(15, 23, 42, 0.12);
--ol-card-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(252, 252, 253, 0.82));
--ol-card-border: rgba(255, 255, 255, 0.74);
--ol-card-shadow: var(--ol-aura-shadow-soft);
--ol-card-shadow: 0 10px 30px -18px rgba(15, 23, 42, 0.16), 0 0 0 0.5px rgba(255, 255, 255, 0.55) inset;
/* 玻璃面板背景(半透明,配 backdrop-filter);FloatingShell 主区非不透明分支用,
light/dark 取值不同需保持主题感知。 */
--ol-panel-glass-bg: rgba(255, 255, 255, 0.72);
--ol-sidebar-bg: linear-gradient(180deg, rgba(255,255,255,0.34), rgba(255,255,255,0.12));
--ol-sidebar-border: rgba(255,255,255,0.38);
@@ -252,12 +242,8 @@
--ol-bg-base: #0a0d12;
--ol-bg-elevated: #10151d;
--ol-aura-surface: rgba(17, 23, 31, 0.76);
--ol-aura-surface-2: rgba(22, 29, 39, 0.88);
--ol-aura-surface-solid: #171d26;
--ol-aura-line: rgba(255, 255, 255, 0.10);
--ol-aura-line-strong: rgba(255, 255, 255, 0.16);
--ol-aura-line-soft: rgba(255, 255, 255, 0.08);
/* 玻璃面板背景暗色取值。 */
--ol-panel-glass-bg: rgba(17, 23, 31, 0.76);
--ol-ink: #f4f7fb;
--ol-ink-2: #d8dfeb;
@@ -274,14 +260,11 @@
--ol-glass-bg: rgba(15, 19, 27, 0.68);
--ol-glass-bg-strong: rgba(17, 22, 30, 0.84);
--ol-glass-border: rgba(255, 255, 255, 0.10);
--ol-aura-glass-border: rgba(255, 255, 255, 0.10);
--ol-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.34), 0 0 0 0.5px rgba(255, 255, 255, 0.04);
--ol-shadow-md: 0 10px 30px -20px rgba(0, 0, 0, 0.42), 0 0 0 0.5px rgba(255, 255, 255, 0.05);
--ol-shadow-lg: 0 24px 72px -32px rgba(0, 0, 0, 0.58), 0 0 0 0.5px rgba(255, 255, 255, 0.05);
--ol-shadow-xl: 0 48px 120px -52px rgba(0, 0, 0, 0.72), 0 0 0 0.5px rgba(255, 255, 255, 0.06);
--ol-aura-shadow: 0 28px 90px -44px rgba(0, 0, 0, 0.72), 0 12px 36px -24px rgba(2, 8, 20, 0.48);
--ol-aura-shadow-soft: 0 18px 40px -28px rgba(0, 0, 0, 0.62), 0 0 0 0.5px rgba(255, 255, 255, 0.06) inset;
--ol-ok: #4ade80;
--ol-ok-soft: rgba(74, 222, 128, 0.14);