Merge remote-tracking branch 'upstream/beta' into feat/mobile-remote-input

# Conflicts:
#	openless-all/app/src-tauri/Cargo.toml
#	openless-all/app/src-tauri/src/coordinator.rs
This commit is contained in:
ciddwd committed 2026-06-07 17:52:34 +08:00
commit 4f199ebe62
75 files changed
+7656 -624

No files matched your search

+13
View File
@@ -550,6 +550,16 @@ jobs:
openless-all/app/src-tauri/target/release/bundle/latest-windows-x86_64*.json
if-no-files-found: error
- name: Upload fcitx5 plugin artifact (standalone)
if: matrix.platform == 'ubuntu-22.04'
uses: actions/upload-artifact@v4
with:
name: openless-fcitx5-plugin-linux-x64
path: |
${{ github.workspace }}/openless-all/scripts/linux-fcitx5-plugin/build/libopenless.so
${{ github.workspace }}/openless-all/scripts/linux-fcitx5-plugin/build/openless.conf
if-no-files-found: error
- name: Upload Linux artifacts
if: matrix.platform == 'ubuntu-22.04'
uses: actions/upload-artifact@v4
@@ -637,6 +647,9 @@ jobs:
openless-all/app/src-tauri/target/release/bundle/appimage/*.AppImage
openless-all/app/src-tauri/target/release/bundle/appimage/*.AppImage.sig
openless-all/app/src-tauri/target/release/bundle/latest-*.json
# fcitx5 插件独立下载:供 AppImage 用户或手动安装
openless-all/scripts/linux-fcitx5-plugin/build/libopenless.so
openless-all/scripts/linux-fcitx5-plugin/build/openless.conf
# ── 正式版发布后,自动更新 Homebrew cask ──
# 为什么放进这条流水线,而不是单独的 `release: published` 工作流:softprops 用默认
@@ -0,0 +1,541 @@
# 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"
```
@@ -0,0 +1,254 @@
# 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.
+26
View File
@@ -14,6 +14,8 @@
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-shell": "^2.0.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"@types/dompurify": "^3.0.5",
"dompurify": "^3.4.8",
"framer-motion": "^12.39.0",
"i18next": "^26.0.8",
"marked": "^11.2.0",
@@ -1433,6 +1435,15 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1468,6 +1479,12 @@
"@types/react": "^18.0.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1589,6 +1606,15 @@
}
}
},
"node_modules/dompurify": {
"version": "3.4.8",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.344",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz",
+3
View File
@@ -8,6 +8,7 @@
"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"
},
@@ -18,6 +19,8 @@
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-shell": "^2.0.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"@types/dompurify": "^3.0.5",
"dompurify": "^3.4.8",
"framer-motion": "^12.39.0",
"i18next": "^26.0.8",
"marked": "^11.2.0",
@@ -0,0 +1,68 @@
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');
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
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);
}
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] = 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');
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');
console.log('Aura skin contract OK');
+142 -10
View File
@@ -155,7 +155,8 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"windows-sys 0.52.0",
"wl-clipboard-rs",
"x11rb",
]
@@ -737,7 +738,7 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
"nom 7.1.3",
]
[[package]]
@@ -1329,7 +1330,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -1393,6 +1394,12 @@ dependencies = [
"tendril",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dpi"
version = "0.1.2"
@@ -1570,7 +1577,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -1677,6 +1684,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.9"
@@ -3323,6 +3336,15 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "num"
version = "0.4.3"
@@ -3923,7 +3945,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.45.0",
]
[[package]]
@@ -4026,6 +4048,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap 2.14.0",
]
[[package]]
name = "phf"
version = "0.11.3"
@@ -4435,7 +4468,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -4824,7 +4857,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -4882,7 +4915,7 @@ dependencies = [
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -6009,7 +6042,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -6449,6 +6482,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom 8.0.0",
"petgraph",
]
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -6877,6 +6921,76 @@ dependencies = [
"semver",
]
[[package]]
name = "wayland-backend"
version = "0.3.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
dependencies = [
"cc",
"downcast-rs",
"rustix",
"smallvec",
"wayland-sys",
]
[[package]]
name = "wayland-client"
version = "0.31.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
dependencies = [
"bitflags 2.11.1",
"rustix",
"wayland-backend",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols"
version = "0.32.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f"
dependencies = [
"bitflags 2.11.1",
"wayland-backend",
"wayland-client",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-wlr"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
dependencies = [
"bitflags 2.11.1",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-scanner"
version = "0.31.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
dependencies = [
"proc-macro2",
"quick-xml",
"quote",
]
[[package]]
name = "wayland-sys"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
dependencies = [
"pkg-config",
]
[[package]]
name = "web-sys"
version = "0.3.97"
@@ -7044,7 +7158,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
@@ -7819,6 +7933,24 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wl-clipboard-rs"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix",
"thiserror 2.0.18",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "writeable"
version = "0.6.3"
+1 -1
View File
@@ -54,7 +54,7 @@ ferrous-opencc = "0.4"
global-hotkey = "0.6"
cpal = "0.15"
enigo = "0.2"
arboard = "3"
arboard = { version = "3", features = ["wayland-data-control"] }
rcgen = "^0.13"
local-ip-address = "^0.6"
rustls = { version = "^0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
+3 -1
View File
@@ -24,7 +24,9 @@ int openless_common_controls_v6_manifest_dependency_anchor = 0;
cc::Build::new()
.file(&source_path)
.compile("openless_common_controls_v6_manifest_dependency");
println!("cargo:rustc-link-arg=/INCLUDE:openless_common_controls_v6_manifest_dependency_anchor");
println!(
"cargo:rustc-link-arg=/INCLUDE:openless_common_controls_v6_manifest_dependency_anchor"
);
}
/// 编译 vendored Open-Less/qwen-asr 的 C 源(仅 macOS)。
@@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capabilities for OpenLess windows",
"windows": ["main", "capsule", "qa"],
"windows": ["main", "capsule", "qa", "less-computer", "less-computer-glow"],
"permissions": [
"core:default",
"core:window:default",
@@ -1,4 +1,7 @@
#![cfg_attr(target_os = "linux", allow(dead_code, unused_imports, unused_variables))]
#![cfg_attr(
target_os = "linux",
allow(dead_code, unused_imports, unused_variables)
)]
//! 本地 Qwen3-ASR 引擎缓存。
//!
//! 用途:避免每次 dictation 都重加载 1.2GB+ 模型。引擎一次 load 后驻留在内存,
@@ -18,9 +18,9 @@ use std::time::Instant;
use anyhow::Result;
use serde::Serialize;
use super::models::ModelId;
#[cfg(target_os = "macos")]
use super::models::model_dir;
use super::models::ModelId;
/// 内嵌测试音频。原始文件 `vendor/qwen-asr/samples/test_speech.wav`
/// 内容:"Hello. This is a test of the Voxtrail speech-to-text system."
+468
View File
@@ -0,0 +1,468 @@
//! Xiaomi MiMo ASR client.
//!
//! MiMo ASR uses the official OpenAI-compatible `/chat/completions` endpoint
//! with `messages[].content[].input_audio`, not Whisper's
//! `/audio/transcriptions` protocol.
use anyhow::{Context, Result};
use base64::Engine;
use parking_lot::Mutex;
use serde_json::Value;
use crate::asr::wav::encode_wav_16k_mono;
use crate::asr::RawTranscript;
const PCM_SAMPLE_RATE_HZ: u64 = 16_000;
const PCM_BYTES_PER_SAMPLE: usize = 2;
// 官方限制:Base64 后的音频数据不能超过 10MB。180s 的 16k/16-bit/mono WAV
// Base64 后约 7.7MB,给 JSON/data-url 前缀和厂商侧 MB 口径差异留余量。
const MIMO_MAX_CHUNK_DURATION_MS: u64 = 180_000;
pub const PROVIDER_ID: &str = "xiaomi-mimo-asr";
pub const DEFAULT_ENDPOINT: &str = "https://api.xiaomimimo.com/v1";
pub const DEFAULT_MODEL: &str = "mimo-v2.5-asr";
pub struct MimoBatchASR {
api_key: String,
base_url: String,
model: String,
buffer: Mutex<Vec<u8>>,
}
impl MimoBatchASR {
pub fn new(api_key: String, base_url: String, model: String) -> Self {
Self {
api_key,
base_url,
model,
buffer: Mutex::new(Vec::new()),
}
}
pub async fn transcribe(&self) -> Result<RawTranscript> {
let pcm = self.buffer.lock().clone();
if pcm.is_empty() {
return Ok(RawTranscript {
text: String::new(),
duration_ms: 0,
});
}
let result = self.transcribe_inner(&pcm).await;
if result.is_ok() {
self.buffer.lock().clear();
}
result
}
async fn transcribe_inner(&self, pcm: &[u8]) -> Result<RawTranscript> {
if self.api_key.trim().is_empty() {
anyhow::bail!("MiMo API key missing");
}
let duration_ms = pcm_duration_ms(pcm);
let chunks = split_pcm_by_duration(pcm, MIMO_MAX_CHUNK_DURATION_MS);
let mut texts = Vec::with_capacity(chunks.len());
for chunk in chunks {
texts.push(self.transcribe_chunk(chunk).await?);
}
Ok(RawTranscript {
text: join_transcript_chunks(&texts),
duration_ms,
})
}
async fn transcribe_chunk(&self, pcm: &[u8]) -> Result<String> {
let samples: Vec<i16> = pcm
.chunks_exact(2)
.map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]]))
.collect();
let wav = encode_wav_16k_mono(&samples);
let body = mimo_chat_body(&self.model, &wav);
let url = mimo_chat_completions_url(&self.base_url)?;
let client = reqwest::Client::new();
let resp = client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key.trim()))
.json(&body)
.send()
.await
.context("MiMo ASR HTTP request failed")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("MiMo ASR API error {}: {}", status, body);
}
let json: Value = resp.json().await.context("parse MiMo ASR response")?;
Ok(extract_mimo_text(&json).trim().to_string())
}
pub fn cancel(&self) {
self.buffer.lock().clear();
}
}
impl crate::recorder::AudioConsumer for MimoBatchASR {
fn consume_pcm_chunk(&self, pcm: &[u8]) {
self.buffer.lock().extend_from_slice(pcm);
}
}
pub fn mimo_chat_completions_url(base_url: &str) -> Result<String> {
let parsed = reqwest::Url::parse(base_url.trim()).context("parse MiMo base URL")?;
let mut url = parsed.clone();
let path = parsed.path().trim_end_matches('/');
let next_path = if path.ends_with("/chat/completions") {
path.to_string()
} else if path.ends_with("/chat") {
format!("{path}/completions")
} else {
format!("{path}/chat/completions")
};
url.set_path(&next_path);
Ok(url.to_string())
}
pub fn mimo_chat_body(model: &str, wav: &[u8]) -> Value {
let audio_data = format!(
"data:audio/wav;base64,{}",
base64::engine::general_purpose::STANDARD.encode(wav)
);
serde_json::json!({
"model": model,
"stream": false,
"messages": [{
"role": "user",
"content": [{
"type": "input_audio",
"input_audio": {
"data": audio_data,
"format": "wav",
},
}],
}],
})
}
pub fn extract_mimo_text(json: &Value) -> String {
let Some(content) = json
.get("choices")
.and_then(|v| v.as_array())
.and_then(|choices| choices.first())
.and_then(|choice| choice.get("message"))
.and_then(|message| message.get("content"))
else {
return String::new();
};
if let Some(text) = content.as_str() {
return text.to_string();
}
content
.as_array()
.map(|items| {
items
.iter()
.filter_map(|item| {
item.get("text")
.and_then(|text| text.as_str())
.or_else(|| item.get("content").and_then(|text| text.as_str()))
})
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default()
}
fn pcm_duration_ms(pcm: &[u8]) -> u64 {
(pcm.len() as u64 / PCM_BYTES_PER_SAMPLE as u64) * 1000 / PCM_SAMPLE_RATE_HZ
}
fn split_pcm_by_duration(pcm: &[u8], max_chunk_duration_ms: u64) -> Vec<&[u8]> {
if max_chunk_duration_ms == 0 {
return vec![pcm];
}
let samples_per_chunk = PCM_SAMPLE_RATE_HZ * max_chunk_duration_ms / 1000;
let bytes_per_chunk = samples_per_chunk as usize * PCM_BYTES_PER_SAMPLE;
if bytes_per_chunk == 0 || pcm.len() <= bytes_per_chunk {
return vec![pcm];
}
pcm.chunks(bytes_per_chunk).collect()
}
fn join_transcript_chunks(chunks: &[String]) -> String {
let mut joined = String::new();
for chunk in chunks.iter().map(|chunk| chunk.trim()) {
if chunk.is_empty() {
continue;
}
if needs_chunk_separator(&joined, chunk) {
joined.push(' ');
}
joined.push_str(chunk);
}
joined
}
fn needs_chunk_separator(current: &str, next: &str) -> bool {
let Some(prev) = current.chars().last() else {
return false;
};
let Some(first) = next.chars().next() else {
return false;
};
if is_closing_punctuation(first) || is_opening_punctuation(prev) {
return false;
}
if is_cjk(prev) && (is_cjk(first) || is_opening_punctuation(first)) {
return false;
}
if is_cjk(first) && is_closing_punctuation(prev) {
return false;
}
if is_cjk_punctuation(prev) && is_cjk(first) {
return false;
}
true
}
fn is_cjk(c: char) -> bool {
matches!(
c as u32,
0x4E00..=0x9FFF
| 0x3400..=0x4DBF
| 0x3040..=0x30FF
| 0xAC00..=0xD7AF
)
}
fn is_cjk_punctuation(c: char) -> bool {
matches!(
c,
'。' | ',' | '、' | '!' | '?' | ';' | ':' | '」' | '』' | ')' | '》'
)
}
fn is_closing_punctuation(c: char) -> bool {
c.is_ascii_punctuation() && !matches!(c, '(' | '[' | '{' | '"' | '\'') || is_cjk_punctuation(c)
}
fn is_opening_punctuation(c: char) -> bool {
matches!(c, '(' | '[' | '{' | '"' | '\'' | '「' | '『' | '(' | '《')
}
#[cfg(test)]
mod tests {
use super::*;
use crate::recorder::AudioConsumer;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
use std::time::{Duration, Instant};
#[test]
fn mimo_url_targets_chat_completions() {
assert_eq!(
mimo_chat_completions_url("https://api.xiaomimimo.com/v1").unwrap(),
"https://api.xiaomimimo.com/v1/chat/completions"
);
assert_eq!(
mimo_chat_completions_url("https://api.xiaomimimo.com/v1/chat/completions").unwrap(),
"https://api.xiaomimimo.com/v1/chat/completions"
);
}
#[test]
fn mimo_body_uses_official_input_audio_shape() {
let body = mimo_chat_body(DEFAULT_MODEL, b"wav");
assert_eq!(body["model"], DEFAULT_MODEL);
assert_eq!(body["stream"], false);
let audio = &body["messages"][0]["content"][0];
assert_eq!(audio["type"], "input_audio");
assert_eq!(audio["input_audio"]["format"], "wav");
assert!(audio["input_audio"]["data"]
.as_str()
.unwrap()
.starts_with("data:audio/wav;base64,"));
}
#[test]
fn mimo_response_extracts_message_content() {
let json = serde_json::json!({
"choices": [{ "message": { "content": "你好 MiMo" } }]
});
assert_eq!(extract_mimo_text(&json), "你好 MiMo");
}
#[test]
fn mimo_response_accepts_content_array_text() {
let json = serde_json::json!({
"choices": [{
"message": {
"content": [
{ "type": "text", "text": "你好" },
{ "type": "text", "text": "MiMo" }
]
}
}]
});
assert_eq!(extract_mimo_text(&json), "你好MiMo");
}
#[tokio::test]
async fn mimo_posts_chat_completion_audio_request() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let addr = listener.local_addr().unwrap();
let server = thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(5);
let mut stream = loop {
match listener.accept() {
Ok((stream, _)) => break stream,
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
assert!(
Instant::now() < deadline,
"timed out waiting for MiMo ASR test request"
);
thread::sleep(Duration::from_millis(10));
}
Err(err) => panic!("accept MiMo ASR test request failed: {err}"),
}
};
stream.set_nonblocking(false).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let request = read_http_request(&mut stream);
let request_text = String::from_utf8_lossy(&request);
let lower = request_text.to_ascii_lowercase();
assert!(request_text.starts_with("POST /chat/completions HTTP/1.1"));
assert!(lower.contains("authorization: bearer key"));
assert!(lower.contains("content-type: application/json"));
assert!(request_text.contains(r#""model":"mimo-v2.5-asr""#));
assert!(request_text.contains(r#""type":"input_audio""#));
assert!(request_text.contains("data:audio/wav;base64,"));
write_json_response(
&mut stream,
r#"{"choices":[{"message":{"content":"mimo ok"}}]}"#,
);
});
let asr = MimoBatchASR::new(
"key".to_string(),
format!("http://{}", addr),
DEFAULT_MODEL.to_string(),
);
asr.consume_pcm_chunk(&vec![0u8; 32_000]);
let transcript = asr.transcribe().await.unwrap();
assert_eq!(transcript.text, "mimo ok");
assert_eq!(transcript.duration_ms, 1_000);
server.join().unwrap();
}
#[tokio::test]
async fn mimo_splits_audio_before_base64_limit() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let addr = listener.local_addr().unwrap();
let server = thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(5);
for (index, text) in ["hello", "world"].into_iter().enumerate() {
let mut stream = loop {
match listener.accept() {
Ok((stream, _)) => break stream,
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
assert!(
Instant::now() < deadline,
"timed out waiting for MiMo ASR chunk request"
);
thread::sleep(Duration::from_millis(10));
}
Err(err) => panic!("accept MiMo ASR chunk request failed: {err}"),
}
};
stream.set_nonblocking(false).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let request = read_http_request(&mut stream);
let request_text = String::from_utf8_lossy(&request);
assert!(request_text.starts_with("POST /chat/completions HTTP/1.1"));
assert!(request_text.contains(r#""model":"mimo-v2.5-asr""#));
assert!(request_text.contains("data:audio/wav;base64,"));
assert!(
request_text.len() < 10 * 1024 * 1024,
"chunk {index} exceeded MiMo 10MB base64 request budget"
);
write_json_response(
&mut stream,
&format!(r#"{{"choices":[{{"message":{{"content":"{text}"}}}}]}}"#),
);
}
});
let asr = MimoBatchASR::new(
"key".to_string(),
format!("http://{}", addr),
DEFAULT_MODEL.to_string(),
);
let pcm = vec![0u8; 32_000 * 181];
asr.consume_pcm_chunk(&pcm);
let transcript = asr.transcribe().await.unwrap();
assert_eq!(transcript.text, "hello world");
assert_eq!(transcript.duration_ms, 181_000);
server.join().unwrap();
}
fn read_http_request(stream: &mut std::net::TcpStream) -> Vec<u8> {
let mut request = Vec::new();
let mut buf = [0u8; 4096];
let mut expected_len = None;
loop {
let read = stream.read(&mut buf).unwrap();
if read == 0 {
break;
}
request.extend_from_slice(&buf[..read]);
if expected_len.is_none() {
expected_len = parse_expected_request_len(&request);
}
if expected_len.is_some_and(|len| request.len() >= len) {
break;
}
}
request
}
fn parse_expected_request_len(request: &[u8]) -> Option<usize> {
let header_end = request.windows(4).position(|w| w == b"\r\n\r\n")? + 4;
let headers = String::from_utf8_lossy(&request[..header_end]);
let content_len = headers.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
if name.eq_ignore_ascii_case("content-length") {
value.trim().parse::<usize>().ok()
} else {
None
}
})?;
Some(header_end + content_len)
}
fn write_json_response(stream: &mut std::net::TcpStream, body: &str) {
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
);
stream.write_all(response.as_bytes()).unwrap();
stream.flush().unwrap();
}
}
@@ -8,11 +8,13 @@
pub mod bailian;
mod frame;
pub mod local;
pub mod mimo;
pub mod volcengine;
pub mod wav;
pub mod whisper;
pub use bailian::{BailianCredentials, BailianRealtimeASR};
pub use mimo::MimoBatchASR;
pub use volcengine::{VolcengineCredentials, VolcengineStreamingASR};
pub use whisper::WhisperBatchASR;
@@ -162,7 +162,9 @@ impl VolcengineStreamingASR {
.map_err(|e| VolcengineASRError::ConnectionFailed(e.to_string()))?,
);
let (ws, _resp) = connect_async(request).await.map_err(classify_connect_error)?;
let (ws, _resp) = connect_async(request)
.await
.map_err(classify_connect_error)?;
let (write, read) = ws.split();
let (tx, rx) = oneshot::channel();
@@ -260,9 +260,8 @@ fn extract_confident_text(json: &serde_json::Value) -> String {
.and_then(|v| v.as_f64())
.unwrap_or(1.0);
let is_hallucination = (no_speech > 0.6 && avg_logprob < -0.5)
|| compression > 2.4
|| avg_logprob < -1.0;
let is_hallucination =
(no_speech > 0.6 && avg_logprob < -0.5) || compression > 2.4 || avg_logprob < -1.0;
if is_hallucination {
log::warn!(
"[whisper] 丢弃疑似幻听段落: no_speech={:.2} avg_logprob={:.2} compression={:.2} text={:?}",
@@ -0,0 +1,233 @@
//! 无头 Claude Code(`claude -p`)调用参数构造。
//!
//! 纯逻辑:把一个 [`CodingAgentRequest`] 翻译成 `claude` 的命令行参数列表。
//! prompt 本身**不**进 argv(避免出现在进程列表里泄露),由运行器写进 stdin。
use std::path::PathBuf;
/// Claude Code 权限模式,对应 CLI `--permission-mode` 的取值(已对本机 v2.1.161 核实)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CodingAgentPermissionMode {
/// 只读/计划模式:不改文件。
Plan,
/// 默认:每个动作都要确认(无头下等于大多被拒,少用)。
Default,
/// 放行可恢复的编辑/动作(本项目「放行 + 护栏」的默认)。
AcceptEdits,
/// 跳过所有权限检查——仅高级区,绝不做默认。
BypassPermissions,
}
impl CodingAgentPermissionMode {
/// 传给 `--permission-mode` 的字符串。
pub fn as_cli_arg(self) -> &'static str {
match self {
Self::Plan => "plan",
Self::Default => "default",
Self::AcceptEdits => "acceptEdits",
Self::BypassPermissions => "bypassPermissions",
}
}
}
impl Default for CodingAgentPermissionMode {
fn default() -> Self {
Self::AcceptEdits
}
}
/// 一次无头 agent 运行的完整请求。
#[derive(Debug, Clone)]
pub struct CodingAgentRequest {
/// 会话标识,用于丢弃迟到事件。
pub session_id: String,
/// 最终发给 Claude 的指令(写入 stdin,不进 argv)。
pub prompt: String,
/// 工作目录;同时作为 `--add-dir` 限定文件作用域。
pub cwd: Option<PathBuf>,
pub model: Option<String>,
pub fallback_model: Option<String>,
pub permission_mode: CodingAgentPermissionMode,
pub allowed_tools: Vec<String>,
pub disallowed_tools: Vec<String>,
/// 单次运行成本硬上限(`--max-budget-usd`)。
pub max_budget_usd: Option<f64>,
/// 运行超时(秒)。
pub timeout_secs: u64,
/// 额外系统提示词(`--append-system-prompt`)。
pub extra_system_prompt: Option<String>,
/// 护栏 settings JSON 文件路径(`--settings`)。
pub settings_json_path: Option<PathBuf>,
/// 是否保留会话(false 时加 `--no-session-persistence`,快取用走 false 更快)。
pub session_persistence: bool,
/// 续接最近一次会话(`--continue`)。Less Computer 连续对话用:第二轮起带上下文。
pub continue_session: bool,
}
impl CodingAgentRequest {
/// 最小化构造:只给会话 id 和 prompt,其余取保守默认。
pub fn new(session_id: impl Into<String>, prompt: impl Into<String>) -> Self {
Self {
session_id: session_id.into(),
prompt: prompt.into(),
cwd: None,
model: None,
fallback_model: None,
permission_mode: CodingAgentPermissionMode::default(),
allowed_tools: Vec::new(),
disallowed_tools: Vec::new(),
max_budget_usd: None,
timeout_secs: 300,
extra_system_prompt: None,
settings_json_path: None,
session_persistence: true,
continue_session: false,
}
}
}
/// 构造 `claude` 的命令行参数(不含可执行文件本身,也不含 prompt)。
///
/// 固定使用无头流式:`-p --output-format stream-json --verbose --include-partial-messages`,
/// 这样前端能拿到逐字 delta。
pub fn build_claude_args(req: &CodingAgentRequest) -> Vec<String> {
let mut args: Vec<String> = vec![
"-p".into(),
"--output-format".into(),
"stream-json".into(),
"--verbose".into(),
"--include-partial-messages".into(),
"--permission-mode".into(),
req.permission_mode.as_cli_arg().into(),
];
if let Some(model) = &req.model {
args.push("--model".into());
args.push(model.clone());
}
if let Some(fm) = &req.fallback_model {
args.push("--fallback-model".into());
args.push(fm.clone());
}
if let Some(cwd) = &req.cwd {
args.push("--add-dir".into());
args.push(cwd.to_string_lossy().into_owned());
}
if !req.allowed_tools.is_empty() {
args.push("--allowedTools".into());
args.push(req.allowed_tools.join(","));
}
if !req.disallowed_tools.is_empty() {
args.push("--disallowedTools".into());
args.push(req.disallowed_tools.join(","));
}
if let Some(budget) = req.max_budget_usd {
args.push("--max-budget-usd".into());
args.push(format!("{budget}"));
}
if let Some(path) = &req.settings_json_path {
args.push("--settings".into());
args.push(path.to_string_lossy().into_owned());
}
if let Some(sp) = &req.extra_system_prompt {
args.push("--append-system-prompt".into());
args.push(sp.clone());
}
if !req.session_persistence {
args.push("--no-session-persistence".into());
}
if req.continue_session {
args.push("--continue".into());
}
args
}
#[cfg(test)]
mod tests {
use super::*;
fn arg_value<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
args.iter()
.position(|a| a == flag)
.and_then(|i| args.get(i + 1))
.map(|s| s.as_str())
}
#[test]
fn default_args_are_headless_streaming() {
let req = CodingAgentRequest::new("s1", "hello");
let args = build_claude_args(&req);
assert!(args.contains(&"-p".to_string()));
assert_eq!(arg_value(&args, "--output-format"), Some("stream-json"));
assert!(args.contains(&"--verbose".to_string()));
assert!(args.contains(&"--include-partial-messages".to_string()));
// prompt 不能出现在 argv 里
assert!(!args.iter().any(|a| a.contains("hello")));
}
#[test]
fn permission_mode_maps_to_cli_string() {
assert_eq!(CodingAgentPermissionMode::Plan.as_cli_arg(), "plan");
assert_eq!(
CodingAgentPermissionMode::AcceptEdits.as_cli_arg(),
"acceptEdits"
);
assert_eq!(
CodingAgentPermissionMode::BypassPermissions.as_cli_arg(),
"bypassPermissions"
);
let mut req = CodingAgentRequest::new("s", "p");
req.permission_mode = CodingAgentPermissionMode::Plan;
assert_eq!(
arg_value(&build_claude_args(&req), "--permission-mode"),
Some("plan")
);
}
#[test]
fn default_permission_mode_is_accept_edits() {
assert_eq!(
CodingAgentPermissionMode::default(),
CodingAgentPermissionMode::AcceptEdits
);
}
#[test]
fn optional_flags_are_emitted_when_set() {
let mut req = CodingAgentRequest::new("s", "p");
req.model = Some("sonnet".into());
req.fallback_model = Some("haiku".into());
req.max_budget_usd = Some(0.5);
req.cwd = Some(PathBuf::from("/tmp/work"));
req.allowed_tools = vec!["Bash(git *)".into(), "Edit".into()];
req.disallowed_tools = vec!["Bash(rm -rf:*)".into()];
req.settings_json_path = Some(PathBuf::from("/tmp/guard.json"));
req.extra_system_prompt = Some("be terse".into());
req.session_persistence = false;
let args = build_claude_args(&req);
assert_eq!(arg_value(&args, "--model"), Some("sonnet"));
assert_eq!(arg_value(&args, "--fallback-model"), Some("haiku"));
assert_eq!(arg_value(&args, "--max-budget-usd"), Some("0.5"));
assert_eq!(arg_value(&args, "--add-dir"), Some("/tmp/work"));
assert_eq!(arg_value(&args, "--allowedTools"), Some("Bash(git *),Edit"));
assert_eq!(
arg_value(&args, "--disallowedTools"),
Some("Bash(rm -rf:*)")
);
assert_eq!(arg_value(&args, "--settings"), Some("/tmp/guard.json"));
assert_eq!(arg_value(&args, "--append-system-prompt"), Some("be terse"));
assert!(args.contains(&"--no-session-persistence".to_string()));
}
#[test]
fn optional_flags_absent_by_default() {
let req = CodingAgentRequest::new("s", "p");
let args = build_claude_args(&req);
assert!(arg_value(&args, "--model").is_none());
assert!(arg_value(&args, "--max-budget-usd").is_none());
assert!(!args.contains(&"--no-session-persistence".to_string()));
}
}
@@ -0,0 +1,179 @@
//! 「Claude 控制台」用到的 Tauri 命令:检测安装 / MCP 列表、护栏化流式测试运行、取消。
//!
//! 这些命令不碰录音 / coordinator,是「快速 Agent」引擎最小可用的垂直切片。
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use serde::Serialize;
use tauri::{AppHandle, Emitter};
use super::detect::{has_computer_use_mcp, McpServerStatus};
use super::guard::build_guard_settings_json;
use super::{
claude_mcp_list, create_git_snapshot, detect_claude, run_claude_agent,
CodingAgentPermissionMode, CodingAgentRequest,
};
/// 当前测试运行的取消标志(一次只跑一个)。
static TEST_CANCEL: Lazy<Mutex<Option<Arc<AtomicBool>>>> = Lazy::new(|| Mutex::new(None));
/// 测试运行计数器,给每次运行一个唯一 session id(避免依赖时间戳)。
static TEST_COUNTER: Lazy<Mutex<u64>> = Lazy::new(|| Mutex::new(0));
fn next_session_id() -> String {
let mut c = TEST_COUNTER.lock();
*c = c.wrapping_add(1);
format!("console-{}", *c)
}
fn normalize_exe(exe: Option<String>) -> String {
exe.map(|e| e.trim().to_string())
.filter(|e| !e.is_empty())
.unwrap_or_else(|| "claude".to_string())
}
/// Claude Code 检测结果(回前端,camelCase)。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClaudeDetectionWire {
/// 是否检测到可运行的 claude。
pub installed: bool,
/// 版本号(如 "2.1.161")。
pub version: Option<String>,
/// 实际使用的可执行文件名/路径。
pub exe: String,
/// 已配置的 MCP server 列表(含健康状态)。
pub mcp_servers: Vec<McpServerStatus>,
/// 是否检测到桌面控制类(computer use)MCP。
pub has_computer_use: bool,
}
/// 检测 claude 是否安装、版本、已配置的 MCP server(即「computer use 技能」检测口径)。
#[tauri::command]
pub async fn coding_agent_detect(exe: Option<String>) -> ClaudeDetectionWire {
let exe = normalize_exe(exe);
let version = detect_claude(&exe).await;
let mcp_servers = if version.is_some() {
claude_mcp_list(&exe).await
} else {
Vec::new()
};
let has_computer_use = has_computer_use_mcp(&mcp_servers);
ClaudeDetectionWire {
installed: version.is_some(),
version,
exe,
mcp_servers,
has_computer_use,
}
}
/// 护栏化地无头跑一次 claude,事件流式 emit 到前端 `coding-agent:test`。
///
/// 安全:附 `--settings`(acceptEdits + 高风险 deny)、`--max-budget-usd` 成本上限;
/// 若 workdir 是 git 仓库,运行前做一次 `git stash create` 快照(可回滚)。
#[tauri::command]
pub async fn coding_agent_run_test(
app: AppHandle,
prompt: String,
exe: Option<String>,
permission_mode: Option<CodingAgentPermissionMode>,
workdir: Option<String>,
model: Option<String>,
max_budget_usd: Option<f64>,
) -> Result<(), String> {
let prompt = prompt.trim().to_string();
if prompt.is_empty() {
return Err("指令为空".into());
}
let exe = normalize_exe(exe);
let mode = permission_mode.unwrap_or_default();
let cwd = workdir
.map(|w| w.trim().to_string())
.filter(|w| !w.is_empty())
.map(std::path::PathBuf::from);
// 运行前 git 快照(仅当是 git 仓库;非仓库返回 None,无副作用)。
if let Some(dir) = &cwd {
if let Some(sha) = create_git_snapshot(dir) {
log::info!("[coding-agent] 运行前已生成 git 快照 {sha}(git stash apply 可回滚)");
}
}
// 写护栏 settings 到临时文件。
let settings_json = build_guard_settings_json(mode.as_cli_arg(), &[]);
let settings_path = std::env::temp_dir().join(format!(
"openless-claude-guard-{}.json",
uuid::Uuid::new_v4()
));
std::fs::write(
&settings_path,
serde_json::to_vec_pretty(&settings_json).map_err(|e| e.to_string())?,
)
.map_err(|e| format!("写护栏配置失败: {e}"))?;
let mut req = CodingAgentRequest::new(next_session_id(), prompt);
req.cwd = cwd;
// 控制台测试默认走 sonnet:比用户默认的 Opus 便宜约一个数量级,足够验证连通与流式。
req.model = model
.filter(|m| !m.trim().is_empty())
.or_else(|| Some("sonnet".to_string()));
req.permission_mode = mode;
req.max_budget_usd = max_budget_usd.or(Some(0.5));
req.timeout_secs = 120;
req.settings_json_path = Some(settings_path.clone());
req.session_persistence = false;
// 「放行 + 护栏」:允许轻动作与可恢复编辑;高风险由 deny 清单拦截。
req.allowed_tools = vec![
"Bash".into(),
"Read".into(),
"Edit".into(),
"Write".into(),
"Glob".into(),
"Grep".into(),
// 去掉 WebFetch:控制台 prompt 同样可被注入诱导 SSRF(与语音路径保持一致)。
"WebSearch".into(),
];
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let cancel = Arc::new(AtomicBool::new(false));
*TEST_CANCEL.lock() = Some(cancel.clone());
let exe_for_task = exe.clone();
let handle = tauri::async_runtime::spawn(async move {
run_claude_agent(&exe_for_task, req, tx, cancel).await
});
// 边收边发:runner 结束会 drop sink,rx 收到 None 退出。
while let Some(ev) = rx.recv().await {
let _ = app.emit("coding-agent:test", &ev);
}
let run_result = handle.await;
*TEST_CANCEL.lock() = None;
let _ = std::fs::remove_file(&settings_path);
match run_result {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(e.to_string()),
Err(join_err) => Err(format!("agent 任务异常: {join_err}")),
}
}
/// 取消当前正在跑的测试运行。
#[tauri::command]
pub fn coding_agent_cancel_test() {
if let Some(flag) = TEST_CANCEL.lock().clone() {
flag.store(true, Ordering::Relaxed);
}
}
/// 本地预检一条命令是否高风险,返回原因(控制台在运行前给用户警示用)。
#[tauri::command]
pub fn coding_agent_command_risk(command: String) -> Option<String> {
super::guard::is_high_risk_command(&command).map(|r| r.to_string())
}
@@ -0,0 +1,138 @@
//! 解析 `claude --version` 与 `claude mcp list` 的输出(纯逻辑,便于单测)。
/// 从 `claude --version` 输出里提取版本号。
///
/// 兼容 `"2.1.161 (Claude Code)"` 与 `"Claude Code version 2.1.161"` 两种排版:
/// 取第一个形如 `x.y.z` 的 token。
pub fn parse_claude_version(stdout: &str) -> Option<String> {
for raw in stdout.split_whitespace() {
let token = raw.trim_matches(|c: char| !c.is_ascii_digit() && c != '.');
let parts: Vec<&str> = token.split('.').collect();
if parts.len() == 3
&& parts
.iter()
.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
{
return Some(token.to_string());
}
}
None
}
/// MCP server 健康状态。
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum McpHealth {
Connected,
Failed,
NeedsAuth,
Unknown,
}
/// `claude mcp list` 里的一项。
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct McpServerStatus {
pub name: String,
pub detail: String,
pub health: McpHealth,
}
/// 解析 `claude mcp list` 输出。忽略 "Checking..." 等噪声行。
///
/// 行格式约为:`<name>: <detail> - <✓|✗|!> <status text>`。
pub fn parse_mcp_list(stdout: &str) -> Vec<McpServerStatus> {
let mut out = Vec::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with("Checking") {
continue;
}
let Some((name, rest)) = line.split_once(": ") else {
continue;
};
// 用最后一个 " - " 分隔 detail 与状态,避免 URL 里的连字符误伤。
let (detail, status) = match rest.rfind(" - ") {
Some(idx) => (rest[..idx].trim(), rest[idx + 3..].trim()),
None => (rest.trim(), ""),
};
let health = if status.contains("Connected") {
McpHealth::Connected
} else if status.contains("Failed") {
McpHealth::Failed
} else if status.contains("authentication") || status.contains("Needs") {
McpHealth::NeedsAuth
} else {
McpHealth::Unknown
};
out.push(McpServerStatus {
name: name.trim().to_string(),
detail: detail.to_string(),
health,
});
}
out
}
/// 是否存在桌面控制类(computer use)MCP server。
///
/// 这是 OpenLess 对「computer use 技能是否安装」的检测口径:Claude Code 本身无原生
/// computer use,桌面 GUI 控制只能通过挂载相应 MCP server 获得。
pub fn has_computer_use_mcp(servers: &[McpServerStatus]) -> bool {
servers.iter().any(|s| {
let n = s.name.to_lowercase();
n.contains("computer") || n.contains("desktop") || n.contains("screen")
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_version_from_both_layouts() {
assert_eq!(
parse_claude_version("2.1.161 (Claude Code)").as_deref(),
Some("2.1.161")
);
assert_eq!(
parse_claude_version("Claude Code version 2.1.161").as_deref(),
Some("2.1.161")
);
assert_eq!(parse_claude_version("no version here"), None);
}
#[test]
fn parses_mcp_list_health() {
let stdout = "Checking MCP server health…\n\
memory: npx -y @modelcontextprotocol/server-memory - ✓ Connected\n\
railway: npx -y @railway/mcp-server - ✗ Failed to connect\n\
cloudflare-observability: https://observability.mcp.cloudflare.com/mcp (HTTP) - ! Needs authentication\n";
let servers = parse_mcp_list(stdout);
assert_eq!(servers.len(), 3);
assert_eq!(servers[0].name, "memory");
assert_eq!(servers[0].health, McpHealth::Connected);
assert_eq!(servers[1].health, McpHealth::Failed);
assert_eq!(servers[2].name, "cloudflare-observability");
assert_eq!(servers[2].health, McpHealth::NeedsAuth);
// URL 里的 "-" 不应把状态切错
assert!(servers[2]
.detail
.contains("observability.mcp.cloudflare.com"));
}
#[test]
fn detects_computer_use_mcp_by_name() {
let with = vec![McpServerStatus {
name: "computer-use".into(),
detail: String::new(),
health: McpHealth::Connected,
}];
let without = vec![McpServerStatus {
name: "playwright".into(),
detail: String::new(),
health: McpHealth::Connected,
}];
assert!(has_computer_use_mcp(&with));
assert!(!has_computer_use_mcp(&without));
}
}
@@ -0,0 +1,223 @@
//! 护栏:高风险命令分类 + 生成传给 `claude --settings` 的权限 JSON。
//!
//! 「放行 + 护栏」策略:
//! - `permissions.defaultMode = acceptEdits`(放行可恢复/轻动作)。
//! - `permissions.deny` 声明式拦截高风险工具调用(跨平台、稳)。
//! - 运行级 git 快照由运行器在启动前做(见 `mod.rs::create_git_snapshot`)。
//!
//! [`is_high_risk_command`] 供「Claude 控制台」等场景对**单条命令**做本地预检/展示用,
//! 与 CLI 侧的 deny 规则互为补充。
/// 高风险子串(已小写)→ 原因。命中任一即判为高风险。
pub const HIGH_RISK_PATTERNS: &[(&str, &str)] = &[
("rm -rf", "递归强制删除"),
("rm -fr", "递归强制删除"),
("sudo ", "提权执行"),
("git push --force", "强制推送会覆盖远端历史"),
("git push -f", "强制推送会覆盖远端历史"),
("git reset --hard", "硬重置会丢弃未提交改动"),
("git clean -fd", "强制清理未跟踪文件"),
("git clean -f -d", "强制清理未跟踪文件"),
("mkfs", "格式化文件系统"),
("dd if=", "裸盘写入"),
(":(){", "fork 炸弹"),
("shutdown", "关机"),
("reboot", "重启"),
("> /dev/sd", "直接写入块设备"),
("| sh", "管道执行远程脚本"),
("|sh", "管道执行远程脚本"),
("| bash", "管道执行远程脚本"),
("|bash", "管道执行远程脚本"),
("chmod -r 777 /", "危险的全局权限修改"),
("chown -r", "递归改所有权"),
];
/// 若命令命中高风险模式,返回原因;否则 `None`。
pub fn is_high_risk_command(command: &str) -> Option<&'static str> {
let lowered = command.to_lowercase();
HIGH_RISK_PATTERNS
.iter()
.find(|(pat, _)| lowered.contains(pat))
.map(|(_, reason)| *reason)
}
/// 同一风险的等价命令子串分组:approve 其中之一即整组放行(从 deny 移除 + 加入 allow)。
///
/// 审批 UI 只回传命中的单个 `HIGH_RISK_PATTERNS` 子串(如 "git push --force"),但同一风险
/// 往往有多个等价写法("git push -f")。若只放行被点的那一个,等价写法仍在 deny(deny 优先
/// 级高于 allow)→ 命令仍被拦,用户误以为已批准。返回整组让调用方按组放行。
/// 命中返回整组;未命中返回空(调用方回落到 pattern 自身)。
pub fn risk_equivalent_patterns(pattern: &str) -> Vec<&'static str> {
const GROUPS: &[&[&str]] = &[
&["git push --force", "git push -f"],
&["rm -rf", "rm -fr"],
&["git clean -fd", "git clean -f -d"],
// 其余按需补充;默认返回自身
];
for g in GROUPS {
if g.contains(&pattern) {
return g.to_vec();
}
}
Vec::new()
}
/// CLI `--settings` 默认的 `permissions.deny` 规则(Claude Code 工具说明符语法)。
///
/// 注意:管道执行远程脚本(`| sh`)、fork 炸弹(`:(){`)、`> /dev/sd` 等无法用命令前缀
/// 说明符(`Bash(<prefix>:*)`)表达——它们出现在命令中段或依赖 shell 语法——仍由
/// `defaultMode = acceptEdits` + 运行级 [`is_high_risk_command`] 探测兜底。
pub fn default_deny_rules() -> Vec<String> {
vec![
"Bash(rm -rf:*)".into(),
"Bash(rm -fr:*)".into(),
"Bash(sudo:*)".into(),
"Bash(git push --force:*)".into(),
"Bash(git push -f:*)".into(),
"Bash(git reset --hard:*)".into(),
"Bash(git clean -fd:*)".into(),
"Bash(git clean -f -d:*)".into(),
"Bash(mkfs:*)".into(),
"Bash(dd:*)".into(),
"Bash(shutdown:*)".into(),
"Bash(reboot:*)".into(),
// 权限/所有权/持久化/系统级命令(补齐 HIGH_RISK_PATTERNS 覆盖面 + macOS 持久化面)。
"Bash(chmod:*)".into(),
"Bash(chown:*)".into(),
"Bash(crontab:*)".into(),
"Bash(osascript:*)".into(),
"Bash(launchctl:*)".into(),
"Bash(kextload:*)".into(),
"Bash(nvram:*)".into(),
"Edit(.env)".into(),
"Edit(.git/**)".into(),
// macOS 持久化面:开机自启 plist + 登录 shell 配置(写入即可持久驻留/提权)。
// 用 `~/` 家目录前缀(Claude Code settings 官方写法,如 `Read(~/.zshrc)`):
// 文件路径规则里 bare `**/.zshrc` 是相对 agent **工作目录**匹配,命中不到工作目录
// 之外的真正 `~/.zshrc` → 护栏失效。LaunchDaemons 是系统路径(写入需 root,已被
// `Bash(sudo:*)` 拦),这里只覆盖用户态 LaunchAgents + 登录 shell 配置。
"Edit(~/Library/LaunchAgents/**)".into(),
"Write(~/Library/LaunchAgents/**)".into(),
"Edit(~/.zshrc)".into(),
"Write(~/.zshrc)".into(),
"Edit(~/.zprofile)".into(),
"Write(~/.zprofile)".into(),
"Edit(~/.bash_profile)".into(),
"Write(~/.bash_profile)".into(),
"Edit(~/.bashrc)".into(),
"Write(~/.bashrc)".into(),
]
}
/// 生成护栏 settings JSON。`mode` 为 `--permission-mode` 同名取值;
/// `extra_deny` 追加在默认 deny 之后。
pub fn build_guard_settings_json(mode: &str, extra_deny: &[String]) -> serde_json::Value {
let mut deny = default_deny_rules();
deny.extend(extra_deny.iter().cloned());
serde_json::json!({
"permissions": {
"defaultMode": mode,
"deny": deny,
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flags_rm_rf_regardless_of_case_and_spacing() {
assert!(is_high_risk_command("rm -rf /tmp/x").is_some());
assert!(is_high_risk_command("RM -RF /").is_some());
assert!(is_high_risk_command("sudo apt install").is_some());
assert!(is_high_risk_command("git push --force origin main").is_some());
}
#[test]
fn flags_pipe_to_shell() {
assert!(is_high_risk_command("curl http://x | sh").is_some());
assert!(is_high_risk_command("wget -qO- x|bash").is_some());
}
#[test]
fn allows_ordinary_reversible_commands() {
assert!(is_high_risk_command("ls -la").is_none());
assert!(is_high_risk_command("git status").is_none());
assert!(is_high_risk_command("pbcopy < file.txt").is_none());
assert!(is_high_risk_command("echo hi").is_none());
}
#[test]
fn guard_settings_has_accept_edits_and_deny_list() {
let v = build_guard_settings_json("acceptEdits", &[]);
assert_eq!(v["permissions"]["defaultMode"], "acceptEdits");
let deny = v["permissions"]["deny"].as_array().unwrap();
assert!(deny.iter().any(|d| d == "Bash(rm -rf:*)"));
assert!(deny.iter().any(|d| d == "Bash(sudo:*)"));
}
#[test]
fn guard_settings_appends_extra_deny() {
let extra = vec!["Bash(npm publish:*)".to_string()];
let v = build_guard_settings_json("acceptEdits", &extra);
let deny = v["permissions"]["deny"].as_array().unwrap();
assert!(deny.iter().any(|d| d == "Bash(npm publish:*)"));
}
#[test]
fn default_deny_covers_perms_and_macos_persistence() {
let deny = default_deny_rules();
// 新增的权限/系统级命令。
for rule in [
"Bash(chmod:*)",
"Bash(chown:*)",
"Bash(crontab:*)",
"Bash(osascript:*)",
"Bash(launchctl:*)",
"Bash(kextload:*)",
"Bash(nvram:*)",
] {
assert!(deny.iter().any(|d| d == rule), "缺少 deny: {rule}");
}
// macOS 持久化面(`~/` 家目录前缀,全 Edit/Write 变体)。
for rule in [
"Edit(~/Library/LaunchAgents/**)",
"Write(~/Library/LaunchAgents/**)",
"Edit(~/.zshrc)",
"Write(~/.zshrc)",
"Edit(~/.zprofile)",
"Write(~/.zprofile)",
"Edit(~/.bash_profile)",
"Write(~/.bash_profile)",
"Edit(~/.bashrc)",
"Write(~/.bashrc)",
] {
assert!(deny.iter().any(|d| d == rule), "缺少 deny: {rule}");
}
}
#[test]
fn risk_equivalent_force_push_releases_whole_group() {
// approve "--force" 应同时放行 "-f" 等价写法。
let group = risk_equivalent_patterns("git push --force");
assert!(group.contains(&"git push --force"));
assert!(group.contains(&"git push -f"));
// 反向也成立:approve "-f" 同样放行 "--force"。
let group2 = risk_equivalent_patterns("git push -f");
assert!(group2.contains(&"git push --force"));
}
#[test]
fn risk_equivalent_rm_group_and_unknown_returns_empty() {
let rm = risk_equivalent_patterns("rm -rf");
assert!(rm.contains(&"rm -rf"));
assert!(rm.contains(&"rm -fr"));
// approve "git clean -fd" 应同时放行 "git clean -f -d" 等价写法。
let clean = risk_equivalent_patterns("git clean -fd");
assert!(clean.contains(&"git clean -fd"));
assert!(clean.contains(&"git clean -f -d"));
// 不在任何分组里 → 返回空,调用方回落到 pattern 自身。
assert!(risk_equivalent_patterns("sudo ").is_empty());
}
}
@@ -0,0 +1,292 @@
//! 无头 Claude Code 调用子系统(「快速 Agent」后端)。
//!
//! - [`args`]:`claude -p` 参数构造。
//! - [`stream`]:stream-json 输出解析为 [`stream::CodingAgentEvent`]。
//! - [`guard`]:高风险命令分类 + `--settings` 护栏 JSON。
//! - [`detect`]:解析 `claude --version` / `claude mcp list`。
//!
//! 本模块只负责「跑无头 Claude 并把事件抛出来」,不碰录音 / ASR / 前端——
//! 那些由 coordinator 串联(镜像现有 QA 链路)。
pub mod args;
pub mod commands;
pub mod detect;
pub mod guard;
pub mod stream;
use std::path::Path;
use std::process::Stdio;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
pub use args::{build_claude_args, CodingAgentPermissionMode, CodingAgentRequest};
pub use detect::McpServerStatus;
pub use stream::{parse_stream_json_line, CodingAgentEvent};
/// 无头 Claude 的「自动化前置说明」。
///
/// 无头 `claude -p` 是单次运行、没有多轮对话兜底:模型若中途提问、只给计划 / 半成品,
/// 这一轮就废了。所以在把用户的真实需求交给它之前,统一包一层目标驱动(/goal 式)的
/// 自动化指令,要求它一口气把任务彻底做完、只回最终结果。所有走 [`run_claude_agent`]
/// 的「让 Claude 干活」入口都应该用它来构造 prompt。
pub fn autonomous_prompt(task: &str) -> String {
format!(
"【自动化任务 · 一次性完成】这是一次无人值守的单次无头运行,没有多轮对话机会,\
你无法事后追问或补充。请把下面的需求当成一个必须在本次运行内彻底达成的目标(等价于先 /goal \
设定目标与完成标准,再自主执行直到达成):\n\
- 先想清楚目标和「完成」的判定标准,再开始动手;\n\
- 自主、连续地一口气执行到完全完成,不要中途停下来提问或等待确认;遇到歧义按最合理的方式继续;\n\
- 不要只给计划、思路或半成品,也不要留「后续步骤」给别人——要交付最终可用的结果;\n\
- 任务较长也要想办法在这一次运行内拆解并跑完;\n\
- 全部完成后,只输出最终结果本身,不要解释过程、不要前后缀、不要引号。\n\n\
需求:\n{task}"
)
}
/// 运行器把事件投递到这个 sink(coordinator / 命令层再转成 Tauri event)。
pub type CodingAgentEventSink = tokio::sync::mpsc::UnboundedSender<CodingAgentEvent>;
#[derive(Debug, thiserror::Error)]
pub enum CodingAgentError {
#[error("找不到可执行文件: {0}")]
ExecutableNotFound(String),
#[error("启动 agent 进程失败: {0}")]
Spawn(String),
#[error("agent 进程异常退出 (code={0:?})")]
ProcessExit(Option<i32>),
#[error("agent 运行超时 ({0}s)")]
Timeout(u64),
#[error("已取消")]
Cancelled,
#[error("IO 错误: {0}")]
Io(String),
}
/// 给 GUI 进程补 PATH / HOME:macOS 从 Finder 启动的进程不继承登录 shell 环境,
/// `claude` 常装在 `~/.local/bin`、Homebrew 在 `/opt/homebrew/bin`。
fn augment_env(cmd: &mut Command) {
let mut path = std::env::var("PATH").unwrap_or_default();
if let Some(home_os) = std::env::var_os("HOME") {
let home = home_os.to_string_lossy().to_string();
let extras = [
format!("{home}/.local/bin"),
"/opt/homebrew/bin".to_string(),
"/usr/local/bin".to_string(),
];
for extra in extras {
if !path.split(':').any(|p| p == extra) {
path = if path.is_empty() {
extra
} else {
format!("{extra}:{path}")
};
}
}
cmd.env("HOME", home);
}
cmd.env("PATH", path);
}
fn augmented_command(exe: &str) -> Command {
let mut cmd = Command::new(exe);
augment_env(&mut cmd);
cmd
}
/// `git stash create`:生成一个表示当前工作区的提交对象,**不改动工作区、也不进 stash 列表**。
/// 返回该快照的 commit SHA,供出问题时 `git stash apply <sha>` 回滚。无改动时返回 `None`。
pub fn create_git_snapshot(cwd: &Path) -> Option<String> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(cwd)
.args(["stash", "create", "openless-agent-pre-run"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
if sha.is_empty() {
None
} else {
Some(sha)
}
}
/// 探测 `claude` 版本(`None` 表示未安装或无法运行)。
pub async fn detect_claude(exe: &str) -> Option<String> {
let out = augmented_command(exe)
.arg("--version")
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
detect::parse_claude_version(&String::from_utf8_lossy(&out.stdout))
}
/// 列出 Claude Code 已配置的 MCP server(含健康状态)。
pub async fn claude_mcp_list(exe: &str) -> Vec<McpServerStatus> {
match augmented_command(exe).args(["mcp", "list"]).output().await {
Ok(out) => detect::parse_mcp_list(&String::from_utf8_lossy(&out.stdout)),
Err(_) => Vec::new(),
}
}
async fn wait_cancel(cancel: &Arc<AtomicBool>) {
loop {
if cancel.load(Ordering::Relaxed) {
return;
}
tokio::time::sleep(Duration::from_millis(150)).await;
}
}
/// 无头跑一次 Claude:写 prompt 到 stdin,逐行解析 stream-json,把事件投到 `sink`。
/// 支持取消(`cancel` 置 true)与超时(`req.timeout_secs`),两者都会 kill 子进程。
pub async fn run_claude_agent(
exe: &str,
req: CodingAgentRequest,
sink: CodingAgentEventSink,
cancel: Arc<AtomicBool>,
) -> Result<(), CodingAgentError> {
let args = build_claude_args(&req);
let mut cmd = augmented_command(exe);
cmd.args(&args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(cwd) = &req.cwd {
cmd.current_dir(cwd);
}
let mut child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
CodingAgentError::ExecutableNotFound(exe.to_string())
} else {
CodingAgentError::Spawn(e.to_string())
}
})?;
// 写入 prompt 后立即关闭 stdin,触发 claude 开始处理。
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(req.prompt.as_bytes()).await;
let _ = stdin.shutdown().await;
}
// 后台排空 stderr,避免管道写满导致子进程阻塞;出错时用作摘要。
let stderr_task = child.stderr.take().map(|s| {
tokio::spawn(async move {
let mut buf = String::new();
let _ = BufReader::new(s).read_to_string(&mut buf).await;
buf
})
});
let _ = sink.send(CodingAgentEvent::Started {
session_id: req.session_id.clone(),
});
let stdout = child
.stdout
.take()
.ok_or_else(|| CodingAgentError::Io("子进程无 stdout".into()))?;
let mut lines = BufReader::new(stdout).lines();
let deadline = tokio::time::Instant::now() + Duration::from_secs(req.timeout_secs.max(1));
let mut got_terminal = false;
let mut outcome: Result<(), CodingAgentError> = Ok(());
loop {
tokio::select! {
biased;
_ = wait_cancel(&cancel) => {
let _ = child.start_kill();
let _ = sink.send(CodingAgentEvent::Cancelled { session_id: req.session_id.clone() });
got_terminal = true;
outcome = Err(CodingAgentError::Cancelled);
break;
}
_ = tokio::time::sleep_until(deadline) => {
let _ = child.start_kill();
let _ = sink.send(CodingAgentEvent::Error {
session_id: req.session_id.clone(),
message: format!("运行超时({}s)", req.timeout_secs),
});
got_terminal = true;
outcome = Err(CodingAgentError::Timeout(req.timeout_secs));
break;
}
line = lines.next_line() => {
match line {
Ok(Some(l)) => {
if let Some(ev) = parse_stream_json_line(&req.session_id, &l) {
if matches!(ev, CodingAgentEvent::Completed { .. } | CodingAgentEvent::Error { .. }) {
got_terminal = true;
}
let _ = sink.send(ev);
}
}
Ok(None) => break, // EOF
Err(e) => {
outcome = Err(CodingAgentError::Io(e.to_string()));
break;
}
}
}
}
}
let status = child
.wait()
.await
.map_err(|e| CodingAgentError::Io(e.to_string()))?;
if !status.success() && outcome.is_ok() {
// 进程非 0 退出且我们还没判终局:补一条 Error。
if !got_terminal {
let stderr = match stderr_task {
Some(t) => t.await.unwrap_or_default(),
None => String::new(),
};
let summary = stderr.lines().last().unwrap_or("").trim().to_string();
let _ = sink.send(CodingAgentEvent::Error {
session_id: req.session_id.clone(),
message: if summary.is_empty() {
format!("agent 异常退出 (code={:?})", status.code())
} else {
summary
},
});
}
return Err(CodingAgentError::ProcessExit(status.code()));
}
outcome
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn autonomous_prompt_wraps_task_with_oneshot_directive() {
let p = autonomous_prompt("把这段话翻译成英文:你好");
// 原始需求必须原样带上。
assert!(p.contains("把这段话翻译成英文:你好"));
// 必含「一次性完成 / 单次无头运行 / 不要提问 / 只输出最终结果」这些核心约束。
assert!(p.contains("一次性完成"));
assert!(p.contains("无头"));
assert!(p.contains("不要中途停下来提问"));
assert!(p.contains("只输出最终结果"));
// 需求要排在自动化说明之后(前置说明在前)。
let directive_idx = p.find("自动化任务").unwrap();
let task_idx = p.find("把这段话翻译成英文").unwrap();
assert!(directive_idx < task_idx, "自动化前置说明必须在需求之前");
}
}
@@ -0,0 +1,166 @@
//! 解析 `claude -p --output-format stream-json` 的逐行 JSON 输出。
//!
//! 关注的几类行(其余忽略):
//! - `stream_event` → `content_block_delta` → `text_delta`:逐字增量。
//! - `assistant` 消息里的 `tool_use` 块:工具调用提示。
//! - `result`:终局,带 `result` 文本、`total_cost_usd`、`duration_ms`、`is_error`。
/// 转发给前端的 agent 事件。`kind` 作为 tag,前端按 `kind` 分发。
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CodingAgentEvent {
/// 进程已启动。
Started { session_id: String },
/// 逐字增量文本。
Delta { session_id: String, text: String },
/// agent 触发了某个工具(如 Bash / Edit)。
ToolUse { session_id: String, name: String },
/// 运行完成的最终结果。
Completed {
session_id: String,
text: String,
cost_usd: Option<f64>,
duration_ms: Option<u64>,
},
/// 用户取消。
Cancelled { session_id: String },
/// 运行出错(超时、进程异常、解析失败等)。
Error { session_id: String, message: String },
}
/// 解析一行 stream-json。无关行返回 `None`(防御式:解析失败也返回 `None`,不 panic)。
pub fn parse_stream_json_line(session_id: &str, line: &str) -> Option<CodingAgentEvent> {
let line = line.trim();
if line.is_empty() {
return None;
}
let v: serde_json::Value = serde_json::from_str(line).ok()?;
match v.get("type")?.as_str()? {
"stream_event" => {
let event = v.get("event")?;
if event.get("type")?.as_str()? != "content_block_delta" {
return None;
}
let delta = event.get("delta")?;
if delta.get("type")?.as_str()? != "text_delta" {
return None;
}
let text = delta.get("text")?.as_str()?.to_string();
Some(CodingAgentEvent::Delta {
session_id: session_id.to_string(),
text,
})
}
"assistant" => {
let content = v.get("message")?.get("content")?.as_array()?;
for block in content {
if block.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
if let Some(name) = block.get("name").and_then(|n| n.as_str()) {
return Some(CodingAgentEvent::ToolUse {
session_id: session_id.to_string(),
name: name.to_string(),
});
}
}
}
None
}
"result" => {
let is_error = v.get("is_error").and_then(|b| b.as_bool()).unwrap_or(false);
let text = v
.get("result")
.and_then(|r| r.as_str())
.unwrap_or_default()
.to_string();
if is_error {
Some(CodingAgentEvent::Error {
session_id: session_id.to_string(),
message: if text.is_empty() {
"agent 返回错误".to_string()
} else {
text
},
})
} else {
Some(CodingAgentEvent::Completed {
session_id: session_id.to_string(),
text,
cost_usd: v.get("total_cost_usd").and_then(|c| c.as_f64()),
duration_ms: v.get("duration_ms").and_then(|d| d.as_u64()),
})
}
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_text_delta() {
let line = r#"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"你好"}}}"#;
assert_eq!(
parse_stream_json_line("s1", line),
Some(CodingAgentEvent::Delta {
session_id: "s1".into(),
text: "你好".into()
})
);
}
#[test]
fn ignores_non_text_deltas() {
let line = r#"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"input_json_delta","partial_json":"{"}}}"#;
assert_eq!(parse_stream_json_line("s1", line), None);
}
#[test]
fn parses_tool_use() {
let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{}}]}}"#;
assert_eq!(
parse_stream_json_line("s1", line),
Some(CodingAgentEvent::ToolUse {
session_id: "s1".into(),
name: "Bash".into()
})
);
}
#[test]
fn parses_successful_result_with_cost() {
let line = r#"{"type":"result","subtype":"success","is_error":false,"result":"done","total_cost_usd":0.0123,"duration_ms":1500,"session_id":"abc"}"#;
assert_eq!(
parse_stream_json_line("s1", line),
Some(CodingAgentEvent::Completed {
session_id: "s1".into(),
text: "done".into(),
cost_usd: Some(0.0123),
duration_ms: Some(1500),
})
);
}
#[test]
fn parses_error_result() {
let line = r#"{"type":"result","is_error":true,"result":"boom"}"#;
assert_eq!(
parse_stream_json_line("s1", line),
Some(CodingAgentEvent::Error {
session_id: "s1".into(),
message: "boom".into()
})
);
}
#[test]
fn ignores_system_init_and_garbage() {
assert_eq!(
parse_stream_json_line("s1", r#"{"type":"system","subtype":"init"}"#),
None
);
assert_eq!(parse_stream_json_line("s1", "not json"), None);
assert_eq!(parse_stream_json_line("s1", ""), None);
}
}
+198 -33
View File
@@ -86,6 +86,7 @@ trait SettingsWriter {
fn refresh_translation_hotkey(&self);
fn refresh_switch_style_hotkey(&self);
fn refresh_open_app_hotkey(&self);
fn refresh_coding_agent_hotkey(&self);
}
impl SettingsWriter for Coordinator {
@@ -124,6 +125,10 @@ impl SettingsWriter for Coordinator {
fn refresh_open_app_hotkey(&self) {
self.update_open_app_hotkey_binding();
}
fn refresh_coding_agent_hotkey(&self) {
self.update_coding_agent_hotkey_binding();
}
}
impl<T: SettingsWriter + ?Sized> SettingsWriter for Arc<T> {
@@ -162,6 +167,10 @@ impl<T: SettingsWriter + ?Sized> SettingsWriter for Arc<T> {
fn refresh_open_app_hotkey(&self) {
(**self).refresh_open_app_hotkey();
}
fn refresh_coding_agent_hotkey(&self) {
(**self).refresh_coding_agent_hotkey();
}
}
fn persist_settings<T: SettingsWriter>(
@@ -178,6 +187,8 @@ fn persist_settings<T: SettingsWriter>(
let translation_changed = previous.translation_hotkey != prefs.translation_hotkey;
let switch_style_changed = previous.switch_style_hotkey != prefs.switch_style_hotkey;
let open_app_changed = previous.open_app_hotkey != prefs.open_app_hotkey;
let coding_agent_changed = previous.coding_agent_enabled != prefs.coding_agent_enabled
|| previous.coding_agent_voice_hotkey != prefs.coding_agent_voice_hotkey;
let active_asr_provider_changed = previous.active_asr_provider != prefs.active_asr_provider;
let active_asr_provider = prefs.active_asr_provider.clone();
if active_asr_provider_changed {
@@ -218,6 +229,9 @@ fn persist_settings<T: SettingsWriter>(
if open_app_changed {
coord.refresh_open_app_hotkey();
}
if coding_agent_changed {
coord.refresh_coding_agent_hotkey();
}
Ok(())
}
@@ -572,10 +586,8 @@ async fn resolve_beta_manifest_endpoints() -> Result<Vec<url::Url>, String> {
let direct = format!(
"https://github.com/appergb/openless/releases/download/{tag}/latest-{{{{target}}}}-{{{{arch}}}}-beta.json"
);
let mirror_url =
url::Url::parse(&mirror).map_err(|e| format!("parse beta mirror url: {e}"))?;
let direct_url =
url::Url::parse(&direct).map_err(|e| format!("parse beta direct url: {e}"))?;
let mirror_url = url::Url::parse(&mirror).map_err(|e| format!("parse beta mirror url: {e}"))?;
let direct_url = url::Url::parse(&direct).map_err(|e| format!("parse beta direct url: {e}"))?;
Ok(vec![mirror_url, direct_url])
}
@@ -720,6 +732,11 @@ fn asr_configured_for_provider(provider: &str, snap: &CredentialsSnapshot) -> bo
if provider == crate::asr::bailian::PROVIDER_ID {
return configured(&snap.asr_api_key);
}
if provider == crate::asr::mimo::PROVIDER_ID {
return configured(&snap.asr_api_key)
&& configured(&snap.asr_endpoint)
&& configured(&snap.asr_model);
}
configured(&snap.asr_endpoint) && configured(&snap.asr_model)
}
@@ -918,6 +935,11 @@ pub async fn list_provider_models(kind: String) -> Result<ProviderModelsResult,
models: vec![crate::asr::bailian::DEFAULT_MODEL.to_string()],
});
}
if kind == "asr" && CredentialsVault::get_active_asr() == crate::asr::mimo::PROVIDER_ID {
return Ok(ProviderModelsResult {
models: vec![crate::asr::mimo::DEFAULT_MODEL.to_string()],
});
}
if kind == "llm" && CredentialsVault::get_active_llm() == CODEX_OAUTH_PROVIDER_ID {
return Ok(ProviderModelsResult {
models: vec![
@@ -1050,6 +1072,9 @@ async fn validate_asr_provider() -> Result<(), String> {
if active_asr == crate::asr::bailian::PROVIDER_ID {
return validate_bailian_asr_provider().await;
}
if active_asr == crate::asr::mimo::PROVIDER_ID {
return validate_mimo_asr_provider().await;
}
let config = read_openai_provider_config("asr")?;
let model = CredentialsVault::get(CredentialAccount::AsrModel)
@@ -1059,6 +1084,23 @@ async fn validate_asr_provider() -> Result<(), String> {
validate_asr_transcription(&config, model.trim()).await
}
async fn validate_mimo_asr_provider() -> Result<(), String> {
let config = read_openai_provider_config("asr")?;
let model = CredentialsVault::get(CredentialAccount::AsrModel)
.map_err(|e| e.to_string())?
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::mimo::DEFAULT_MODEL.to_string());
let asr = crate::asr::MimoBatchASR::new(config.api_key, config.base_url, model);
crate::recorder::AudioConsumer::consume_pcm_chunk(
&asr,
&encode_wav_16k_mono_silence(250)[44..],
);
asr.transcribe()
.await
.map(|_| ())
.map_err(|e| e.to_string())
}
async fn validate_bailian_asr_provider() -> Result<(), String> {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
.map_err(|e| e.to_string())?
@@ -1451,7 +1493,8 @@ fn is_valid_local_pack_id(s: &str) -> bool {
if s.is_empty() || s.len() > 128 {
return false;
}
s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-' || b == b'_')
s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-' || b == b'_')
}
// ─────────────────────────── vocab ───────────────────────────
@@ -1945,6 +1988,24 @@ pub fn qa_window_pin(coord: CoordinatorState<'_>, pinned: bool) {
coord.qa_window_pin(pinned);
}
/// 用户点 ✕ / 按 Esc 关 Less Computer 浮窗。
#[tauri::command]
pub fn less_computer_window_dismiss(coord: CoordinatorState<'_>) {
coord.less_computer_window_dismiss();
}
/// 前端按内容测高后回传高度,后端 clamp + bottom-anchored 重新摆放浮窗。
#[tauri::command]
pub fn less_computer_window_resize(coord: CoordinatorState<'_>, height: f64) {
coord.less_computer_window_resize(height);
}
/// 内联审批卡的 Approve / Deny 回执。token 关联到等待中的拦截动作。
#[tauri::command]
pub fn less_computer_approve(coord: CoordinatorState<'_>, token: String, approved: bool) {
coord.less_computer_approve(&token, approved);
}
// ─────────────────────────── 自定义组合键 ───────────────────────────
/// 测试一个组合键是否可以注册(验证格式,不实际注册)。
@@ -2163,9 +2224,13 @@ fn reject_hotkey_collisions(prefs: &UserPreferences) -> Result<(), String> {
// 停用(None)的 action 快捷键不参与任何冲突检测。
let switch_style = prefs.switch_style_hotkey.as_ref();
let open_app = prefs.open_app_hotkey.as_ref();
let less_computer = prefs.coding_agent_voice_hotkey.as_ref();
if let Some(qa_hotkey) = prefs.qa_hotkey.as_ref() {
reject_dictation_qa_hotkey_overlap(&prefs.dictation_hotkey, qa_hotkey)?;
reject_qa_translation_hotkey_overlap(qa_hotkey, &prefs.translation_hotkey)?;
if let Some(less_computer) = less_computer {
reject_qa_less_computer_hotkey_overlap(qa_hotkey, less_computer)?;
}
if let Some(switch_style) = switch_style {
reject_qa_switch_style_hotkey_overlap(qa_hotkey, switch_style)?;
}
@@ -2177,13 +2242,23 @@ fn reject_hotkey_collisions(prefs: &UserPreferences) -> Result<(), String> {
&prefs.dictation_hotkey,
&prefs.translation_hotkey,
)?;
if let Some(less_computer) = less_computer {
reject_dictation_less_computer_hotkey_overlap(&prefs.dictation_hotkey, less_computer)?;
reject_translation_less_computer_hotkey_overlap(&prefs.translation_hotkey, less_computer)?;
}
if let Some(switch_style) = switch_style {
reject_dictation_switch_style_hotkey_overlap(&prefs.dictation_hotkey, switch_style)?;
reject_translation_switch_style_hotkey_overlap(&prefs.translation_hotkey, switch_style)?;
if let Some(less_computer) = less_computer {
reject_less_computer_switch_style_hotkey_overlap(less_computer, switch_style)?;
}
}
if let Some(open_app) = open_app {
reject_dictation_open_app_hotkey_overlap(&prefs.dictation_hotkey, open_app)?;
reject_translation_open_app_hotkey_overlap(&prefs.translation_hotkey, open_app)?;
if let Some(less_computer) = less_computer {
reject_less_computer_open_app_hotkey_overlap(less_computer, open_app)?;
}
}
if let (Some(switch_style), Some(open_app)) = (switch_style, open_app) {
reject_switch_style_open_app_hotkey_overlap(switch_style, open_app)?;
@@ -2216,6 +2291,17 @@ fn reject_dictation_open_app_hotkey_overlap(
reject_hotkey_overlap(dictation, open_app, "打开应用快捷键不能和听写快捷键相同")
}
fn reject_dictation_less_computer_hotkey_overlap(
dictation: &ShortcutBinding,
less_computer: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(
dictation,
less_computer,
"Less Computer 快捷键不能和听写快捷键相同",
)
}
fn reject_qa_translation_hotkey_overlap(
qa: &ShortcutBinding,
translation: &ShortcutBinding,
@@ -2237,6 +2323,17 @@ fn reject_qa_open_app_hotkey_overlap(
reject_hotkey_overlap(qa, open_app, "打开应用快捷键不能和 QA 快捷键相同")
}
fn reject_qa_less_computer_hotkey_overlap(
qa: &ShortcutBinding,
less_computer: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(
qa,
less_computer,
"Less Computer 快捷键不能和 QA 快捷键相同",
)
}
fn reject_translation_switch_style_hotkey_overlap(
translation: &ShortcutBinding,
switch_style: &ShortcutBinding,
@@ -2255,6 +2352,17 @@ fn reject_translation_open_app_hotkey_overlap(
reject_hotkey_overlap(translation, open_app, "打开应用快捷键不能和翻译快捷键相同")
}
fn reject_translation_less_computer_hotkey_overlap(
translation: &ShortcutBinding,
less_computer: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(
translation,
less_computer,
"Less Computer 快捷键不能和翻译快捷键相同",
)
}
fn reject_switch_style_open_app_hotkey_overlap(
switch_style: &ShortcutBinding,
open_app: &ShortcutBinding,
@@ -2266,6 +2374,28 @@ fn reject_switch_style_open_app_hotkey_overlap(
)
}
fn reject_less_computer_switch_style_hotkey_overlap(
less_computer: &ShortcutBinding,
switch_style: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(
less_computer,
switch_style,
"Less Computer 快捷键不能和切换风格快捷键相同",
)
}
fn reject_less_computer_open_app_hotkey_overlap(
less_computer: &ShortcutBinding,
open_app: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(
less_computer,
open_app,
"Less Computer 快捷键不能和打开应用快捷键相同",
)
}
fn shortcut_bindings_overlap(left: &ShortcutBinding, right: &ShortcutBinding) -> bool {
let left_legacy = crate::shortcut_binding::legacy_modifier_trigger(left);
let right_legacy = crate::shortcut_binding::legacy_modifier_trigger(right);
@@ -3151,8 +3281,10 @@ pub async fn marketplace_list(
let body = resp.text().await.unwrap_or_default();
return Err(format!("marketplace HTTP {status}: {body}"));
}
let items: Vec<MarketplaceListItem> =
resp.json().await.map_err(|e| format!("parse failed: {e}"))?;
let items: Vec<MarketplaceListItem> = resp
.json()
.await
.map_err(|e| format!("parse failed: {e}"))?;
Ok(items)
}
@@ -3479,11 +3611,13 @@ fn get_github_oauth_client_id() -> Result<String, String> {
if !GITHUB_OAUTH_CLIENT_ID.is_empty() {
return Ok(GITHUB_OAUTH_CLIENT_ID.to_string());
}
Err("GitHub OAuth 未配置。请去 https://github.com/settings/applications/new 注册一个 OAuth App\
Err(
"GitHub OAuth 未配置。请去 https://github.com/settings/applications/new 注册一个 OAuth App\
(必须勾 Enable Device Flow),把 client_id 填到 \
openless-all/app/src-tauri/src/commands.rs 的 GITHUB_OAUTH_CLIENT_ID 常量,\
或在启动前设置环境变量 GITHUB_OAUTH_CLIENT_ID=<your_client_id>。"
.to_string())
.to_string(),
)
}
#[derive(Debug, serde::Serialize)]
@@ -3600,12 +3734,6 @@ pub async fn github_device_flow_poll(
Ok(GithubDevicePollResult::Error { message: msg })
}
#[tauri::command]
pub fn is_no_compositing_mode() -> bool {
// Linux WebKitGTK: WEBKIT_DISABLE_COMPOSITING_MODE=1 时 backdrop-filter 不可用
std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").as_deref() == Ok("1")
}
#[cfg(test)]
mod tests {
use super::{
@@ -3641,6 +3769,7 @@ mod tests {
translation_refreshes: Mutex<u32>,
switch_style_refreshes: Mutex<u32>,
open_app_refreshes: Mutex<u32>,
coding_agent_refreshes: Mutex<u32>,
}
fn snapshot() -> CredentialsSnapshot {
@@ -3977,12 +4106,7 @@ mod tests {
} {
return Err(error);
}
if let Some(error) = self
.active_asr_provider_sync_error
.lock()
.unwrap()
.clone()
{
if let Some(error) = self.active_asr_provider_sync_error.lock().unwrap().clone() {
return Err(error);
}
Ok(())
@@ -4011,6 +4135,10 @@ mod tests {
fn refresh_open_app_hotkey(&self) {
*self.open_app_refreshes.lock().unwrap() += 1;
}
fn refresh_coding_agent_hotkey(&self) {
*self.coding_agent_refreshes.lock().unwrap() += 1;
}
}
#[test]
@@ -4148,6 +4276,10 @@ mod tests {
primary: "O".to_string(),
modifiers: vec!["ctrl".to_string(), "alt".to_string()],
}),
coding_agent_voice_hotkey: Some(ShortcutBinding {
primary: "RightControl".to_string(),
modifiers: vec![],
}),
hotkey: HotkeyBinding {
trigger: HotkeyTrigger::Custom,
mode: HotkeyMode::Hold,
@@ -4177,6 +4309,27 @@ mod tests {
assert_eq!(*writer.translation_refreshes.lock().unwrap(), 1);
assert_eq!(*writer.switch_style_refreshes.lock().unwrap(), 1);
assert_eq!(*writer.open_app_refreshes.lock().unwrap(), 1);
assert_eq!(*writer.coding_agent_refreshes.lock().unwrap(), 1);
}
#[test]
fn persist_settings_rejects_less_computer_dictation_overlap() {
let writer = FakeSettingsWriter::default();
let binding = ShortcutBinding {
primary: "LeftControl".into(),
modifiers: vec![],
};
let prefs = UserPreferences {
dictation_hotkey: binding.clone(),
coding_agent_voice_hotkey: Some(binding),
..Default::default()
};
assert_eq!(
persist_settings(&writer, prefs),
Err("Less Computer 快捷键不能和听写快捷键相同".into())
);
assert!(writer.saved.lock().unwrap().is_none());
}
#[test]
@@ -4216,6 +4369,7 @@ mod tests {
assert_eq!(*writer.translation_refreshes.lock().unwrap(), 0);
assert_eq!(*writer.switch_style_refreshes.lock().unwrap(), 0);
assert_eq!(*writer.open_app_refreshes.lock().unwrap(), 0);
assert_eq!(*writer.coding_agent_refreshes.lock().unwrap(), 0);
}
#[test]
@@ -4246,7 +4400,10 @@ mod tests {
.clone()
.expect("previous settings remain saved");
assert_eq!(saved.active_asr_provider, previous.active_asr_provider);
assert_eq!(saved.microphone_device_name, previous.microphone_device_name);
assert_eq!(
saved.microphone_device_name,
previous.microphone_device_name
);
assert_eq!(
writer.active_asr_provider_syncs.lock().unwrap().clone(),
vec!["whisper".to_string()]
@@ -4257,6 +4414,7 @@ mod tests {
assert_eq!(*writer.translation_refreshes.lock().unwrap(), 0);
assert_eq!(*writer.switch_style_refreshes.lock().unwrap(), 0);
assert_eq!(*writer.open_app_refreshes.lock().unwrap(), 0);
assert_eq!(*writer.coding_agent_refreshes.lock().unwrap(), 0);
}
#[test]
@@ -4287,13 +4445,13 @@ mod tests {
.clone()
.expect("previous settings remain saved");
assert_eq!(saved.active_asr_provider, previous.active_asr_provider);
assert_eq!(saved.microphone_device_name, previous.microphone_device_name);
assert_eq!(
saved.microphone_device_name,
previous.microphone_device_name
);
assert_eq!(
writer.active_asr_provider_syncs.lock().unwrap().clone(),
vec![
"whisper".to_string(),
previous.active_asr_provider.clone()
]
vec!["whisper".to_string(), previous.active_asr_provider.clone()]
);
assert_eq!(*writer.dictation_refreshes.lock().unwrap(), 0);
assert_eq!(*writer.combo_refreshes.lock().unwrap(), 0);
@@ -4301,6 +4459,7 @@ mod tests {
assert_eq!(*writer.translation_refreshes.lock().unwrap(), 0);
assert_eq!(*writer.switch_style_refreshes.lock().unwrap(), 0);
assert_eq!(*writer.open_app_refreshes.lock().unwrap(), 0);
assert_eq!(*writer.coding_agent_refreshes.lock().unwrap(), 0);
}
#[test]
@@ -4308,8 +4467,7 @@ mod tests {
let writer = FakeSettingsWriter::default();
let previous = UserPreferences::default();
*writer.saved.lock().unwrap() = Some(previous.clone());
*writer.write_settings_errors.lock().unwrap() =
vec![Some("save failed".to_string()), None];
*writer.write_settings_errors.lock().unwrap() = vec![Some("save failed".to_string()), None];
*writer.active_asr_provider_sync_errors.lock().unwrap() =
vec![None, Some("rollback failed".to_string())];
let prefs = UserPreferences {
@@ -4344,6 +4502,7 @@ mod tests {
assert_eq!(*writer.translation_refreshes.lock().unwrap(), 0);
assert_eq!(*writer.switch_style_refreshes.lock().unwrap(), 0);
assert_eq!(*writer.open_app_refreshes.lock().unwrap(), 0);
assert_eq!(*writer.coding_agent_refreshes.lock().unwrap(), 0);
}
#[test]
@@ -4692,13 +4851,17 @@ mod tests {
// 长度对但含 `/`:dash 位置错或非 hex 字符都不通过
assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-44665544/000"));
assert!(!is_valid_session_id("550e8400_e29b_41d4_a716_446655440000")); // 用 _ 代 -
// 非 hex 字符
// 非 hex 字符
assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-44665544000g"));
// 长度不对(35 / 37)
assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-44665544000"));
assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-4466554400000"));
assert!(!is_valid_session_id(
"550e8400-e29b-41d4-a716-4466554400000"
));
// NUL 字节
assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-44665544\x00000"));
assert!(!is_valid_session_id(
"550e8400-e29b-41d4-a716-44665544\x00000"
));
// 百分号编码与绝对路径
assert!(!is_valid_session_id("%2e%2e/recordings/x"));
assert!(!is_valid_session_id("/Users/attacker/secret.wav"));
@@ -4709,7 +4872,9 @@ mod tests {
assert!(is_valid_local_pack_id("builtin.light"));
assert!(is_valid_local_pack_id("builtin.structured"));
assert!(is_valid_local_pack_id("custom.meeting"));
assert!(is_valid_local_pack_id("550e8400-e29b-41d4-a716-446655440000"));
assert!(is_valid_local_pack_id(
"550e8400-e29b-41d4-a716-446655440000"
));
assert!(is_valid_local_pack_id("my_pack_v2"));
assert!(is_valid_local_pack_id("Pack-2026.05"));
}
+510 -7
View File
@@ -1,4 +1,7 @@
#![cfg_attr(target_os = "linux", allow(dead_code, unused_imports, unused_variables))]
#![cfg_attr(
target_os = "linux",
allow(dead_code, unused_imports, unused_variables)
)]
//! Dictation coordinator.
//!
//! Mirrors the Swift `DictationCoordinator` state machine. Single owner of
@@ -22,7 +25,7 @@ use crate::asr::local::{
foundry, sherpa, FoundryLocalRuntime, FoundryLocalWhisperAsr, SherpaOnnxAsr, SherpaOnnxRuntime,
};
use crate::asr::{
BailianCredentials, BailianRealtimeASR, DictionaryHotword, RawTranscript,
BailianCredentials, BailianRealtimeASR, DictionaryHotword, MimoBatchASR, RawTranscript,
VolcengineCredentials, VolcengineStreamingASR, WhisperBatchASR,
};
use crate::combo_hotkey::{ComboHotkeyError, ComboHotkeyEvent, ComboHotkeyMonitor};
@@ -65,9 +68,11 @@ mod resources;
#[cfg(test)]
use dictation::dictation_error_code;
use dictation::{
begin_session, cancel_session, end_session, handle_pressed, handle_pressed_edge,
handle_released, handle_released_edge, request_stop_during_starting,
begin_session, cancel_session, end_session, handle_pressed_edge, handle_released_edge,
request_stop_during_starting,
};
#[cfg(any(debug_assertions, test))]
use dictation::{handle_pressed, handle_released};
use qa::{close_qa_panel, handle_qa_hotkey_pressed, QaPhase, QaSessionState};
#[cfg(test)]
use resources::discard_startup_resources_for_session;
@@ -144,6 +149,7 @@ fn show_capsule_window_for_recording<R: tauri::Runtime>(
enum ActiveAsr {
Volcengine(Arc<VolcengineStreamingASR>),
Whisper(Arc<WhisperBatchASR>),
Mimo(Arc<MimoBatchASR>),
Bailian(Arc<BailianRealtimeASR>),
#[cfg(target_os = "windows")]
FoundryLocalWhisper(Arc<FoundryLocalWhisperAsr>),
@@ -170,6 +176,7 @@ fn asr_transcribe_uses_global_timeout(asr: &ActiveAsr) -> bool {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ActiveAsrProviderKind {
Bailian,
Mimo,
WhisperCompatible,
Volcengine,
}
@@ -177,6 +184,8 @@ enum ActiveAsrProviderKind {
fn active_asr_provider_kind(id: &str) -> ActiveAsrProviderKind {
if is_bailian_provider(id) {
ActiveAsrProviderKind::Bailian
} else if is_mimo_provider(id) {
ActiveAsrProviderKind::Mimo
} else if is_whisper_compatible_provider(id) {
ActiveAsrProviderKind::WhisperCompatible
} else {
@@ -255,6 +264,12 @@ struct Inner {
/// 划词语音问答(issue #118):与 dictation hotkey 平行的全局快捷键
/// 监听器(global-hotkey crate)。`None` 表示功能关闭或还没成功安装。
qa_hotkey: Mutex<Option<QaHotkeyMonitor>>,
coding_agent_modifier_hotkey: Mutex<Option<HotkeyMonitor>>,
coding_agent_combo_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
/// 最近一次 emit_capsule 下发的 state,纯内省/测试用途(在 app 句柄校验之前写入,
/// 因此无 GUI 的测试环境也能断言「按下热键 → 弹了哪种胶囊」)。写入是单次廉价
/// 加锁,对 ~30Hz 录音回调可忽略。
last_capsule_state: Mutex<Option<CapsuleState>>,
/// QA 单独的 session 状态,与 dictation 的 SessionPhase 不冲突。
qa_state: Mutex<QaSessionState>,
/// 最近一次应用到 capsule 窗口的几何状态。避免录音 level tick 反复触发
@@ -287,6 +302,9 @@ struct Inner {
/// PC 端当前界面语言(BCP-47,如 "zh-CN")。前端切换语言时经命令同步,
/// H5 录音页据此渲染对应语言。进程内镜像,不持久化(前端会在启动/切换时重新下发)。
remote_locale: Mutex<String>,
/// Less Computer 连续对话:true=浮窗里已有进行中的会话,下一轮 `claude --continue` 续上下文;
/// 关闭浮窗(dismiss)复位为 false,下次说话开新会话。
less_computer_conversation: AtomicBool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -349,6 +367,9 @@ impl Coordinator {
open_app_hotkey: Mutex::new(None),
translation_modifier_seen: AtomicBool::new(false),
qa_hotkey: Mutex::new(None),
coding_agent_modifier_hotkey: Mutex::new(None),
coding_agent_combo_hotkey: Mutex::new(None),
last_capsule_state: Mutex::new(None),
qa_state: Mutex::new(QaSessionState::default()),
capsule_layout: Mutex::new(None),
qa_asr: Mutex::new(None),
@@ -361,6 +382,7 @@ impl Coordinator {
remote_server: Mutex::new(None),
remote_pin: Mutex::new(None),
remote_locale: Mutex::new(String::from("zh-CN")),
less_computer_conversation: AtomicBool::new(false),
}),
}
}
@@ -416,6 +438,9 @@ impl Coordinator {
open_app_hotkey: Mutex::new(None),
translation_modifier_seen: AtomicBool::new(false),
qa_hotkey: Mutex::new(None),
coding_agent_modifier_hotkey: Mutex::new(None),
coding_agent_combo_hotkey: Mutex::new(None),
last_capsule_state: Mutex::new(None),
qa_state: Mutex::new(QaSessionState::default()),
capsule_layout: Mutex::new(None),
qa_asr: Mutex::new(None),
@@ -430,6 +455,7 @@ impl Coordinator {
remote_server: Mutex::new(None),
remote_pin: Mutex::new(None),
remote_locale: Mutex::new(String::from("zh-CN")),
less_computer_conversation: AtomicBool::new(false),
}),
}
}
@@ -521,6 +547,24 @@ impl Coordinator {
.ok();
}
/// 启动「快速 Agent」双热键 supervisor。与 QA hotkey 平行;功能默认关闭,
/// 仅在 `coding_agent_enabled` 时注册。
pub fn start_coding_agent_hotkey_listener(&self) {
let inner = Arc::clone(&self.inner);
std::thread::Builder::new()
.name("openless-coding-agent-hotkey-supervisor".into())
.spawn(move || coding_agent_hotkey_supervisor_loop(inner))
.ok();
}
pub fn stop_coding_agent_hotkey_listener(&self) {
take_coding_agent_hotkeys_on_main_thread(&self.inner);
}
pub fn update_coding_agent_hotkey_binding(&self) {
update_coding_agent_hotkey_binding_now(&self.inner);
}
pub fn stop_qa_hotkey_listener(&self) {
// QaHotkeyMonitor::drop 在 macOS 底层是 Carbon RemoveEventHotKey,要求主线程。
// RunEvent::Exit 回调不保证在 AppKit 主线程跑,drop 漏到 tokio worker 上会
@@ -820,6 +864,30 @@ impl Coordinator {
log::info!("[coord] QA window pinned={pinned}");
}
/// 用户点 ✕ / 按 Esc 关 Less Computer 浮窗:隐藏窗口 + 结束连续对话
/// (下次说话开新会话,不再 --continue 续旧上下文)。
pub fn less_computer_window_dismiss(&self) {
self.inner
.less_computer_conversation
.store(false, Ordering::SeqCst);
if let Some(app) = self.inner.app.lock().clone() {
crate::hide_less_computer_window(&app);
crate::hide_less_computer_glow(&app);
}
}
/// 前端按内容测高后回传,后端 clamp + bottom-anchored 重新摆放 Less Computer 浮窗。
pub fn less_computer_window_resize(&self, height: f64) {
if let Some(app) = self.inner.app.lock().clone() {
crate::resize_less_computer_window(&app, height);
}
}
/// 内联审批卡的 Approve / Deny 回执:解析等待中的 token。
pub fn less_computer_approve(&self, token: &str, approved: bool) {
dictation::resolve_less_computer_approval(token, approved);
}
pub fn history(&self) -> &HistoryStore {
&self.inner.history
}
@@ -1505,6 +1573,346 @@ fn qa_hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<QaHotkeyEvent>) {
// ─────────────────────────── combo hotkey supervisor ───────────────────────────
// ─────────────────────── coding agent hotkey supervisor ───────────────────────
fn coding_agent_hotkey_supervisor_loop(inner: Arc<Inner>) {
loop {
if inner.shutdown.load(Ordering::SeqCst) {
return;
}
update_coding_agent_hotkey_binding_now(&inner);
std::thread::sleep(std::time::Duration::from_secs(5));
}
}
fn update_coding_agent_hotkey_binding_now(inner: &Arc<Inner>) {
#[cfg(not(target_os = "macos"))]
{
// Less Computer is intentionally macOS-only for now; keep Windows/Linux hidden and inert.
take_coding_agent_hotkeys_on_main_thread(inner);
return;
}
#[cfg(target_os = "macos")]
{
let prefs = inner.prefs.get();
let Some(binding) = prefs.coding_agent_voice_hotkey.clone() else {
take_coding_agent_hotkeys_on_main_thread(inner);
log::info!("[less-computer] hotkey disabled");
return;
};
if !prefs.coding_agent_enabled || is_unconfigured_shortcut(&binding) {
take_coding_agent_hotkeys_on_main_thread(inner);
return;
}
if let Some(modifier_binding) = less_computer_modifier_binding(&binding) {
take_coding_agent_combo_hotkey_on_main_thread(inner);
if let Some(monitor) = inner.coding_agent_modifier_hotkey.lock().as_ref() {
monitor.update_binding(modifier_binding);
return;
}
let (tx, rx) = mpsc::channel::<HotkeyEvent>();
match HotkeyMonitor::start(modifier_binding, tx) {
Ok(monitor) => {
*inner.coding_agent_modifier_hotkey.lock() = Some(monitor);
log::info!(
"[less-computer] modifier hotkey installed ({})",
binding.display_label()
);
let bridge_inner = Arc::clone(inner);
std::thread::Builder::new()
.name("openless-less-computer-modifier-bridge".into())
.spawn(move || less_computer_modifier_bridge_loop(bridge_inner, rx))
.ok();
}
Err(e) => log::warn!("[less-computer] modifier hotkey install failed: {e}"),
}
return;
}
inner.coding_agent_modifier_hotkey.lock().take();
let app = match inner.app.lock().clone() {
Some(app) => app,
None => {
log::warn!("[less-computer] AppHandle 未 bind,跳过组合键注册");
return;
}
};
let inner_clone = Arc::clone(inner);
let binding_for_main = binding.clone();
let _ = app.run_on_main_thread(move || {
if let Some(monitor) = inner_clone.coding_agent_combo_hotkey.lock().as_ref() {
if let Err(e) = monitor.update_binding(binding_for_main.clone()) {
log::warn!("[less-computer] combo hotkey update failed: {e}");
}
return;
}
let (tx, rx) = mpsc::channel::<ComboHotkeyEvent>();
match ComboHotkeyMonitor::start(binding_for_main.clone(), tx) {
Ok(monitor) => {
*inner_clone.coding_agent_combo_hotkey.lock() = Some(monitor);
log::info!(
"[less-computer] combo hotkey installed ({})",
binding_for_main.display_label()
);
let bridge_inner = Arc::clone(&inner_clone);
std::thread::Builder::new()
.name("openless-less-computer-combo-bridge".into())
.spawn(move || less_computer_combo_bridge_loop(bridge_inner, rx))
.ok();
}
Err(e) => log::warn!("[less-computer] combo hotkey install failed: {e}"),
}
});
}
}
#[cfg(target_os = "macos")]
fn less_computer_modifier_binding(
binding: &crate::types::ShortcutBinding,
) -> Option<crate::types::HotkeyBinding> {
let trigger = crate::shortcut_binding::legacy_modifier_trigger(binding)?;
Some(crate::types::HotkeyBinding {
trigger,
mode: crate::types::HotkeyMode::Hold,
keys: None,
})
}
fn less_computer_modifier_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<HotkeyEvent>) {
while let Ok(evt) = rx.recv() {
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
continue;
}
let inner_cloned = Arc::clone(&inner);
match evt {
HotkeyEvent::Pressed => {
async_runtime::block_on(async {
handle_less_computer_pressed(&inner_cloned).await
});
}
HotkeyEvent::Released => {
async_runtime::block_on(async {
handle_less_computer_released(&inner_cloned).await
});
}
HotkeyEvent::Cancelled => cancel_session(&inner_cloned),
HotkeyEvent::TranslationModifierPressed | HotkeyEvent::QaShortcutPressed => {}
}
}
}
fn less_computer_combo_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<ComboHotkeyEvent>) {
while let Ok(evt) = rx.recv() {
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
continue;
}
let inner_cloned = Arc::clone(&inner);
match evt {
ComboHotkeyEvent::Pressed => {
async_runtime::block_on(async {
handle_less_computer_pressed(&inner_cloned).await
});
}
ComboHotkeyEvent::Released => {
async_runtime::block_on(async {
handle_less_computer_released(&inner_cloned).await
});
}
}
}
}
async fn handle_less_computer_pressed(inner: &Arc<Inner>) {
let prefs = inner.prefs.get();
if !prefs.coding_agent_enabled {
return;
}
if !matches!(inner.state.lock().phase, SessionPhase::Idle) {
log::info!("[less-computer] press ignored: dictation session already active");
return;
}
if !matches!(inner.qa_state.lock().phase, QaPhase::Idle) {
log::info!("[less-computer] press ignored: QA session active");
return;
}
if begin_session(inner).await.is_err() {
return;
}
let started = {
let mut state = inner.state.lock();
if matches!(
state.phase,
SessionPhase::Starting | SessionPhase::Listening
) {
state.voice_agent = true;
log::info!(
"[less-computer] voice session started (session={:?})",
state.session_id
);
true
} else {
false
}
};
// 一按下键(开始录音)就点亮整屏彩虹描边,贯穿 录音 → 处理 → 出结果,完成/关闭才熄灭。
if started {
if let Some(app) = inner.app.lock().clone() {
crate::show_less_computer_glow(&app);
}
}
}
async fn handle_less_computer_released(inner: &Arc<Inner>) {
let (phase, voice_agent) = {
let state = inner.state.lock();
(state.phase, state.voice_agent)
};
if !voice_agent {
return;
}
match phase {
SessionPhase::Listening => {
let _ = end_session(inner).await;
// 收尾后熄灭整屏描边。正常路径 run_voice_agent_transcript 已熄过、这里兜底;
// 空转写/出错路径不进 run_voice_agent_transcript,全靠这里熄,否则描边卡住不灭。
if let Some(app) = inner.app.lock().clone() {
crate::hide_less_computer_glow(&app);
}
}
SessionPhase::Starting => {
// 握手中松手:排队;正常路径真正收尾在 begin 续流的 end_session → run_voice_agent_transcript 熄灭。
request_stop_during_starting(inner, "less-computer release edge");
// 但若初始化失败永远到不了 Listening(不会进 run_voice_agent_transcript),
// 描边会永久卡屏 → 这里兜底熄灭。Listening 分支已有熄灭逻辑,故只在 Starting 加。
if let Some(app) = inner.app.lock().clone() {
crate::hide_less_computer_glow(&app);
}
}
_ => {}
}
}
fn take_coding_agent_hotkeys_on_main_thread(inner: &Arc<Inner>) {
inner.coding_agent_modifier_hotkey.lock().take();
take_coding_agent_combo_hotkey_on_main_thread(inner);
}
fn take_coding_agent_combo_hotkey_on_main_thread(inner: &Arc<Inner>) {
let app = inner.app.lock().clone();
if let Some(app) = app {
let inner = Arc::clone(inner);
let _ = app.run_on_main_thread(move || {
inner.coding_agent_combo_hotkey.lock().take();
});
} else {
inner.coding_agent_combo_hotkey.lock().take();
}
}
/// 快取用:抓当前选中文本 → Claude 润色 → 回插(替换选区)。全程胶囊反馈。
async fn handle_coding_agent_quick(inner: &Arc<Inner>) {
let prefs = inner.prefs.get();
if !prefs.coding_agent_enabled {
return;
}
let selection = tauri::async_runtime::spawn_blocking(crate::selection::capture_selection)
.await
.ok()
.flatten();
let source_text = match selection {
Some(ctx) => ctx.text,
None => {
log::info!("[coding-agent] 快取用:没有选中文本");
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some("请先选中文本,再按快捷键".to_string()),
None,
);
return;
}
};
log::info!(
"[coding-agent] 快取用:润色 {} 字",
source_text.chars().count()
);
emit_capsule(
inner,
CapsuleState::Polishing,
0.0,
0,
Some("Claude 润色中…".to_string()),
None,
);
let prompt = format!(
"请润色下面这段文字,使其更通顺自然、表达更清晰,保持原意、语言和事实不变。\
直接输出润色后的文本,不要加任何解释、前缀或引号:\n\n{source_text}"
);
// 纯文本润色:不需要任何工具 → plan 只读、无 guard、便宜快、最可靠。
let mut req = crate::coding_agent::CodingAgentRequest::new("quick-polish", prompt);
req.model = prefs
.coding_agent_model
.clone()
.filter(|m| !m.trim().is_empty())
.or_else(|| Some("sonnet".to_string()));
req.permission_mode = crate::coding_agent::CodingAgentPermissionMode::Plan;
req.allowed_tools = Vec::new();
req.max_budget_usd = Some(0.2);
req.timeout_secs = 60;
req.session_persistence = false;
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let cancel = Arc::new(std::sync::atomic::AtomicBool::new(false));
let run = async_runtime::spawn(async move {
crate::coding_agent::run_claude_agent("claude", req, tx, cancel).await
});
let mut final_text = String::new();
let mut error_msg: Option<String> = None;
while let Some(ev) = rx.recv().await {
match ev {
crate::coding_agent::CodingAgentEvent::Completed { text, .. } => final_text = text,
crate::coding_agent::CodingAgentEvent::Error { message, .. } => {
error_msg = Some(message)
}
_ => {}
}
}
let run_result = run.await;
let final_text = final_text.trim().to_string();
if final_text.is_empty() {
let msg = error_msg
.or_else(|| match run_result {
Ok(Err(e)) => Some(e.to_string()),
_ => None,
})
.unwrap_or_else(|| "Claude 无结果(确认已登录 claude 且额度充足)".to_string());
log::warn!("[coding-agent] 快取用失败: {msg}");
emit_capsule(inner, CapsuleState::Error, 0.0, 0, Some(msg), None);
return;
}
let inserted = final_text.chars().count() as u32;
let inner2 = Arc::clone(inner);
let restore = prefs.restore_clipboard_after_paste;
let paste_shortcut = prefs.paste_shortcut;
let _ = tauri::async_runtime::spawn_blocking(move || {
inner2.inserter.insert(&final_text, restore, paste_shortcut)
})
.await;
log::info!("[coding-agent] 快取用:已回插 {inserted} 字");
emit_capsule(inner, CapsuleState::Done, 0.0, 0, None, Some(inserted));
}
fn combo_hotkey_supervisor_loop(inner: Arc<Inner>) {
let mut attempts: u32 = 0;
loop {
@@ -2701,7 +3109,10 @@ async fn build_local_qwen3(
/// (messages=[{content:[{audio:...}]}]) 协议,不是 Whisper multipart,需要
/// 单独 ASR 客户端,留给 V2。
fn is_whisper_compatible_provider(id: &str) -> bool {
matches!(id, "whisper" | "siliconflow" | "zhipu" | "groq" | "openrouter")
matches!(
id,
"whisper" | "siliconflow" | "zhipu" | "groq" | "openrouter"
)
}
/// 该 provider 的请求体编码方式。OpenRouter 的 `/audio/transcriptions` 是
@@ -2730,6 +3141,10 @@ fn is_bailian_provider(id: &str) -> bool {
id == crate::asr::bailian::PROVIDER_ID
}
fn is_mimo_provider(id: &str) -> bool {
id == crate::asr::mimo::PROVIDER_ID
}
fn apply_chinese_script_preference(text: &str, pref: ChineseScriptPreference) -> String {
if text.is_empty() {
return String::new();
@@ -2882,6 +3297,13 @@ async fn build_qa_asr_start(inner: &Arc<Inner>, active_asr: &str) -> Result<QaAs
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 =
@@ -3282,6 +3704,24 @@ fn read_whisper_credentials() -> (String, String, String) {
(api_key, base_url, model)
}
fn read_mimo_credentials() -> (String, String, String) {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
.ok()
.flatten()
.unwrap_or_default();
let base_url = CredentialsVault::get(CredentialAccount::AsrEndpoint)
.ok()
.flatten()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::mimo::DEFAULT_ENDPOINT.to_string());
let model = CredentialsVault::get(CredentialAccount::AsrModel)
.ok()
.flatten()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::mimo::DEFAULT_MODEL.to_string());
(api_key, base_url, model)
}
fn read_bailian_credentials() -> BailianCredentials {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
.ok()
@@ -3634,6 +4074,26 @@ async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
}
}
}
ActiveAsr::Mimo(m) => {
debug_assert!(uses_global_timeout);
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
match tokio::time::timeout(timeout_duration, m.transcribe()).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
log::error!("[coord] QA: MiMo ASR transcribe failed: {e}");
finish_qa_with_error(inner, format!("识别失败: {e}"));
return Err(e.to_string());
}
Err(_) => {
log::error!(
"[coord] QA: MiMo ASR 全局超时 {} 秒",
COORDINATOR_GLOBAL_TIMEOUT_SECS
);
finish_qa_with_error(inner, "识别超时".to_string());
return Err("mimo global timeout".to_string());
}
}
}
#[cfg(target_os = "windows")]
ActiveAsr::FoundryLocalWhisper(local) => {
debug_assert!(!uses_global_timeout);
@@ -4167,6 +4627,33 @@ mod tests {
std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN");
}
/// 复现并验证目标 2(a):按下 Less Computer 键必须弹出可见胶囊。
/// 这里直接驱动 bridge 会调用的 handler,断言 begin_session 确实下发了可见胶囊。
#[tokio::test]
async fn less_computer_press_emits_visible_capsule() {
let _guard = ENV_LOCK.lock().await;
std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1");
let coordinator = Coordinator::new();
{
let mut prefs = coordinator.inner.prefs.get();
prefs.coding_agent_enabled = true;
coordinator.inner.prefs.set(prefs).unwrap();
}
// 前置:还没弹过任何胶囊。
assert!(coordinator.inner.last_capsule_state.lock().is_none());
// 等价于「按下 Less Computer 键」:bridge_loop 收到 Pressed 后就是调这个 handler。
super::handle_less_computer_pressed(&coordinator.inner).await;
assert_eq!(
*coordinator.inner.last_capsule_state.lock(),
Some(CapsuleState::Recording),
"按下 Less Computer 键必须进入录音并弹出可见胶囊"
);
std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN");
}
#[tokio::test]
async fn begin_session_dry_run_enters_listening_and_clears_stale_edges() {
let _guard = ENV_LOCK.lock().await;
@@ -4269,6 +4756,9 @@ mod tests {
assert!(!is_whisper_compatible_provider(
crate::asr::local::sherpa::PROVIDER_ID
));
assert!(!is_whisper_compatible_provider(
crate::asr::mimo::PROVIDER_ID
));
}
#[test]
@@ -4312,6 +4802,10 @@ mod tests {
active_asr_provider_kind("whisper"),
ActiveAsrProviderKind::WhisperCompatible
);
assert_eq!(
active_asr_provider_kind(crate::asr::mimo::PROVIDER_ID),
ActiveAsrProviderKind::Mimo
);
assert_eq!(
active_asr_provider_kind("volcengine"),
ActiveAsrProviderKind::Volcengine
@@ -5236,7 +5730,9 @@ fn show_capsule_window_no_activate<R: tauri::Runtime>(
let Ok(handle) = window.window_handle() else {
// #470 诊断 v2:Win32 show 路径最可能的暗点之一。此前静默 return,
// 无法观测「胶囊完全不显示」是否卡在这里。
log::warn!("[capsule] no_activate failed: window_handle() unavailable — Win32 show skipped");
log::warn!(
"[capsule] no_activate failed: window_handle() unavailable — Win32 show skipped"
);
return false;
};
let RawWindowHandle::Win32(raw) = handle.as_raw() else {
@@ -5357,9 +5853,12 @@ fn emit_capsule(
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,
@@ -5367,6 +5866,7 @@ fn emit_capsule(
message,
inserted_chars,
translation,
operating,
};
// visible / translation 是「这一帧 capsule:state event 的 payload」内容 ——
@@ -5535,7 +6035,10 @@ fn emit_capsule(
}
});
let _ = app.emit_to("capsule", "capsule:state", payload);
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)]
@@ -1,4 +1,4 @@
use std::sync::atomic::Ordering;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use crate::coordinator_state::request_stop_during_starting_state;
@@ -14,6 +14,47 @@ use super::*;
const HOTKEY_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(250);
const STREAMING_INSERT_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(12);
/// Less Computer 浮窗的 Tauri 事件名(前端 LessComputerPanel 订阅)。
const LESS_COMPUTER_EVENT: &str = "less-computer:event";
/// Less Computer 内联审批:等待用户决断的 token → oneshot sender 注册表。
///
/// 无头 `claude -p` 没有 mid-run 的 `--permission-prompt-tool` 通道(v2.1.165 不支持),
/// 所以护栏拦截发生在「整轮跑完、护栏 deny 生效」之后。这个注册表是审批 UI 的实回路:
/// 后端发 `approval` 事件后把一个 oneshot 接收端挂在这里,等前端 `less_computer_approve`
/// 命令按 token 解析出用户决断(true=Approve / false=Deny)。
static LESS_COMPUTER_APPROVALS: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, tokio::sync::oneshot::Sender<bool>>>,
> = std::sync::OnceLock::new();
fn less_computer_approvals(
) -> &'static std::sync::Mutex<std::collections::HashMap<String, tokio::sync::oneshot::Sender<bool>>>
{
LESS_COMPUTER_APPROVALS.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
}
/// 前端 `less_computer_approve` 命令调到这里:按 token 解析等待中的审批。
/// token 不存在(已超时 / 已解析)时静默忽略。
pub(super) fn resolve_less_computer_approval(token: &str, approved: bool) {
let sender = less_computer_approvals()
.lock()
.ok()
.and_then(|mut m| m.remove(token));
if let Some(tx) = sender {
let _ = tx.send(approved);
log::info!("[less-computer] 审批 token={token} approved={approved}");
} else {
log::info!("[less-computer] 审批 token={token} 已失效(超时/重复)");
}
}
/// 往 Less Computer 浮窗发一条事件(macOS only;前端按 `kind` 渲染聊天结构)。
fn emit_less_computer(inner: &Arc<Inner>, payload: serde_json::Value) {
if let Some(app) = inner.app.lock().clone() {
let _ = app.emit_to("less-computer", LESS_COMPUTER_EVENT, payload);
}
}
/// 跑流式润色路径(opt-in,跨平台)。
///
/// 平台差异:
@@ -187,8 +228,9 @@ async fn run_streaming_polish(
Some(e) => (typed_text, Some(format!("typing partially failed: {e}"))),
None => (text, None),
};
// 把 final_text 写回剪贴板(默认 on,可关)。一次性路径天然走剪贴板,
// 开关默认对齐一次性行为,让 Cmd+V 重复粘贴可用。
// 把 final_text 写回剪贴板(默认 on,macOS/Windows 适用)。
// Linux:fcitx5 插件已直写文字到目标 app,跳过剪贴板避免破坏用户数据。
#[cfg(not(target_os = "linux"))]
if inner.prefs.get().streaming_insert_save_clipboard {
match arboard::Clipboard::new() {
Ok(mut cb) => match cb.set_text(final_text.clone()) {
@@ -511,6 +553,10 @@ pub(super) async fn handle_released(inner: &Arc<Inner>) {
let mode = inner.prefs.get().hotkey.mode;
let phase = inner.state.lock().phase;
log::info!("[coord] hotkey released (mode={mode:?}, phase={phase:?})");
if mode == HotkeyMode::Toggle {
// Toggle 听写松手不做事(点一下停)。Less Computer 走独立专用键监听器。
return;
}
if mode == HotkeyMode::Hold {
match phase {
SessionPhase::Listening => {
@@ -525,6 +571,417 @@ pub(super) async fn handle_released(inner: &Arc<Inner>) {
}
}
/// Less Computer 收尾:把转写当作指令交给无头 Claude,结果以胶囊展示(不插入到光标)。
async fn run_voice_agent_transcript(
inner: &Arc<Inner>,
_session_id: SessionId,
transcript: String,
elapsed: u64,
) -> Result<(), String> {
log::info!(
"[coord] Cloud Agent 语音:指令 {} 字",
transcript.chars().count()
);
// 胶囊保留「处理中」反馈(用户熟悉的小录音条状态机);聊天浮窗承载完整对话。
emit_capsule(
inner,
CapsuleState::Polishing,
0.0,
elapsed,
Some("Claude 处理中…".to_string()),
None,
);
// 聊天浮窗:显示窗口 + 落用户气泡(语音指令转写)。macOS only(helper 内部 gating)。
if let Some(app) = inner.app.lock().clone() {
crate::show_less_computer_window(&app);
// 全屏彩虹描边已在按下键时(handle_less_computer_pressed)点亮,这里不重复。
}
// 连续对话:浮窗里已有进行中的会话 → 本轮 `claude --continue` 续上下文;否则是新会话(fresh)。
// dismiss 关窗会把标志复位为 false。
let continue_session = inner
.less_computer_conversation
.swap(true, Ordering::SeqCst);
emit_less_computer(
inner,
serde_json::json!({ "kind": "user", "text": transcript, "fresh": !continue_session }),
);
let prefs = inner.prefs.get();
// 工作目录:用户设的 workdir,否则 $HOME。--add-dir 把文件作用域限定在此。
let cwd = prefs
.coding_agent_workdir
.clone()
.filter(|d| !d.trim().is_empty())
.map(std::path::PathBuf::from)
.or_else(|| std::env::var("HOME").ok().map(std::path::PathBuf::from));
// 运行前 git 快照(cwd 是 git 仓库才有效;非仓库无副作用),便于回滚文件改动。
if let Some(dir) = &cwd {
if let Some(sha) = crate::coding_agent::create_git_snapshot(dir) {
log::info!("[less-computer] 运行前 git 快照 {sha}(git stash apply 可回滚)");
}
}
// 钳制:语音 → shell 这条全自动路径禁止 bypassPermissions 绕过护栏(无人审、动手即生效)。
// 即便用户在偏好里设了 bypass,这里也降级为 acceptEdits(仍带 deny 护栏)。
let mode = match coding_agent_mode_from_pref(&prefs.coding_agent_permission_mode) {
crate::coding_agent::CodingAgentPermissionMode::BypassPermissions => {
log::warn!(
"[less-computer] 语音 Agent 路径禁止 bypassPermissions,已降级为 acceptEdits(保留护栏)"
);
crate::coding_agent::CodingAgentPermissionMode::AcceptEdits
}
other => other,
};
let model = prefs
.coding_agent_model
.clone()
.filter(|m| !m.trim().is_empty())
.or_else(|| Some("sonnet".to_string()));
let prompt = crate::coding_agent::autonomous_prompt(&transcript);
// 第一轮:默认护栏(高风险全 deny)。运行后若检测到护栏拦截,弹审批卡;
// 用户 Approve 则在第二轮把该高风险模式从 deny 移除 + 加进 allowed,重跑一次。
let outcome = run_less_computer_once(
inner,
&prompt,
cwd.as_deref(),
mode,
model.as_deref(),
&[],
continue_session,
)
.await;
let final_outcome = match maybe_request_approval(inner, &outcome).await {
Some(approved_pattern) => {
log::info!("[less-computer] 审批通过,放行高风险模式后重跑:{approved_pattern}");
run_less_computer_once(
inner,
&prompt,
cwd.as_deref(),
mode,
model.as_deref(),
&[approved_pattern],
continue_session,
)
.await
}
None => outcome,
};
inner.state.lock().phase = SessionPhase::Idle;
// 工作结束:熄灭全屏彩虹描边(聊天浮窗保留,等用户读完/关闭)。
if let Some(app) = inner.app.lock().clone() {
crate::hide_less_computer_glow(&app);
}
match final_outcome {
LessComputerOutcome::Done { text, cost_usd } => {
let text = text.trim().to_string();
if text.is_empty() {
let msg = "Claude 无结果(确认已登录 claude 且额度充足)".to_string();
emit_less_computer(
inner,
serde_json::json!({ "kind": "error", "message": msg }),
);
emit_capsule(inner, CapsuleState::Error, 0.0, elapsed, Some(msg), None);
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err("voice agent empty".to_string());
}
log::info!("[coord] Cloud Agent 语音:返回 {} 字", text.chars().count());
emit_less_computer(
inner,
serde_json::json!({ "kind": "completed", "text": text, "costUsd": cost_usd }),
);
emit_capsule(inner, CapsuleState::Done, 0.0, elapsed, Some(text), None);
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
Ok(())
}
LessComputerOutcome::Failed { message } => {
log::warn!("[coord] Cloud Agent 语音失败: {message}");
emit_less_computer(
inner,
serde_json::json!({ "kind": "error", "message": message }),
);
emit_capsule(
inner,
CapsuleState::Error,
0.0,
elapsed,
Some(message),
None,
);
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
Err("voice agent failed".to_string())
}
LessComputerOutcome::Cancelled => {
log::info!("[coord] Cloud Agent 语音已取消");
emit_less_computer(inner, serde_json::json!({ "kind": "cancelled" }));
emit_capsule(inner, CapsuleState::Cancelled, 0.0, elapsed, None, None);
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
Err("voice agent cancelled".to_string())
}
}
}
/// 一轮无头 Less Computer 运行的结果。
enum LessComputerOutcome {
Done { text: String, cost_usd: Option<f64> },
Failed { message: String },
Cancelled,
}
/// 跑一轮无头 Claude(「放行 + 护栏」),把 Delta/ToolUse 实时 stream 到聊天浮窗,
/// 终局收敛为 [`LessComputerOutcome`]。`extra_allow_patterns` 为审批通过后放行的
/// 高风险子串(如 "git push --force"):从 deny 清单剔除 + 作为 `Bash(<pat>:*)` 加进 allowed。
async fn run_less_computer_once(
inner: &Arc<Inner>,
prompt: &str,
cwd: Option<&std::path::Path>,
mode: crate::coding_agent::CodingAgentPermissionMode,
model: Option<&str>,
extra_allow_patterns: &[String],
continue_session: bool,
) -> LessComputerOutcome {
// 护栏 deny:默认全量;审批放行的模式从 deny 中剔除。
// 审批 UI 只回传命中的单个高风险子串,但同一风险有等价写法(如 --force / -f)。
// 按「风险等价组」整组放行:只放行被点那一个会让等价写法仍卡在 deny(deny 优先级高于
// allow)→ 命令仍被拦。见 guard::risk_equivalent_patterns。
let mut deny = crate::coding_agent::guard::default_deny_rules();
let approved_patterns: Vec<String> = extra_allow_patterns
.iter()
.flat_map(|p| {
let group = crate::coding_agent::guard::risk_equivalent_patterns(p);
if group.is_empty() {
vec![p.clone()]
} else {
group.into_iter().map(|s| s.to_string()).collect()
}
})
.collect();
let allow_rules: Vec<String> = approved_patterns
.iter()
.map(|p| format!("Bash({p}:*)"))
.collect();
if !allow_rules.is_empty() {
deny.retain(|d| !allow_rules.iter().any(|a| a == d));
}
let settings_json = serde_json::json!({
"permissions": { "defaultMode": mode.as_cli_arg(), "deny": deny }
});
let settings_path = std::env::temp_dir().join(format!(
"openless-less-computer-guard-{}.json",
uuid::Uuid::new_v4()
));
// fail-closed:序列化或写入失败时立即中止,绝不在「无护栏」下把无效路径交给
// `claude -p --settings`(找不到文件 = 完全裸跑)。宁可不跑也不裸跑。
let settings_bytes = match serde_json::to_vec_pretty(&settings_json) {
Ok(b) => b,
Err(e) => {
log::warn!("[less-computer] 序列化护栏配置失败: {e}");
return LessComputerOutcome::Failed {
message: "护栏配置写入失败,已中止(拒绝在无护栏下执行)".into(),
};
}
};
if let Err(e) = std::fs::write(&settings_path, settings_bytes) {
log::warn!("[less-computer] 写护栏配置失败: {e}");
return LessComputerOutcome::Failed {
message: "护栏配置写入失败,已中止(拒绝在无护栏下执行)".into(),
};
}
let mut req = crate::coding_agent::CodingAgentRequest::new("less-computer", prompt.to_string());
req.cwd = cwd.map(|p| p.to_path_buf());
req.model = model.map(|m| m.to_string());
req.permission_mode = mode;
// 写护栏成功后才设置:写失败已在上面 fail-closed 返回,不会带无效路径裸跑。
req.settings_json_path = Some(settings_path.clone());
// 去掉 WebFetch:无出站白名单时它是 prompt 注入 SSRF 面(诱导拉取内网/元数据端点)。
// 保留 WebSearch(走搜索引擎,不直接抓任意 URL)。
req.allowed_tools = vec![
"Bash".into(),
"Read".into(),
"Edit".into(),
"Write".into(),
"Glob".into(),
"Grep".into(),
"WebSearch".into(),
];
req.allowed_tools.extend(allow_rules);
// 真实任务(开应用、多步操作、读写文件)常超过 120s/0.5$ → 老是「运行超时」。放宽到
// 5 分钟 / 2$,给多步任务足够空间;仍有硬上限兜底,不会无限跑/烧钱。
req.max_budget_usd = Some(2.0);
req.timeout_secs = 300;
// 连续对话需要保留会话:本轮保存(供下轮 --continue),第二轮起带 --continue 续上下文。
req.session_persistence = true;
req.continue_session = continue_session;
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let cancel = Arc::new(AtomicBool::new(false));
let cancel_for_runner = Arc::clone(&cancel);
let run = async_runtime::spawn(async move {
crate::coding_agent::run_claude_agent("claude", req, tx, cancel_for_runner).await
});
let cancel_for_watcher = Arc::clone(&cancel);
let inner_for_cancel = Arc::clone(inner);
let cancel_watcher = async_runtime::spawn(async move {
loop {
if cancel_for_watcher.load(Ordering::Relaxed) {
return;
}
if inner_for_cancel.state.lock().cancelled {
cancel_for_watcher.store(true, Ordering::Relaxed);
return;
}
tokio::time::sleep(std::time::Duration::from_millis(120)).await;
}
});
let mut final_text = String::new();
let mut cost_usd: Option<f64> = None;
let mut error_msg: Option<String> = None;
let mut cancelled = false;
while let Some(ev) = rx.recv().await {
use crate::coding_agent::CodingAgentEvent as E;
match ev {
E::Started { .. } => {
emit_less_computer(inner, serde_json::json!({ "kind": "started" }));
}
E::Delta { text, .. } => {
emit_less_computer(inner, serde_json::json!({ "kind": "delta", "text": text }));
}
E::ToolUse { name, .. } => {
emit_less_computer(inner, serde_json::json!({ "kind": "tool", "name": name }));
}
E::Completed {
text, cost_usd: c, ..
} => {
final_text = text;
cost_usd = c;
}
E::Error { message, .. } => error_msg = Some(message),
E::Cancelled { .. } => cancelled = true,
}
}
let run_result = run.await;
cancel.store(true, Ordering::Relaxed);
let _ = cancel_watcher.await;
let _ = std::fs::remove_file(&settings_path);
if cancelled
|| matches!(
&run_result,
Ok(Err(crate::coding_agent::CodingAgentError::Cancelled))
)
{
return LessComputerOutcome::Cancelled;
}
let trimmed = final_text.trim().to_string();
if !trimmed.is_empty() {
LessComputerOutcome::Done {
text: trimmed,
cost_usd,
}
} else {
let message = error_msg
.or_else(|| match run_result {
Ok(Err(e)) => Some(e.to_string()),
_ => None,
})
.unwrap_or_else(|| "Claude 无结果(确认已登录 claude 且额度充足)".to_string());
LessComputerOutcome::Failed { message }
}
}
/// 护栏拦截探测 + 内联审批(best-effort)。
///
/// 无头 `claude -p`(v2.1.165)没有 mid-run 的 `--permission-prompt-tool` 通道,所以
/// 我们只能在「一轮跑完」后判断护栏是否拦了高风险动作:扫描终局文本里是否提到某个
/// 高风险模式 + 权限/拒绝/blocked 关键词。命中则发 `approval` 事件、挂一个 oneshot 等
/// 用户决断(前端 Approve/Deny → `less_computer_approve` 命令解析)。
///
/// 返回 `Some(pattern)` 表示用户 Approve 了某高风险模式 → 调用方应放行该模式重跑一轮;
/// `None` 表示无需审批 / 用户 Deny / 超时。**注意**这是「重跑放行」而非真正的 mid-run
/// 续跑——headless 下没有干净的 mid-run round-trip,详见 report。
async fn maybe_request_approval(
inner: &Arc<Inner>,
outcome: &LessComputerOutcome,
) -> Option<String> {
let text = match outcome {
LessComputerOutcome::Done { text, .. } => text.as_str(),
LessComputerOutcome::Failed { message } => message.as_str(),
LessComputerOutcome::Cancelled => return None,
};
let lowered = text.to_lowercase();
// 必须同时出现「拒绝/权限/blocked」语义 + 某个已知高风险模式,才认为是护栏拦截,
// 避免把正常提到 "rm" 的回答误判成审批请求。
let mentions_block = [
"denied",
"permission",
"not allowed",
"blocked",
"拒绝",
"权限",
"被拦",
]
.iter()
.any(|kw| lowered.contains(kw));
if !mentions_block {
return None;
}
let hit = crate::coding_agent::guard::HIGH_RISK_PATTERNS
.iter()
.find(|(pat, _)| lowered.contains(*pat))?;
let (pattern, reason) = (hit.0.to_string(), hit.1.to_string());
// 挂 oneshot 等用户决断。
let token = uuid::Uuid::new_v4().to_string();
let (tx, rx) = tokio::sync::oneshot::channel::<bool>();
if let Ok(mut map) = less_computer_approvals().lock() {
map.insert(token.clone(), tx);
}
emit_less_computer(
inner,
serde_json::json!({
"kind": "approval",
"token": token,
"command": pattern,
"reason": reason,
}),
);
// 等用户点 Approve/Deny;90s 无响应按 Deny 处理并清理注册表项。
let approved = match tokio::time::timeout(std::time::Duration::from_secs(90), rx).await {
Ok(Ok(v)) => v,
_ => {
less_computer_approvals()
.lock()
.ok()
.map(|mut m| m.remove(&token));
false
}
};
if approved {
Some(pattern)
} else {
None
}
}
/// 把 prefs 里的权限模式字符串映射成枚举;未知值回落到 acceptEdits(放行+护栏的默认)。
fn coding_agent_mode_from_pref(s: &str) -> crate::coding_agent::CodingAgentPermissionMode {
use crate::coding_agent::CodingAgentPermissionMode as M;
match s.trim() {
"plan" => M::Plan,
"default" => M::Default,
"bypassPermissions" => M::BypassPermissions,
_ => M::AcceptEdits,
}
}
pub(super) fn request_stop_during_starting(inner: &Arc<Inner>, reason: &str) {
{
let mut state = inner.state.lock();
@@ -807,6 +1264,17 @@ pub(super) async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
let flushed_bytes = bridge.attach(target);
log::info!("[coord] Bailian ASR connected; flushed {flushed_bytes} deferred audio bytes");
finish_starting_session(inner, current_session_id).await;
} else if is_mimo_provider(&active_asr) {
let (api_key, base_url, model) = read_mimo_credentials();
let mimo = Arc::new(MimoBatchASR::new(api_key, base_url, model));
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::Mimo(Arc::clone(&mimo)),
);
let consumer: Arc<dyn crate::recorder::AudioConsumer> = mimo;
start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer)
.await?;
} else if is_whisper_compatible_provider(&active_asr) {
let (api_key, base_url, model) = read_whisper_credentials();
// 用户辞書の有効フレーズを Whisper の `prompt` に流し込む。固有名詞や
@@ -1262,6 +1730,46 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
}
}
}
ActiveAsr::Mimo(m) => {
debug_assert!(uses_global_timeout);
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
match tokio::time::timeout(timeout_duration, m.transcribe()).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
log::error!("[coord] MiMo ASR transcribe failed: {e}");
emit_capsule(
inner,
CapsuleState::Error,
0.0,
elapsed,
Some(format!("识别失败: {e}")),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(e.to_string());
}
Err(_) => {
log::error!(
"[coord] MiMo ASR 全局超时 {} 秒",
COORDINATOR_GLOBAL_TIMEOUT_SECS
);
emit_capsule(
inner,
CapsuleState::Error,
0.0,
elapsed,
Some("识别超时".to_string()),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err("mimo global timeout".to_string());
}
}
}
ActiveAsr::Bailian(asr) => {
debug_assert!(uses_global_timeout);
if let Err(e) = asr.send_last_frame().await {
@@ -1554,6 +2062,13 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
raw.text = corrected;
}
}
// Cloud Agent 语音分流:长按升级的会话不走润色/插入,转写交给 Claude 跑任务、结果弹胶囊。
if inner.state.lock().voice_agent {
return run_voice_agent_transcript(inner, current_session_id, raw.text.clone(), elapsed)
.await;
}
emit_capsule(inner, CapsuleState::Polishing, 0.0, elapsed, None, None);
let prefs = inner.prefs.get();
@@ -1926,6 +2441,10 @@ pub(super) fn cancel_session(inner: &Arc<Inner>) {
emit_capsule(inner, CapsuleState::Cancelled, 0.0, 0, None, None);
log::info!("[coord] session cancelled (was {:?})", decision.phase);
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
// 取消时也熄灭整屏彩虹描边(dictation session 没开描边,hide 是无害 no-op)。
if let Some(app) = inner.app.lock().clone() {
crate::hide_less_computer_glow(&app);
}
}
fn append_typed_prefix(target: &mut String, delta: &str, typed_chars: usize) -> usize {
@@ -67,6 +67,7 @@ pub(super) fn cancel_active_asr(asr: ActiveAsr) {
match asr {
ActiveAsr::Volcengine(v) => v.cancel(),
ActiveAsr::Whisper(w) => w.cancel(),
ActiveAsr::Mimo(m) => m.cancel(),
ActiveAsr::Bailian(b) => b.cancel(),
#[cfg(target_os = "windows")]
ActiveAsr::FoundryLocalWhisper(local) => local.cancel(),
@@ -48,6 +48,9 @@ pub(crate) struct SessionState {
/// 用户开始 dictation 时所处的前台 app 标签("Mail (com.apple.mail)" / Windows 窗口标题)。
/// 用作 LLM polish/translate 的上下文前提,让模型按 app 调风格。详见 issue #116。
pub(crate) front_app: Option<String>,
/// Less Computer 语音模式:专用 Agent 键按下后置 true。end_session 在拿到转写后
/// 据此分流——不走润色插入,转而把转写交给 Claude 跑任务、结果弹胶囊。默认 false。
pub(crate) voice_agent: bool,
}
impl Default for SessionState {
@@ -60,6 +63,7 @@ impl Default for SessionState {
focus_target: None,
session_id: initial_session_id(),
front_app: None,
voice_agent: false,
}
}
}
@@ -80,6 +84,8 @@ pub(crate) fn begin_session_state(
state.focus_target = focus_target;
state.session_id = new_session_id();
state.front_app = front_app;
// 每个新会话默认是普通听写;Less Computer 专用入口会显式把它标为语音 Agent。
state.voice_agent = false;
Some(state.session_id)
}
@@ -247,6 +253,18 @@ mod tests {
assert_ne!(id, initial_session_id());
}
#[test]
fn begin_session_resets_voice_agent_flag() {
// 安全护栏:上一会话残留的 voice_agent=true 绝不能让下一次普通听写被误判成
// Cloud Agent(否则听写内容会被发去跑 Claude 而不是插入光标)。
let mut state = SessionState {
voice_agent: true,
..Default::default()
};
begin_session_state(&mut state, None, None).unwrap();
assert!(!state.voice_agent, "新会话必须从普通听写开始");
}
#[test]
fn begin_session_ignores_non_idle_phase() {
let mut state = SessionState {
+12 -4
View File
@@ -228,7 +228,17 @@ where
}
fn update_shared_binding(shared: &Shared, binding: HotkeyBinding) {
*shared.binding.write() = binding;
{
let mut current = shared.binding.write();
if *current == binding {
// 绑定未变化(如 supervisor 每 5s 周期性重新应用同一绑定):不要碰 held latch。
// 否则会在长按期间把「已按住」清成 false,松手时 `!is_active && was_held` 不成立、
// 不再发 Released —— hold 模式(Less Computer 按住说话)录音停不下来、要再按一次。
// 复现:长按 >5s 跨过一次 supervisor 轮询即触发。
return;
}
*current = binding;
}
shared
.trigger_held
.store(false, std::sync::atomic::Ordering::SeqCst);
@@ -1200,9 +1210,7 @@ mod platform {
_binding: HotkeyBinding,
_tx: Sender<HotkeyEvent>,
) -> Result<Box<dyn HotkeyAdapter>, HotkeyInstallError> {
log::info!(
"[hotkey] Linux — fcitx5 plugin handles hotkeys"
);
log::info!("[hotkey] Linux — fcitx5 plugin handles hotkeys");
Ok(Box::new(PlaceholderAdapter { _tx }))
}
+124 -19
View File
@@ -30,10 +30,27 @@ impl TextInserter {
Self
}
/// Windows/Linux 路径:写剪贴板 + 模拟 `paste_shortcut`。
/// Linux 路径:优先走 fcitx5 CommitText;插件不可用或提交失败时回退剪贴板粘贴。
#[cfg(target_os = "linux")]
pub fn insert(
&self,
text: &str,
restore_clipboard_after_paste: bool,
paste_shortcut: PasteShortcut,
) -> InsertStatus {
insert_with_fcitx_or_clipboard_fallback(
text,
restore_clipboard_after_paste,
paste_shortcut,
crate::linux_fcitx::commit_text,
insert_with_clipboard_restore,
)
}
/// Windows 路径:写剪贴板 + 模拟 `paste_shortcut`。
/// - `restore_clipboard_after_paste`:粘贴后是否恢复用户原剪贴板。
/// - `paste_shortcut`:模拟按下的粘贴快捷键(如终端可能要 Ctrl+Shift+V)。
#[cfg(not(target_os = "macos"))]
#[cfg(target_os = "windows")]
pub fn insert(
&self,
text: &str,
@@ -43,22 +60,6 @@ impl TextInserter {
if text.is_empty() {
return InsertStatus::CopiedFallback;
}
// Linux: 始终优先使用 fcitx5 CommitText 直写(支持中文)。
// 如果插件未加载,降级到剪贴板拷贝(统一路径,不单独维护 enigo XTest)。
#[cfg(target_os = "linux")]
{
match crate::linux_fcitx::commit_text(text) {
Ok(()) => return InsertStatus::Inserted,
Err(e) => {
log::warn!("[insertion] fcitx commit_text failed: {e}, fallback to clipboard only");
if copy_to_clipboard(text) {
return InsertStatus::CopiedFallback;
}
return InsertStatus::Failed;
}
}
}
#[cfg(not(target_os = "linux"))]
insert_with_clipboard_restore(text, restore_clipboard_after_paste, paste_shortcut)
}
@@ -69,7 +70,10 @@ impl TextInserter {
restore_clipboard_after_paste: bool,
paste_shortcut: PasteShortcut,
) -> InsertStatus {
self.insert(text, restore_clipboard_after_paste, paste_shortcut)
if text.is_empty() {
return InsertStatus::CopiedFallback;
}
insert_with_clipboard_restore(text, restore_clipboard_after_paste, paste_shortcut)
}
#[cfg(target_os = "windows")]
@@ -116,6 +120,32 @@ impl TextInserter {
}
}
#[cfg(target_os = "linux")]
fn insert_with_fcitx_or_clipboard_fallback<C, F>(
text: &str,
restore_clipboard_after_paste: bool,
paste_shortcut: PasteShortcut,
commit_text: C,
clipboard_fallback: F,
) -> InsertStatus
where
C: FnOnce(&str) -> Result<(), String>,
F: FnOnce(&str, bool, PasteShortcut) -> InsertStatus,
{
if text.is_empty() {
return InsertStatus::CopiedFallback;
}
match commit_text(text) {
Ok(()) => InsertStatus::Inserted,
Err(err) => {
log::warn!(
"[insertion] fcitx commit_text failed, falling back to clipboard paste: {err}"
);
clipboard_fallback(text, restore_clipboard_after_paste, paste_shortcut)
}
}
}
#[cfg(target_os = "macos")]
fn macos_insert_status_after_paste(result: Result<(), String>) -> InsertStatus {
match result {
@@ -585,6 +615,81 @@ mod tests {
assert_eq!(inserter.copy_fallback(""), InsertStatus::CopiedFallback);
}
#[test]
#[cfg(target_os = "linux")]
fn linux_commit_text_success_skips_clipboard_fallback() {
let mut fallback_called = false;
let status = insert_with_fcitx_or_clipboard_fallback(
"dictated text",
true,
PasteShortcut::CtrlV,
|text| {
assert_eq!(text, "dictated text");
Ok(())
},
|_, _, _| {
fallback_called = true;
InsertStatus::CopiedFallback
},
);
assert_eq!(status, InsertStatus::Inserted);
assert!(!fallback_called);
}
#[test]
#[cfg(target_os = "linux")]
fn linux_commit_text_failure_uses_clipboard_fallback() {
let mut fallback_args = None;
let status = insert_with_fcitx_or_clipboard_fallback(
"dictated text",
true,
PasteShortcut::CtrlShiftV,
|_| Err("plugin unavailable".to_string()),
|text, restore_clipboard_after_paste, paste_shortcut| {
fallback_args = Some((
text.to_string(),
restore_clipboard_after_paste,
paste_shortcut,
));
InsertStatus::CopiedFallback
},
);
assert_eq!(status, InsertStatus::CopiedFallback);
assert_eq!(
fallback_args,
Some(("dictated text".to_string(), true, PasteShortcut::CtrlShiftV))
);
}
#[test]
#[cfg(target_os = "linux")]
fn linux_empty_insert_skips_commit_text_and_clipboard_fallback() {
let mut commit_called = false;
let mut fallback_called = false;
let status = insert_with_fcitx_or_clipboard_fallback(
"",
true,
PasteShortcut::CtrlV,
|_| {
commit_called = true;
Ok(())
},
|_, _, _| {
fallback_called = true;
InsertStatus::CopiedFallback
},
);
assert_eq!(status, InsertStatus::CopiedFallback);
assert!(!commit_called);
assert!(!fallback_called);
}
#[test]
#[cfg(not(target_os = "macos"))]
fn pending_clipboard_restore_keeps_first_original_until_latest_restore() {
+345 -22
View File
@@ -1,4 +1,7 @@
#![cfg_attr(target_os = "linux", allow(dead_code, unused_imports, unused_variables))]
#![cfg_attr(
target_os = "linux",
allow(dead_code, unused_imports, unused_variables)
)]
//! OpenLess Tauri backend.
//!
//! Modules mirror the original Swift libraries (one purpose per file):
@@ -14,6 +17,7 @@
mod asr;
mod audio_mute;
mod cli;
mod coding_agent;
mod combo_hotkey;
mod commands;
mod coordinator;
@@ -165,6 +169,13 @@ pub fn run() {
log::info!("[qa] qa 窗口未在 tauri.conf.json 中声明,前端 agent 会补上");
}
// Less Computer 语音 Agent 浮窗(macOS only)。启动时隐藏;coordinator
// 在 Less Computer 会话开始时再 show + 定位。非 macOS 上该窗口虽在
// tauri.conf.json 声明,但前端不渲染入口、后端不 emit,保持隐藏惰性。
if let Some(lc) = app.get_webview_window("less-computer") {
let _ = lc.hide();
}
// 主窗口磨砂:macOS 用 NSVisualEffectView,Windows 用 Mica。
// 没这一层的话 transparent: true 让窗口透明 → 背后只是空,不是磨砂。
//
@@ -384,6 +395,10 @@ pub fn run() {
commands::start_dictation,
commands::stop_dictation,
commands::cancel_dictation,
coding_agent::commands::coding_agent_detect,
coding_agent::commands::coding_agent_run_test,
coding_agent::commands::coding_agent_cancel_test,
coding_agent::commands::coding_agent_command_risk,
commands::handle_window_hotkey_event,
#[cfg(debug_assertions)]
commands::inject_hotkey_click_for_dev,
@@ -418,6 +433,9 @@ pub fn run() {
commands::set_open_app_hotkey,
commands::qa_window_dismiss,
commands::qa_window_pin,
commands::less_computer_window_dismiss,
commands::less_computer_window_resize,
commands::less_computer_approve,
commands::validate_combo_hotkey,
commands::set_combo_hotkey,
commands::validate_provider_credentials,
@@ -478,7 +496,6 @@ pub fn run() {
#[cfg(target_os = "windows")]
commands::sherpa_onnx_asr_reveal_model_dir,
commands::export_error_log,
commands::is_no_compositing_mode,
restart_app,
])
.build(tauri::generate_context!())
@@ -488,6 +505,8 @@ pub fn run() {
let coordinator = app.state::<Arc<coordinator::Coordinator>>();
// 同步启动 QA hotkey listener。和 dictation hotkey 平行,互不抢状态。
coordinator.start_qa_hotkey_listener();
// 启动「快速 Agent」双热键监听(功能默认关闭,启用后才注册)。
coordinator.start_coding_agent_hotkey_listener();
// 启动自定义组合键监听器。当 trigger == Custom 时替代 modifier-only 监听器。
coordinator.start_combo_hotkey_listener();
coordinator.start_translation_hotkey_listener();
@@ -509,6 +528,7 @@ pub fn run() {
let coordinator = app.state::<Arc<coordinator::Coordinator>>();
coordinator.stop_hotkey_listener();
coordinator.stop_qa_hotkey_listener();
coordinator.stop_coding_agent_hotkey_listener();
coordinator.stop_combo_hotkey_listener();
coordinator.stop_translation_hotkey_listener();
coordinator.stop_switch_style_hotkey_listener();
@@ -862,9 +882,7 @@ fn reset_tcc_service_for_beta_restart(service: &str) {
log::info!("[updater] reset TCC {service} before beta restart");
}
Ok(status) => {
log::warn!(
"[updater] reset TCC {service} before beta restart exited with {status}"
);
log::warn!("[updater] reset TCC {service} before beta restart exited with {status}");
}
Err(e) => {
log::warn!("[updater] reset TCC {service} before beta restart failed: {e}");
@@ -1149,6 +1167,53 @@ const QA_WINDOW_GAP_TO_CAPSULE: f64 = 8.0;
/// 给 macOS Dock 留的下边距(与 capsule 同源)。
const DOCK_BOTTOM_PADDING_FOR_QA: f64 = 80.0;
#[derive(Clone, Copy, Debug, PartialEq)]
struct LogicalMonitorFrame {
x: f64,
y: f64,
width: f64,
height: f64,
}
fn logical_monitor_frame(
physical_x: i32,
physical_y: i32,
physical_width: u32,
physical_height: u32,
scale: f64,
) -> LogicalMonitorFrame {
let scale = scale.max(0.1);
LogicalMonitorFrame {
x: physical_x as f64 / scale,
y: physical_y as f64 / scale,
width: physical_width as f64 / scale,
height: physical_height as f64 / scale,
}
}
fn bottom_center_position(
frame: LogicalMonitorFrame,
window_width: f64,
window_height: f64,
bottom_offset: f64,
) -> (f64, f64) {
let x = frame.x + ((frame.width - window_width) / 2.0).max(0.0);
let y = frame.y + (frame.height - bottom_offset - window_height).max(0.0);
(x, y)
}
fn bottom_visual_position(
frame: LogicalMonitorFrame,
window_width: f64,
visual_height: f64,
bottom_padding: f64,
bottom_inset: f64,
) -> (f64, f64) {
let x = frame.x + ((frame.width - window_width) / 2.0).max(0.0);
let y = frame.y + (frame.height - visual_height - bottom_padding - bottom_inset).max(0.0);
(x, y)
}
/// 把 QA 浮窗放到屏幕底部居中、紧贴胶囊上方。tauri 启动期 + show 之前都会调一次,
/// 防止用户切换显示器后位置错乱。
fn position_qa_window<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>) -> tauri::Result<()> {
@@ -1158,16 +1223,15 @@ fn position_qa_window<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>) -> ta
};
let scale = monitor.scale_factor();
let size = monitor.size();
let logical_w = size.width as f64 / scale;
let logical_h = size.height as f64 / scale;
let pos = monitor.position();
let frame = logical_monitor_frame(pos.x, pos.y, size.width, size.height, scale);
let capsule_height = capsule_height_for_qa();
let x = ((logical_w - QA_WINDOW_WIDTH) / 2.0).max(0.0);
let y = (logical_h
- DOCK_BOTTOM_PADDING_FOR_QA
- capsule_height
- QA_WINDOW_GAP_TO_CAPSULE
- QA_WINDOW_HEIGHT)
.max(0.0);
let (x, y) = bottom_center_position(
frame,
QA_WINDOW_WIDTH,
QA_WINDOW_HEIGHT,
DOCK_BOTTOM_PADDING_FOR_QA + capsule_height + QA_WINDOW_GAP_TO_CAPSULE,
);
window.set_size(tauri::LogicalSize::new(QA_WINDOW_WIDTH, QA_WINDOW_HEIGHT))?;
window.set_position(LogicalPosition::new(x, y))?;
Ok(())
@@ -1275,6 +1339,214 @@ pub(crate) fn hide_qa_window<R: tauri::Runtime>(app: &AppHandle<R>) {
}
}
// ───────────────────────── Less Computer 浮窗 ─────────────────────────
//
// Less Computer 语音 Agent 的聊天浮窗(窗口 label = "less-computer")。
// 仅 macOS:和 coordinator / 前端对 Less Computer 的 gating 一致(Windows/Linux
// 不注册热键、前端 detectOS 不渲染入口),所以这些窗口操作全部 `#[cfg(macos)]`,
// 其它平台是 no-op,避免在非目标平台动 NSWindow / 弹一个空浮窗。
/// Less Computer 浮窗宽度(高度由前端按内容自适应,经 `less_computer_window_resize`
/// 回传,Rust 端按 bottom-anchored 重新摆放,让内容增长向上撑开)。
#[cfg(target_os = "macos")]
const LESS_COMPUTER_WINDOW_WIDTH: f64 = 400.0;
#[cfg(target_os = "macos")]
const LESS_COMPUTER_WINDOW_MIN_HEIGHT: f64 = 120.0;
#[cfg(target_os = "macos")]
const LESS_COMPUTER_WINDOW_MAX_HEIGHT: f64 = 520.0;
/// 把 Less Computer 浮窗按给定高度(clamp 到 [min,max])摆到屏幕底部居中、
/// 紧贴胶囊上方。bottom 对齐胶囊顶部,所以高度变化时窗口向上生长。
#[cfg(target_os = "macos")]
fn position_less_computer_window<R: tauri::Runtime>(
window: &tauri::WebviewWindow<R>,
height: f64,
) -> tauri::Result<()> {
let monitor = match window.current_monitor()? {
Some(m) => m,
None => return Ok(()),
};
let scale = monitor.scale_factor();
let size = monitor.size();
let pos = monitor.position();
let frame = logical_monitor_frame(pos.x, pos.y, size.width, size.height, scale);
let height = height.clamp(
LESS_COMPUTER_WINDOW_MIN_HEIGHT,
LESS_COMPUTER_WINDOW_MAX_HEIGHT,
);
let capsule_height = capsule_height_for_qa();
let (x, y) = bottom_center_position(
frame,
LESS_COMPUTER_WINDOW_WIDTH,
height,
DOCK_BOTTOM_PADDING_FOR_QA + capsule_height + QA_WINDOW_GAP_TO_CAPSULE,
);
window.set_size(tauri::LogicalSize::new(LESS_COMPUTER_WINDOW_WIDTH, height))?;
window.set_position(LogicalPosition::new(x, y))?;
Ok(())
}
/// 显示 Less Computer 浮窗(不抢前台 app 焦点,与 QA 同手法)。`macos` 专用。
#[cfg(target_os = "macos")]
pub(crate) fn show_less_computer_window<R: tauri::Runtime>(app: &AppHandle<R>) {
let Some(window) = app.get_webview_window("less-computer") else {
log::info!("[less-computer] show 跳过:窗口不存在");
return;
};
if let Err(e) = position_less_computer_window(&window, LESS_COMPUTER_WINDOW_MIN_HEIGHT) {
log::warn!("[less-computer] position before show failed: {e}");
}
let window_clone = window.clone();
let _ = app.run_on_main_thread(move || {
use objc2::msg_send;
use objc2::runtime::AnyObject;
match window_clone.ns_window() {
Ok(handle) => {
let ns = handle as *mut AnyObject;
if ns.is_null() {
log::warn!("[less-computer] ns_window null; falling back to window.show()");
let _ = window_clone.show();
} else {
unsafe {
let _: () = msg_send![ns, orderFrontRegardless];
}
}
}
Err(e) => {
log::warn!("[less-computer] ns_window unavailable: {e}; falling back to show()");
let _ = window_clone.show();
}
}
});
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn show_less_computer_window<R: tauri::Runtime>(_app: &AppHandle<R>) {}
/// 隐藏 Less Computer 浮窗。供 dismiss 命令 / session 收尾共用。
#[cfg(target_os = "macos")]
pub(crate) fn hide_less_computer_window<R: tauri::Runtime>(app: &AppHandle<R>) {
if let Some(window) = app.get_webview_window("less-computer") {
let _ = window.hide();
}
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn hide_less_computer_window<R: tauri::Runtime>(_app: &AppHandle<R>) {}
/// 显示全屏彩虹描边浮层:盖满当前显示器、点击穿透、置顶。Agent 工作时点亮整屏边缘。
#[cfg(target_os = "macos")]
pub(crate) fn show_less_computer_glow<R: tauri::Runtime>(app: &AppHandle<R>) {
let Some(window) = app.get_webview_window("less-computer-glow") else {
return;
};
// 盖满当前(否则主)显示器,含菜单栏/Dock 区域。关键:用「逻辑坐标」(物理/缩放) ——
// Retina 上 monitor.size() 是物理像素(2x),直接 set_size 会把窗口铺成两倍、错位、不贴边。
let monitor = window
.current_monitor()
.ok()
.flatten()
.or_else(|| app.primary_monitor().ok().flatten());
if let Some(monitor) = monitor {
let scale = monitor.scale_factor();
let size = monitor.size();
let pos = monitor.position();
let _ = window.set_position(tauri::LogicalPosition::new(
pos.x as f64 / scale,
pos.y as f64 / scale,
));
let _ = window.set_size(tauri::LogicalSize::new(
size.width as f64 / scale,
size.height as f64 / scale,
));
}
// 点击穿透:纯视觉浮层,绝不拦截鼠标。
let _ = window.set_ignore_cursor_events(true);
let window_clone = window.clone();
let _ = app.run_on_main_thread(move || {
use objc2::msg_send;
use objc2::runtime::AnyObject;
match window_clone.ns_window() {
Ok(handle) => {
let ns = handle as *mut AnyObject;
if ns.is_null() {
let _ = window_clone.show();
} else {
unsafe {
// 抬到菜单栏(24)/Dock 之上,让描边能真正贴到屏幕最外缘(含顶部菜单栏区域)。
let _: () = msg_send![ns, setLevel: 25i64];
// 所有 Space 都显示、不参与窗口循环、全屏 app 上也叠加。
let _: () = msg_send![ns, setCollectionBehavior: 273u64];
let _: () = msg_send![ns, setIgnoresMouseEvents: true];
let _: () = msg_send![ns, orderFrontRegardless];
}
}
}
Err(_) => {
let _ = window_clone.show();
}
}
});
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn show_less_computer_glow<R: tauri::Runtime>(_app: &AppHandle<R>) {}
/// 隐藏全屏彩虹描边浮层。
#[cfg(target_os = "macos")]
pub(crate) fn hide_less_computer_glow<R: tauri::Runtime>(app: &AppHandle<R>) {
if let Some(window) = app.get_webview_window("less-computer-glow") {
let _ = window.hide();
}
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn hide_less_computer_glow<R: tauri::Runtime>(_app: &AppHandle<R>) {}
/// 前端按内容测高后回传。以「当前窗口底边」为锚向上生长——只改高度、保住用户拖动后的位置,
/// 不再重新居中(否则一改内容就把拖走的框拉回屏幕底部中间)。`macos` 专用。
#[cfg(target_os = "macos")]
pub(crate) fn resize_less_computer_window<R: tauri::Runtime>(app: &AppHandle<R>, height: f64) {
let Some(window) = app.get_webview_window("less-computer") else {
return;
};
let height = height.clamp(
LESS_COMPUTER_WINDOW_MIN_HEIGHT,
LESS_COMPUTER_WINDOW_MAX_HEIGHT,
);
let scale = window.scale_factor().unwrap_or(1.0);
match (window.outer_position(), window.outer_size()) {
(Ok(pos), Ok(size)) => {
let x = pos.x as f64 / scale;
let cur_top = pos.y as f64 / scale;
let cur_h = size.height as f64 / scale;
let bottom = cur_top + cur_h;
let monitor_top = window
.current_monitor()
.ok()
.flatten()
.map(|m| {
let p = m.position();
let s = m.size();
logical_monitor_frame(p.x, p.y, s.width, s.height, m.scale_factor()).y
})
.unwrap_or(f64::NEG_INFINITY);
let new_y = (bottom - height).max(monitor_top);
let _ = window.set_size(tauri::LogicalSize::new(LESS_COMPUTER_WINDOW_WIDTH, height));
let _ = window.set_position(tauri::LogicalPosition::new(x, new_y));
}
// 拿不到当前位置(极少见)→ 退回首屏居中摆放。
_ => {
if let Err(e) = position_less_computer_window(&window, height) {
log::warn!("[less-computer] resize fallback failed: {e}");
}
}
}
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn resize_less_computer_window<R: tauri::Runtime>(_app: &AppHandle<R>, _height: f64) {}
/// 抓完选区后把焦点重新交回 QA 浮窗(Windows focus-dance 下半场)。begin_qa_session
/// 在 capture_selection 跑完时调;非 Windows 平台是 no-op。issue #466。
#[cfg(target_os = "windows")]
@@ -1375,7 +1647,10 @@ pub(crate) fn position_capsule_bottom_center<R: tauri::Runtime>(
let scale = mon.scale;
let phys_w = (bounds.width * scale).round() as i32;
let phys_h = (bounds.height * scale).round() as i32;
window.set_size(PhysicalSize::new(phys_w.max(1) as u32, phys_h.max(1) as u32))?;
window.set_size(PhysicalSize::new(
phys_w.max(1) as u32,
phys_h.max(1) as u32,
))?;
let mon_w = mon.right - mon.left;
let x = mon.left + ((mon_w - phys_w) / 2).max(0);
@@ -1405,11 +1680,15 @@ pub(crate) fn position_capsule_bottom_center<R: tauri::Runtime>(
let scale = monitor.scale_factor();
let size = monitor.size();
let logical_w = size.width as f64 / scale;
let logical_h = size.height as f64 / scale;
let x = ((logical_w - bounds.width) / 2.0).max(0.0);
let y = (logical_h - capsule_visual_height(translation_active) - 80.0 - bounds.bottom_inset)
.max(0.0);
let pos = monitor.position();
let frame = logical_monitor_frame(pos.x, pos.y, size.width, size.height, scale);
let (x, y) = bottom_visual_position(
frame,
bounds.width,
capsule_visual_height(translation_active),
80.0,
bounds.bottom_inset,
);
window.set_position(LogicalPosition::new(x, y))?;
Ok(())
}
@@ -1467,9 +1746,10 @@ fn capsule_height_for_qa() -> f64 {
#[cfg(test)]
mod tests {
use super::{
capsule_height_for_qa, capsule_visual_height, capsule_window_bounds,
bottom_center_position, bottom_visual_position, capsule_height_for_qa,
capsule_visual_height, capsule_window_bounds, logical_monitor_frame,
parse_tray_polish_mode_id, rotate_log_if_too_large, tray_polish_mode_menu_entries,
tray_style_menu_enabled, LOG_ROTATE_LIMIT_BYTES,
tray_style_menu_enabled, LogicalMonitorFrame, LOG_ROTATE_LIMIT_BYTES,
};
use crate::types::PolishMode;
use std::io::Write;
@@ -1573,6 +1853,49 @@ mod tests {
assert_eq!(capsule_height_for_qa(), 96.0);
}
#[test]
fn logical_monitor_frame_preserves_negative_origin() {
let frame = logical_monitor_frame(-2560, 720, 5120, 2880, 2.0);
assert_eq!(
frame,
LogicalMonitorFrame {
x: -1280.0,
y: 360.0,
width: 2560.0,
height: 1440.0,
}
);
}
#[test]
fn bottom_center_position_keeps_window_on_left_monitor() {
let frame = LogicalMonitorFrame {
x: -1440.0,
y: 0.0,
width: 1440.0,
height: 900.0,
};
let pos = bottom_center_position(frame, 380.0, 440.0, 184.0);
assert_eq!(pos, (-910.0, 276.0));
}
#[test]
fn bottom_visual_position_keeps_capsule_on_upper_monitor() {
let frame = LogicalMonitorFrame {
x: 0.0,
y: -900.0,
width: 1440.0,
height: 900.0,
};
let pos = bottom_visual_position(frame, 220.0, 96.0, 80.0, 0.0);
assert_eq!(pos, (610.0, -176.0));
}
#[test]
fn oversized_log_rotates_to_single_archive() {
let dir = std::env::temp_dir().join(format!("openless-log-rotate-{}", std::process::id()));
+32 -29
View File
@@ -20,8 +20,8 @@ const TIMEOUT: Duration = Duration::from_secs(3);
///
/// 返回 `Ok(())` 表示文字已提交,`Err` 表示调用失败(插件未加载 / DBus 不通等)。
pub fn commit_text(text: &str) -> Result<(), String> {
let conn = dbus::blocking::Connection::new_session()
.map_err(|e| format!("dbus session: {e}"))?;
let conn =
dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?;
let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "CommitText")
.map_err(|e| format!("build msg: {e}"))?
.append1(text);
@@ -34,8 +34,8 @@ pub fn commit_text(text: &str) -> Result<(), String> {
///
/// `keys` 为 Key::parse 格式的字符串数组,例如 `["Control+space"]`。
pub fn set_hotkey(keys: &[&str]) -> Result<(), String> {
let conn = dbus::blocking::Connection::new_session()
.map_err(|e| format!("dbus session: {e}"))?;
let conn =
dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?;
let list: Vec<String> = keys.iter().map(|s| s.to_string()).collect();
let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetHotkey")
.map_err(|e| format!("build msg: {e}"))?
@@ -47,8 +47,8 @@ pub fn set_hotkey(keys: &[&str]) -> Result<(), String> {
/// 通过 fcitx5 插件直接设置 sym + states 作为触发键。
pub fn set_hotkey_raw(sym: u32, states: u32) -> Result<(), String> {
let conn = dbus::blocking::Connection::new_session()
.map_err(|e| format!("dbus session: {e}"))?;
let conn =
dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?;
let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetHotkeyRaw")
.map_err(|e| format!("build msg: {e}"))?
.append2(sym, states);
@@ -59,8 +59,8 @@ pub fn set_hotkey_raw(sym: u32, states: u32) -> Result<(), String> {
/// 通过 fcitx5 插件设置 QA 面板快捷键 sym + states。
pub fn set_qa_hotkey_raw(sym: u32, states: u32) -> Result<(), String> {
let conn = dbus::blocking::Connection::new_session()
.map_err(|e| format!("dbus session: {e}"))?;
let conn =
dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?;
let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetQaHotkeyRaw")
.map_err(|e| format!("build msg: {e}"))?
.append2(sym, states);
@@ -71,8 +71,8 @@ pub fn set_qa_hotkey_raw(sym: u32, states: u32) -> Result<(), String> {
/// 通过 fcitx5 插件设置翻译模式修饰键 sym + states。
pub fn set_translation_hotkey_raw(sym: u32, states: u32) -> Result<(), String> {
let conn = dbus::blocking::Connection::new_session()
.map_err(|e| format!("dbus session: {e}"))?;
let conn =
dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?;
let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetTranslationHotkeyRaw")
.map_err(|e| format!("build msg: {e}"))?
.append2(sym, states);
@@ -97,7 +97,9 @@ fn trigger_to_keysym(trigger: crate::types::HotkeyTrigger) -> u32 {
match trigger {
crate::types::HotkeyTrigger::RightControl => KEYSYM_CONTROL_R,
crate::types::HotkeyTrigger::LeftControl => KEYSYM_CONTROL_L,
crate::types::HotkeyTrigger::RightOption | crate::types::HotkeyTrigger::RightAlt => KEYSYM_ALT_R,
crate::types::HotkeyTrigger::RightOption | crate::types::HotkeyTrigger::RightAlt => {
KEYSYM_ALT_R
}
crate::types::HotkeyTrigger::LeftOption => KEYSYM_ALT_L,
crate::types::HotkeyTrigger::RightCommand => KEYSYM_SUPER_R,
crate::types::HotkeyTrigger::Fn => KEYSYM_CONTROL_R,
@@ -171,13 +173,11 @@ pub fn binding_to_fcitx_key_string(binding: &crate::types::ShortcutBinding) -> S
/// 通过 fcitx5 插件的 SetCustomDictationTrigger 方法设置自定义组合键。
pub fn set_custom_dictation_trigger(key_string: &str) -> Result<(), String> {
let conn = dbus::blocking::Connection::new_session()
.map_err(|e| format!("dbus session: {e}"))?;
let msg = dbus::Message::new_method_call(
DEST, PATH, IFACE, "SetCustomDictationTrigger",
)
.map_err(|e| format!("build msg: {e}"))?
.append1(key_string);
let conn =
dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?;
let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetCustomDictationTrigger")
.map_err(|e| format!("build msg: {e}"))?
.append1(key_string);
conn.send_with_reply_and_block(msg, TIMEOUT)
.map_err(|e| format!("SetCustomDictationTrigger: {e}"))?;
Ok(())
@@ -196,7 +196,9 @@ pub fn sync_qa_binding(trigger: Option<crate::types::HotkeyTrigger>) {
let sym = trigger_to_keysym(trigger);
let name = trigger_name(trigger);
match set_qa_hotkey_raw(sym, 0) {
Ok(()) => log::info!("[fcitx] Synced QA hotkey {name} (sym={sym}) to plugin via SetQaHotkeyRaw"),
Ok(()) => {
log::info!("[fcitx] Synced QA hotkey {name} (sym={sym}) to plugin via SetQaHotkeyRaw")
}
Err(e) => log::warn!("[fcitx] Failed to sync QA hotkey to plugin: {e}"),
}
}
@@ -220,8 +222,8 @@ pub fn sync_translation_binding(trigger: Option<crate::types::HotkeyTrigger>) {
/// 通过 fcitx5 插件在候选词列表下方显示状态文本(不干扰输入法预编辑)。
pub fn set_aux_down(text: &str) -> Result<(), String> {
let conn = dbus::blocking::Connection::new_session()
.map_err(|e| format!("dbus session: {e}"))?;
let conn =
dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?;
let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetAuxDown")
.map_err(|e| format!("build msg: {e}"))?
.append1(text);
@@ -232,8 +234,8 @@ pub fn set_aux_down(text: &str) -> Result<(), String> {
/// 清除 fcitx5 插件候选词列表下方状态文本。
pub fn clear_aux_down() -> Result<(), String> {
let conn = dbus::blocking::Connection::new_session()
.map_err(|e| format!("dbus session: {e}"))?;
let conn =
dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?;
let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "ClearAuxDown")
.map_err(|e| format!("build msg: {e}"))?;
conn.send_with_reply_and_block(msg, TIMEOUT)
@@ -425,8 +427,8 @@ pub fn ensure_plugin_installed(_app: &tauri::AppHandle) {
// fcitx5 在不同发行版的 lib 路径不同,同时支持用户 XDG 安装
let lib_dirs = [
"/usr/lib/x86_64-linux-gnu/fcitx5", // Debian multiarch
"/usr/lib64/fcitx5", // RPM 64-bit
"/usr/lib/fcitx5", // 通用回退
"/usr/lib64/fcitx5", // RPM 64-bit
"/usr/lib/fcitx5", // 通用回退
];
let system_conf = std::path::Path::new("/usr/share/fcitx5/addon/openless.conf");
@@ -442,9 +444,9 @@ pub fn ensure_plugin_installed(_app: &tauri::AppHandle) {
};
let conf_ok = user_conf.exists() || system_conf.exists();
let system_so_found = lib_dirs.iter().find(|dir| {
std::path::Path::new(dir).join("libopenless.so").exists()
});
let system_so_found = lib_dirs
.iter()
.find(|dir| std::path::Path::new(dir).join("libopenless.so").exists());
let so_ok = user_so.exists() || system_so_found.is_some();
// 用户手动安装过 ~/.local/ 版本,同时系统路径也有(deb 注入的)→
@@ -471,7 +473,8 @@ pub fn ensure_plugin_installed(_app: &tauri::AppHandle) {
log::warn!(
"[fcitx] fcitx5 plugin .so not found in {:?} or {:?}. \
The OpenLess package may be incomplete.",
lib_dirs, user_so
lib_dirs,
user_so
);
}
}
+15 -7
View File
@@ -423,10 +423,7 @@ fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> {
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let tmp_path = path.with_file_name(format!(
"{file_name}.tmp-{}",
Uuid::new_v4().simple()
));
let tmp_path = path.with_file_name(format!("{file_name}.tmp-{}", Uuid::new_v4().simple()));
fs::write(&tmp_path, contents)
.with_context(|| format!("write tmp failed: {}", tmp_path.display()))?;
if let Err(err) = fs::rename(&tmp_path, path) {
@@ -1217,9 +1214,19 @@ struct StylePackArchiveManifest {
compatible_app_version: Option<String>,
/// Marketplace 上游关系。旧 ZIP 没有此字段时自动为 None;
/// 兼容早期口误/拼写包里可能出现的 `orion*` 字段名。
#[serde(default, alias = "orionPackId", alias = "orion_pack_id", alias = "origin_pack_id")]
#[serde(
default,
alias = "orionPackId",
alias = "orion_pack_id",
alias = "origin_pack_id"
)]
origin_pack_id: Option<String>,
#[serde(default, alias = "orionAuthorLogin", alias = "orion_author_login", alias = "origin_author_login")]
#[serde(
default,
alias = "orionAuthorLogin",
alias = "orion_author_login",
alias = "origin_author_login"
)]
origin_author_login: Option<String>,
}
@@ -2413,7 +2420,8 @@ impl CredentialsVault {
mod tests {
use super::{
chunk_json_payload, list_vocab_presets, read_preferences, save_vocab_presets,
sync_style_pack_preferences, validate_correction_rule_syntax, KEYRING_CHUNK_MAX_UTF16_UNITS,
sync_style_pack_preferences, validate_correction_rule_syntax,
KEYRING_CHUNK_MAX_UTF16_UNITS,
};
use crate::types::{builtin_style_packs, CustomStylePrompts, VocabPreset, VocabPresetStore};
use std::fs;
+156 -8
View File
@@ -1508,7 +1508,11 @@ fn unix_now_secs() -> u64 {
}
fn apply_openai_compatible_thinking_control(body: &mut Value, config: &OpenAICompatibleConfig) {
match openai_compatible_thinking_control(&config.provider_id) {
// 优先按 provider_id 预设分派;custom / 未声明 provider 时回退到 base_url 兜底,
// 让用户用"自定义"preset 接入 MiniMax 也能正确下发 thinking 控制参数。
let control = openai_compatible_thinking_control(&config.provider_id)
.or_else(|| openai_compatible_thinking_control_for_base_url(&config.base_url));
match control {
Some(ThinkingControl::ReasoningEffort) => {
// OpenAI 官方 Chat Completions 只在推理模型族接受 reasoning_effort;
// 普通 chat 模型会直接 400。其它兼容渠道按渠道声明继续下发。
@@ -1540,6 +1544,17 @@ fn apply_openai_compatible_thinking_control(body: &mut Value, config: &OpenAICom
"type": if config.thinking_enabled { "enabled" } else { "disabled" },
});
}
// MiniMax OpenAI 兼容 Chat Completions 接受官方 `thinking` 字段,关闭用
// `disabled`、开启用 `adaptive`(不传即默认开启,这里显式发 `adaptive` 与
// 渠道文档保持一致)。schema 与 DeepSeekThinking 相同,仅取值字面量不同——
// 走独立变体避免 OpenLess 默认值(DeepSeek 写"enabled")污染 MiniMax 字段。
// 注:M2.x 系列不支持关闭,后端即便下发 `disabled` 服务端仍会保持开启;
// 这与 OpenLess 渠道级"按官方参数声明下发"的策略一致,不维护单模型白名单。
Some(ThinkingControl::MiniMaxThinking) => {
body["thinking"] = json!({
"type": if config.thinking_enabled { "adaptive" } else { "disabled" },
});
}
None => {}
}
}
@@ -1550,18 +1565,55 @@ enum ThinkingControl {
EnableThinking,
OpenRouterReasoning,
DeepSeekThinking,
MiniMaxThinking,
}
fn openai_compatible_thinking_control(provider_id: &str) -> Option<ThinkingControl> {
match provider_id.trim() {
"deepseek" => Some(ThinkingControl::DeepSeekThinking),
// provider_id 预设(见 ProvidersSection.tsx::LLM_PRESETS)。
"minimax" => Some(ThinkingControl::MiniMaxThinking),
"openrouterFree" => Some(ThinkingControl::OpenRouterReasoning),
"alibabaCoding" => Some(ThinkingControl::EnableThinking),
"openai" | "codingPlanX" => Some(ThinkingControl::ReasoningEffort),
// custom / 其他未声明 provider 走 base_url 兜底识别——用户用自定义
// endpoint 接入 MiniMax 时,根据 base_url 命中即下发官方 thinking 参数。
_ => None,
}
}
/// 当 provider_id 不在已知列表(典型场景:用户用"自定义"preset 接入)时,
/// 通过 base_url 推断该走哪种 thinking 控制策略。返回 `None` 表示无法
/// 识别,沿用原"不主动干预"行为。
///
/// 命中策略:base_url 主机名包含厂商关键字。
fn openai_compatible_thinking_control_for_base_url(base_url: &str) -> Option<ThinkingControl> {
// 抽 host(不区分大小写),允许带端口。`base_url` 末尾可能带 `/v1`、`/v1/`、
// 甚至 `/v1/chat/completions`——统一取第一个 `/` 段当 host。
let host = base_url
.trim()
.trim_end_matches('/')
.split_once("://")
.map(|(_, rest)| rest.split('/').next().unwrap_or(rest).to_ascii_lowercase())
.unwrap_or_default();
if host.is_empty() {
return None;
}
if host.contains("minimax") {
return Some(ThinkingControl::MiniMaxThinking);
}
if host.contains("deepseek") {
return Some(ThinkingControl::DeepSeekThinking);
}
if host.contains("openrouter") {
return Some(ThinkingControl::OpenRouterReasoning);
}
if host.contains("dashscope") || host.contains("aliyuncs") {
return Some(ThinkingControl::EnableThinking);
}
None
}
fn openai_chat_reasoning_effort(model: &str, thinking_enabled: bool) -> Option<&'static str> {
let normalized = model
.trim()
@@ -2826,6 +2878,89 @@ mod tests {
assert_eq!(body["thinking"]["type"], "disabled");
}
#[test]
fn openai_chat_body_disables_minimax_thinking_by_preset() {
// provider_id 预设命中 "minimax" → 走 MiniMaxThinking 分支,关闭时下发
// `thinking.type = "disabled"`,与 minimaxi 官方 Chat Completions 文档
// (https://platform.minimaxi.com/docs/api-reference/text-chat-openai#thinking-控制) 一致。
// 修这个 bug 前,provider_id 未命中时根本不下发 thinking 参数,UI 关闭无效。
let provider = OpenAICompatibleLLMProvider::new(
OpenAICompatibleConfig::new(
"minimax",
"MiniMax",
"https://api.minimaxi.com/v1",
"k",
"MiniMax-M3",
)
.with_thinking_enabled(false),
);
let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]);
assert_eq!(body["thinking"]["type"], "disabled");
}
#[test]
fn openai_chat_body_enables_minimax_thinking_with_adaptive_literal() {
// MiniMax 开启 thinking 必须用 `"adaptive"`,不是 DeepSeek 的 `"enabled"`。
// 若错发 `"enabled"`,M3 会落到未声明的 type 并报参数错误,反而失去思考。
let provider = OpenAICompatibleLLMProvider::new(
OpenAICompatibleConfig::new(
"minimax",
"MiniMax",
"https://api.minimaxi.com/v1",
"k",
"MiniMax-M3",
)
.with_thinking_enabled(true),
);
let body = provider.chat_body(true, vec![json!({ "role": "user", "content": "hi" })]);
assert_eq!(body["thinking"]["type"], "adaptive");
}
#[test]
fn openai_chat_body_falls_back_to_base_url_for_custom_minimax_endpoint() {
// 用 "custom" preset + 自定义 MiniMax base_url 接入时,base_url 兜底
// 识别需要命中"minimax"关键字,下发 thinking 控制参数。
let provider = OpenAICompatibleLLMProvider::new(
OpenAICompatibleConfig::new(
"custom",
"Custom",
"https://api.minimaxi.com/v1",
"k",
"MiniMax-M3",
)
.with_thinking_enabled(false),
);
let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]);
assert_eq!(body["thinking"]["type"], "disabled");
}
#[test]
fn openai_chat_body_base_url_fallback_respects_trailing_slash_and_path() {
// base_url 可能带尾斜杠或带 /v1 后缀,host 提取逻辑都要能正确识别。
for base_url in [
"https://api.minimaxi.com/v1",
"https://api.minimaxi.com/v1/",
"https://api.minimaxi.com",
"https://api.minimaxi.com/",
] {
let provider = OpenAICompatibleLLMProvider::new(
OpenAICompatibleConfig::new("custom", "Custom", base_url, "k", "MiniMax-M3")
.with_thinking_enabled(false),
);
let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]);
assert_eq!(
body["thinking"]["type"], "disabled",
"base_url={base_url} should trigger MiniMax thinking control"
);
}
}
#[test]
fn openai_chat_body_omits_thinking_control_for_unknown_provider() {
let provider = OpenAICompatibleLLMProvider::new(
@@ -2967,11 +3102,19 @@ mod tests {
);
// v2 PRO 自带 prompt 必须共享:四/五、ASR 纠错段 + 高/低置信度分级 + 根目录词条。
for mode in [PolishMode::Light, PolishMode::Structured, PolishMode::Formal] {
for mode in [
PolishMode::Light,
PolishMode::Structured,
PolishMode::Formal,
] {
let prompt = prompts::system_prompt(mode);
let has_asr_heading = prompt.contains("# 四、ASR 纠错") || prompt.contains("# 五、ASR 纠错");
let has_asr_heading =
prompt.contains("# 四、ASR 纠错") || prompt.contains("# 五、ASR 纠错");
assert!(has_asr_heading, "{mode:?} prompt 缺少 v2 自带 ASR 纠错段落");
assert!(prompt.contains("根目录"), "{mode:?} prompt 缺少根目录纠错示例");
assert!(
prompt.contains("根目录"),
"{mode:?} prompt 缺少根目录纠错示例"
);
assert!(
prompt.contains("**高置信度**") && prompt.contains("**低置信度**"),
"{mode:?} prompt 缺少分级置信度策略"
@@ -2983,8 +3126,14 @@ mod tests {
fn translate_prompt_swaps_to_en_dedicated_when_target_is_english() {
// 英文目标:整段切到 EN_TRANSLATE_SYSTEM_PROMPT,不再带通用 base 的 \"# 任务(翻译输出)\" 标题。
let en = prompts::translate_system_prompt("English");
assert!(en.contains("# 任务(中文转写 → 英文翻译)"), "English target 必须使用 EN 专用 prompt");
assert!(!en.contains("# 任务(翻译输出)"), "English target 不应再带通用 base 标题");
assert!(
en.contains("# 任务(中文转写 → 英文翻译)"),
"English target 必须使用 EN 专用 prompt"
);
assert!(
!en.contains("# 任务(翻译输出)"),
"English target 不应再带通用 base 标题"
);
assert!(en.contains("# 工作流程"));
assert!(en.contains("# 中→英术语规范化"));
assert!(en.contains("# 翻译要求"));
@@ -3005,8 +3154,7 @@ mod tests {
// 别名容忍:'美式英文' / '英文' / 'english' / 'British English' 都走 EN 专用 prompt。
for alias in ["美式英文", "英文", "english", "British English"] {
assert!(
prompts::translate_system_prompt(alias)
.contains("# 任务(中文转写 → 英文翻译)"),
prompts::translate_system_prompt(alias).contains("# 任务(中文转写 → 英文翻译)"),
"alias '{alias}' should resolve to English target"
);
}
+4 -1
View File
@@ -1,4 +1,7 @@
#![cfg_attr(target_os = "linux", allow(dead_code, unused_imports, unused_variables))]
#![cfg_attr(
target_os = "linux",
allow(dead_code, unused_imports, unused_variables)
)]
//! 跨平台「划词捕获」工具:在用户触发 QA 快捷键时尝试拿到当前前台 app 的选区文本。
//!
//! 三级 fallback:
+95 -1
View File
@@ -611,6 +611,31 @@ pub struct UserPreferences {
/// 「唤起 App」全局快捷键。`None` = 停用;`Some(...)` = 注册。默认 `Some(默认键)`。
#[serde(default = "default_open_app_hotkey")]
pub open_app_hotkey: Option<ShortcutBinding>,
/// Less Computer:是否启用。默认关闭,需用户在高级设置开启。
#[serde(default)]
pub coding_agent_enabled: bool,
/// Agent 后端:`claude-code-cli`(默认)或 `opencode-cli`。
#[serde(default = "default_coding_agent_provider")]
pub coding_agent_provider: String,
/// Agent 模型(`None` = 运行时取便宜默认 sonnet)。
#[serde(default)]
pub coding_agent_model: Option<String>,
/// 权限模式:plan/default/acceptEdits/bypassPermissions。默认 acceptEdits(放行+护栏)。
#[serde(default = "default_coding_agent_permission_mode")]
pub coding_agent_permission_mode: String,
/// Agent 工作目录(`None` = 临时目录)。
#[serde(default)]
pub coding_agent_workdir: Option<String>,
/// Less Computer 语音触发键。macOS 生效;支持单修饰键(左/右 Control、左/右 Option、Fn)
/// 和普通组合键。`None` = 停用。
#[serde(default = "default_coding_agent_voice_hotkey")]
pub coding_agent_voice_hotkey: Option<ShortcutBinding>,
/// 热键 1:语音 Agent 面板键。默认 Cmd/Ctrl+Shift+Enter。`None` = 停用。
#[serde(default = "default_coding_agent_panel_hotkey")]
pub coding_agent_panel_hotkey: Option<ShortcutBinding>,
/// 热键 2:快取用键(选中→Claude→回插)。默认 `None`(用户自配)。
#[serde(default)]
pub coding_agent_quick_hotkey: Option<ShortcutBinding>,
/// 本地 Qwen3-ASR 当前激活的模型 id("qwen3-asr-0.6b" / "qwen3-asr-1.7b")。
/// 仅在 active_asr_provider == "local-qwen3" 时有意义。
#[serde(default = "default_local_asr_model")]
@@ -830,6 +855,22 @@ struct UserPreferencesWire {
translation_hotkey: Option<ShortcutBinding>,
switch_style_hotkey: Option<ShortcutBinding>,
open_app_hotkey: Option<ShortcutBinding>,
#[serde(default)]
coding_agent_enabled: bool,
#[serde(default = "default_coding_agent_provider")]
coding_agent_provider: String,
#[serde(default)]
coding_agent_model: Option<String>,
#[serde(default = "default_coding_agent_permission_mode")]
coding_agent_permission_mode: String,
#[serde(default)]
coding_agent_workdir: Option<String>,
#[serde(default = "default_coding_agent_voice_hotkey")]
coding_agent_voice_hotkey: Option<ShortcutBinding>,
#[serde(default = "default_coding_agent_panel_hotkey")]
coding_agent_panel_hotkey: Option<ShortcutBinding>,
#[serde(default)]
coding_agent_quick_hotkey: Option<ShortcutBinding>,
#[serde(default = "default_local_asr_model")]
local_asr_active_model: String,
#[serde(default = "default_local_asr_mirror")]
@@ -921,6 +962,14 @@ impl Default for UserPreferencesWire {
// 默认携带默认键(Some),保证缺字段时仍是启用状态;None 专表「用户主动停用」。
switch_style_hotkey: prefs.switch_style_hotkey,
open_app_hotkey: prefs.open_app_hotkey,
coding_agent_enabled: prefs.coding_agent_enabled,
coding_agent_provider: prefs.coding_agent_provider,
coding_agent_model: prefs.coding_agent_model,
coding_agent_permission_mode: prefs.coding_agent_permission_mode,
coding_agent_workdir: prefs.coding_agent_workdir,
coding_agent_voice_hotkey: prefs.coding_agent_voice_hotkey,
coding_agent_panel_hotkey: prefs.coding_agent_panel_hotkey,
coding_agent_quick_hotkey: prefs.coding_agent_quick_hotkey,
local_asr_active_model: prefs.local_asr_active_model,
local_asr_mirror: prefs.local_asr_mirror,
local_asr_keep_loaded_secs: prefs.local_asr_keep_loaded_secs,
@@ -1001,6 +1050,14 @@ impl<'de> Deserialize<'de> for UserPreferences {
output_language_preference: wire.output_language_preference,
qa_hotkey: wire.qa_hotkey,
qa_save_history: wire.qa_save_history,
coding_agent_enabled: wire.coding_agent_enabled,
coding_agent_provider: wire.coding_agent_provider,
coding_agent_model: wire.coding_agent_model,
coding_agent_permission_mode: wire.coding_agent_permission_mode,
coding_agent_workdir: wire.coding_agent_workdir,
coding_agent_voice_hotkey: wire.coding_agent_voice_hotkey,
coding_agent_panel_hotkey: wire.coding_agent_panel_hotkey,
coding_agent_quick_hotkey: wire.coding_agent_quick_hotkey,
custom_combo_hotkey: wire.custom_combo_hotkey,
translation_hotkey: wire
.translation_hotkey
@@ -1049,6 +1106,28 @@ fn default_qa_hotkey() -> Option<ShortcutBinding> {
Some(ShortcutBinding::default_qa())
}
fn default_coding_agent_provider() -> String {
"claude-code-cli".to_string()
}
fn default_coding_agent_permission_mode() -> String {
"acceptEdits".to_string()
}
pub(crate) fn default_coding_agent_voice_hotkey() -> Option<ShortcutBinding> {
Some(ShortcutBinding {
primary: "LeftControl".into(),
modifiers: Vec::new(),
})
}
pub(crate) fn default_coding_agent_panel_hotkey() -> Option<ShortcutBinding> {
Some(ShortcutBinding {
primary: "Enter".into(),
modifiers: vec!["cmd".into(), "shift".into()],
})
}
fn default_translation_hotkey() -> ShortcutBinding {
ShortcutBinding {
primary: "Shift".into(),
@@ -1707,6 +1786,14 @@ impl Default for UserPreferences {
translation_hotkey: default_translation_hotkey(),
switch_style_hotkey: default_switch_style_hotkey(),
open_app_hotkey: default_open_app_hotkey(),
coding_agent_enabled: false,
coding_agent_provider: default_coding_agent_provider(),
coding_agent_model: None,
coding_agent_permission_mode: default_coding_agent_permission_mode(),
coding_agent_workdir: None,
coding_agent_voice_hotkey: default_coding_agent_voice_hotkey(),
coding_agent_panel_hotkey: default_coding_agent_panel_hotkey(),
coding_agent_quick_hotkey: None,
local_asr_active_model: default_local_asr_model(),
local_asr_mirror: default_local_asr_mirror(),
local_asr_keep_loaded_secs: default_local_asr_keep_loaded_secs(),
@@ -2286,6 +2373,10 @@ pub struct CapsulePayload {
/// 当前 session 是否处于翻译模式(用户按过 Shift)。前端用它在胶囊顶部
/// 渲染"正在翻译"标签,让用户立刻知道这次输出会走翻译管线。详见 issue #4。
pub translation: bool,
/// 当前是否是 Less Computer(语音 Agent 操控电脑)会话。前端据此把处理态文案
/// 从 "thinking" 换成 "using"——告诉用户 Agent 正在操作电脑而非单纯思考。
#[serde(default)]
pub operating: bool,
}
/// Snapshot of credentials read from vault — only what the UI needs to know
@@ -2410,7 +2501,10 @@ mod tests {
.unwrap();
let binding = prefs.switch_style_hotkey.expect("应保留为 Some");
assert_eq!(binding.primary, "S");
assert_eq!(binding.modifiers, vec!["cmd".to_string(), "shift".to_string()]);
assert_eq!(
binding.modifiers,
vec!["cmd".to_string(), "shift".to_string()]
);
}
#[test]
@@ -63,6 +63,39 @@
"visible": false,
"center": false,
"acceptFirstMouse": true
},
{
"label": "less-computer",
"url": "index.html?window=less-computer",
"title": "OpenLess Less Computer",
"width": 400,
"height": 200,
"decorations": false,
"transparent": true,
"shadow": true,
"alwaysOnTop": true,
"skipTaskbar": true,
"resizable": false,
"focus": false,
"visible": false,
"center": false,
"acceptFirstMouse": true
},
{
"label": "less-computer-glow",
"url": "index.html?window=less-computer-glow",
"title": "OpenLess Less Computer Glow",
"width": 800,
"height": 600,
"decorations": false,
"transparent": true,
"shadow": false,
"alwaysOnTop": true,
"skipTaskbar": true,
"resizable": false,
"focus": false,
"visible": false,
"center": false
}
],
"security": {
@@ -11,7 +11,7 @@
"minHeight": 640,
"resizable": true,
"decorations": false,
"transparent": true,
"transparent": false,
"shadow": true,
"visible": false,
"acceptFirstMouse": true
@@ -23,7 +23,7 @@
"width": 220,
"height": 110,
"decorations": false,
"transparent": true,
"transparent": false,
"shadow": false,
"alwaysOnTop": true,
"skipTaskbar": true,
@@ -40,7 +40,7 @@
"width": 380,
"height": 440,
"decorations": false,
"transparent": true,
"transparent": false,
"shadow": true,
"alwaysOnTop": true,
"skipTaskbar": true,
+22 -15
View File
@@ -17,29 +17,49 @@ import {
windowMouseHotkeyCode,
} from './lib/windowHotkeyFallback';
import { QaPanel } from './pages/QaPanel';
import { invoke } from '@tauri-apps/api/core';
import { LessComputerPanel } from './pages/LessComputerPanel';
import { LessComputerGlow } from './pages/LessComputerGlow';
import { HotkeySettingsProvider } from './state/HotkeySettingsContext';
import { APP_THEME_KEY, applyAppTheme, readAppTheme } from './lib/appTheme';
interface AppProps {
isCapsule: boolean;
isQa: boolean;
isLessComputer: boolean;
isLessComputerGlow: boolean;
forcedOs?: OS | null;
}
type Gate = 'onboarding' | 'ready';
export function App({ isCapsule, isQa, forcedOs }: AppProps) {
export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, forcedOs }: AppProps) {
if (isCapsule) {
return <Capsule />;
}
if (isQa) {
return <QaPanel />;
}
if (isLessComputer) {
return <LessComputerPanel />;
}
if (isLessComputerGlow) {
return <LessComputerGlow />;
}
const os = forcedOs ?? detectOS();
// Windows 启动不应被权限探测阻塞首屏。
const [gate, setGate] = useState<Gate>('ready');
useEffect(() => {
applyAppTheme(readAppTheme());
const syncTheme = (event: StorageEvent) => {
if (event.key !== APP_THEME_KEY) return;
applyAppTheme(readAppTheme());
};
window.addEventListener('storage', syncTheme);
return () => window.removeEventListener('storage', syncTheme);
}, []);
useEffect(() => {
if (!isTauri) return;
let cancelled = false;
@@ -169,18 +189,6 @@ export function App({ isCapsule, isQa, forcedOs }: AppProps) {
};
}, [os]);
// Linux: 检测 WEBKIT_DISABLE_COMPOSITING_MODE → 触发磨砂 fallback(#570, #553)
useEffect(() => {
if (!isTauri) return;
invoke<boolean>('is_no_compositing_mode').then((val) => {
if (val) {
document.documentElement.dataset.olNoCompositing = 'true';
}
}).catch((err) => {
console.warn('[startup] is_no_compositing_mode failed', err);
});
}, []);
return (
<HotkeySettingsProvider>
{gate === 'onboarding' ? <Onboarding onComplete={() => setGate('ready')} /> : <FloatingShell os={os} />}
@@ -188,4 +196,3 @@ export function App({ isCapsule, isQa, forcedOs }: AppProps) {
</HotkeySettingsProvider>
);
}
@@ -0,0 +1,81 @@
// 录音提示音:监听 capsule:state 事件,在"开始录音"边沿播放合成提示音。
// 独立组件,不依赖胶囊窗口显示——Linux 上胶囊隐藏也能正常工作。
// 全平台通用,在 FloatingShellBody 中渲染。
import { useEffect, useRef } from 'react';
import { isTauri } from '../lib/ipc';
import { playRecordStartCue, primeAudioCue, stopAudioCue } from '../lib/audioCue';
import type { CapsuleState, UserPreferences } from '../lib/types';
interface CapsulePayload {
state: CapsuleState;
level?: number;
message?: string | null;
insertedChars?: number | null;
translation?: boolean;
}
export function AudioCueListener() {
const audioCueEnabledRef = useRef<boolean>(true);
const prevStateRef = useRef<CapsuleState>('idle' as CapsuleState);
// 读取设置(默认开启)
useEffect(() => {
if (!isTauri) return;
let cancelled = false;
(async () => {
try {
const { getSettings } = await import('../lib/ipc');
const prefs = await getSettings();
if (!cancelled) audioCueEnabledRef.current = prefs.audioCueOnRecord !== false;
} catch {
// 读取失败保持默认 true
}
})();
return () => { cancelled = true; };
}, []);
// 监听设置变更
useEffect(() => {
if (!isTauri) return;
let unlisten: (() => void) | undefined;
let cancelled = false;
(async () => {
const { listen } = await import('@tauri-apps/api/event');
listen<UserPreferences>('prefs:changed', (event) => {
const next = event.payload;
if (next) audioCueEnabledRef.current = next.audioCueOnRecord !== false;
}).then(fn => { if (!cancelled) unlisten = fn; }).catch(() => {});
})();
return () => { cancelled = true; unlisten?.(); };
}, []);
// 预热 AudioContext
useEffect(() => {
if (!isTauri) return;
primeAudioCue();
}, []);
// 监听 capsule 状态边沿
useEffect(() => {
if (!isTauri) return;
let unlisten: (() => void) | undefined;
let cancelled = false;
(async () => {
const { listen } = await import('@tauri-apps/api/event');
listen<CapsulePayload>('capsule:state', (event) => {
const state = event.payload.state;
const prev = prevStateRef.current;
prevStateRef.current = state;
if (state === 'recording' && prev !== 'recording') {
if (audioCueEnabledRef.current) playRecordStartCue();
} else if (state !== 'recording' && prev === 'recording') {
stopAudioCue();
}
}).then(fn => { if (!cancelled) unlisten = fn; }).catch(() => {});
})();
return () => { cancelled = true; unlisten?.(); };
}, []);
return null;
}
+17 -63
View File
@@ -6,9 +6,8 @@ import {
getCapsuleMessageLayout,
getCapsulePillMetrics,
} from '../lib/capsuleLayout';
import { getSettings, invokeOrMock, isTauri } from '../lib/ipc';
import { playRecordStartCue, primeAudioCue, stopAudioCue } from '../lib/audioCue';
import type { CapsulePayload, CapsuleState, UserPreferences } from '../lib/types';
import { invokeOrMock, isTauri } from '../lib/ipc';
import type { CapsulePayload, CapsuleState } from '../lib/types';
interface AudioBarsProps {
level: number;
@@ -105,6 +104,10 @@ function CircleButton({ variant, enabled, onClick }: CircleButtonProps) {
return (
<button
onClick={enabled ? onClick : undefined}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
aria-label={isCancel ? t('common.cancel') : t('settings.shortcuts.confirm')}
disabled={!enabled}
style={{
@@ -147,15 +150,17 @@ interface PillProps {
level: number;
insertedChars: number;
message?: string;
operating?: boolean;
onCancel: () => void;
onConfirm: () => void;
}
function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }: PillProps) {
function Pill({ os, state, level, insertedChars, message, operating, onCancel, onConfirm }: PillProps) {
const { t } = useTranslation();
const metrics = getCapsulePillMetrics(os);
const processingLayout = getCapsuleMessageLayout(os, 'processing');
const enabled = state === 'recording';
const cancelEnabled = state === 'recording' || state === 'transcribing' || state === 'polishing';
const confirmEnabled = state === 'recording';
// "thinking" 扫光速度:进入 transcribing/polishing 的头 2 秒走快速(0.9s/cycle,提示
// 「流式刚开始」),之后切回慢速(2.4s)作为稳态。切回 idle / done / 其他 state 也复位
@@ -224,7 +229,7 @@ function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }:
WebkitLineClamp: processingLayout.lineClamp,
}}
>
{t('capsule.thinking')}
{t(operating ? 'capsule.using' : 'capsule.thinking')}
</span>
</div>
);
@@ -247,7 +252,7 @@ function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }:
const shadowAlpha = 0.20 + ambient * 0.10;
return (
// 假毛玻璃:半透明白底 + .ol-frost 噪点纹理 + 内描边高光 + 柔和阴影。
// 非 Linux 走假毛玻璃;Linux 禁用透明窗口后由 .ol-frost 平台规则退成不透明面。
// 不写 backdrop-filter —— webview 模糊不了透明窗口背后的桌面(Tauri 上游限制)。
<div
className="ol-frost"
@@ -273,11 +278,11 @@ function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }:
willChange: 'transform, box-shadow',
}}
>
<CircleButton variant="cancel" enabled={enabled} onClick={onCancel} />
<CircleButton variant="cancel" enabled={cancelEnabled} onClick={onCancel} />
<div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{center}
</div>
<CircleButton variant="confirm" enabled={enabled} onClick={onConfirm} />
<CircleButton variant="confirm" enabled={confirmEnabled} onClick={onConfirm} />
</div>
);
}
@@ -302,6 +307,7 @@ export function Capsule() {
const [insertedChars, setInsertedChars] = useState<number>(0);
const [message, setMessage] = useState<string | undefined>();
const [translation, setTranslation] = useState<boolean>(false);
const [operating, setOperating] = useState<boolean>(false);
// `leaving` 与 `lastVisibleState` 协同实现「退出动画」:
// - 当 state 从非 idle 变成 idle 时,不立即卸载,而是把 leaving 置为 true 并保留
// 最后一帧的可见 state(lastVisibleState),让胶囊用 capsule-out 动画收缩淡出。
@@ -311,10 +317,6 @@ export function Capsule() {
const [lastVisibleState, setLastVisibleState] = useState<CapsuleState>(INITIAL_VISIBLE_STATE);
// Windows 端 host 在翻译模式从 84 长到 118;macOS / Linux 上 capsuleLayout 已固定 42 忽略此参数。
const hostMetrics = getCapsuleHostMetrics(os, translation);
// 录音提示音:是否开启(默认 true,老配置缺字段也按开启)+ 上一帧 capsule 状态,
// 用于检测「进入 recording」这条边沿。用 ref 而非 state:提示音是副作用,不该触发重渲染。
const audioCueEnabledRef = useRef<boolean>(true);
const prevStateRef = useRef<CapsuleState>(INITIAL_VISIBLE_STATE);
useEffect(() => {
if (!isTauri) return;
@@ -335,6 +337,7 @@ export function Capsule() {
setMessage(p.message ?? undefined);
if (p.insertedChars != null) setInsertedChars(p.insertedChars);
setTranslation(p.translation === true);
setOperating(p.operating === true);
});
if (cancelled) handle();
else unlisten = handle;
@@ -345,56 +348,6 @@ export function Capsule() {
};
}, []);
// 读取「录音提示音」开关并跟随设置实时更新:capsule 窗口不在 HotkeySettingsProvider 下,
// 所以这里自己拉一次 getSettings(),再订阅 prefs:changed 保持同步。缺字段按默认开启。
useEffect(() => {
if (!isTauri) return;
let cancelled = false;
let unlisten: (() => void) | undefined;
(async () => {
try {
const prefs = await getSettings();
if (!cancelled) audioCueEnabledRef.current = prefs.audioCueOnRecord !== false;
} catch (err) {
console.warn('[capsule] read audioCueOnRecord failed; default on', err);
}
const { listen } = await import('@tauri-apps/api/event');
const handle = await listen<UserPreferences>('prefs:changed', event => {
const next = event.payload;
if (next) audioCueEnabledRef.current = next.audioCueOnRecord !== false;
});
if (cancelled) handle();
else unlisten = handle;
})().catch(err => {
// import / listen 早期失败(Tauri IPC 尚未就绪)不能变成 unhandled rejection。
console.warn('[capsule] audio-cue prefs listener init failed', err);
});
return () => {
cancelled = true;
if (unlisten) unlisten();
};
}, []);
// 预热 AudioContext:胶囊挂载时就 resume,让录音开始时提示音能同步播放,
// 避免 suspended→resume 的异步竞态在「快速录音」时整段丢音(提示音偶尔消失的根因)。
useEffect(() => {
if (!isTauri) return;
primeAudioCue();
}, []);
// 提示音触发:检测 capsule 状态进入 recording 的边沿就播放(提醒「已开始录音」);
// 离开 recording 则停掉,避免连按热键时残留尾音。独立于 showCapsule —— 胶囊隐藏也会响。
useEffect(() => {
const prev = prevStateRef.current;
prevStateRef.current = state;
if (!isTauri) return;
if (state === 'recording' && prev !== 'recording') {
if (audioCueEnabledRef.current) playRecordStartCue();
} else if (state !== 'recording' && prev === 'recording') {
stopAudioCue();
}
}, [state]);
// 退出动画调度:在 state 真正进入 idle 时,先用 capsule-out 播放 EXIT_ANIM_MS,再卸载。
// 设计要点:
// 1. 进入非 idle:清掉 leaving,记录最新可见 state;
@@ -521,6 +474,7 @@ export function Capsule() {
level={leaving ? 0 : level}
insertedChars={insertedChars}
message={message}
operating={operating}
onCancel={onCancel}
onConfirm={onConfirm}
/>
+130 -70
View File
@@ -8,6 +8,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentTy
import { useTranslation } from 'react-i18next';
import { Icon } from './Icon';
import { WindowChrome, detectOS, type OS } from './WindowChrome';
import { AudioCueListener } from "./AudioCue";
import { SettingsModal } from './SettingsModal';
import { Overview } from '../pages/Overview';
import { History } from '../pages/History';
@@ -180,7 +181,10 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
};
return (
<div style={{ flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', minHeight: 0, paddingTop: os === 'mac' ? 28 : 0 }}>
<div
className="ol-app-shell-bg"
style={{ flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', minHeight: 0, paddingTop: os === 'mac' ? 28 : 0 }}
>
{/* Main shell — flush with the frosted backplate (no separate float). */}
<div
@@ -195,22 +199,26 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
{/* Sidebar — 透明地坐在外层磨砂底板上,让 LOGO/导航/快捷键/BETA/footer 共用同一片磨砂玻璃 */}
<aside
className="ol-aura-sidebar"
style={{
width: 188,
width: 196,
flexShrink: 0,
display: 'flex', flexDirection: 'column',
background: 'transparent',
padding: '10px 10px 12px',
display: 'flex',
flexDirection: 'column',
}}>
{/* brand */}
<div style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '2px 8px 12px' }}>
<div className="ol-aura-sidebar-brand">
<img
className="ol-aura-sidebar-brand-mark"
src="AppIcon.png"
alt="OpenLess"
style={{ width: 22, height: 22, borderRadius: 5, boxShadow: '0 1px 2px rgba(0,0,0,.1), 0 0 0 0.5px rgba(0,0,0,.06)' }} />
/>
<div style={{ fontSize: 13.5, fontWeight: 600, letterSpacing: '-0.01em', color: 'var(--ol-ink)' }}>OpenLess</div>
<div>
<div className="ol-aura-sidebar-brand-title">OpenLess</div>
<div className="ol-aura-sidebar-brand-kicker">VOICE CONSOLE</div>
</div>
</div>
{/* nav — 滑动指示器:active pill 是 absolute 元素,currentTab 改变时 top/height
@@ -219,6 +227,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
<nav style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 1 }}>
{pillRect && (
<div
className="ol-aura-sidebar-pill"
aria-hidden
style={{
position: 'absolute',
@@ -226,9 +235,6 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
right: 0,
top: pillRect.top,
height: pillRect.height,
background: 'var(--ol-surface)',
borderRadius: 8,
boxShadow: '0 1px 2px rgba(0,0,0,.05), 0 0 0 0.5px rgba(0,0,0,.06)',
transition: 'top 0.36s var(--ol-motion-spring), height 0.36s var(--ol-motion-spring)',
pointerEvents: 'none',
zIndex: 0,
@@ -242,18 +248,9 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
key={n.id}
ref={el => { navItemRefs.current[i] = el; }}
onClick={() => setCurrentTab(n.id)}
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
className={active ? 'ol-nav-btn ol-nav-btn-active ol-aura-sidebar-nav-btn' : 'ol-nav-btn ol-aura-sidebar-nav-btn'}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '7px 10px',
borderRadius: 8, border: 0,
background: 'transparent',
fontFamily: 'inherit', fontSize: 13,
cursor: 'default',
transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)',
textAlign: 'left',
position: 'relative',
zIndex: 1,
}}>
<Icon name={n.icon} size={14} />
@@ -268,31 +265,10 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
{/* 底部两行:上行 = 版本 chip(含 BETA 标),下行 = 设置按钮。
单行布局在窄 sidebar 下会把「设置」挤成两行竖字 + 版本糊一起;
翻回两行同时把顺序反过来:设置真正落到最底,版本在它上面。 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingTop: 10 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
padding: '0 10px',
fontFamily: 'var(--ol-font-sans)',
fontSize: 11,
color: 'var(--ol-ink-4)',
}}
>
<div className="ol-aura-sidebar-footer">
<div className="ol-aura-sidebar-version">
{IS_BETA_BUILD && (
<span style={{
display: 'inline-block',
padding: '2px 8px',
fontSize: 10,
fontWeight: 600,
letterSpacing: '0.04em',
textTransform: 'uppercase',
color: 'var(--ol-blue)',
background: 'rgba(37,99,235,0.10)',
borderRadius: 999,
}}>{t('shell.betaTag')}</span>
<span className="ol-aura-beta-tag">{t('shell.betaTag')}</span>
)}
<span>{t('shell.footer.version', { version: APP_VERSION_LABEL })}</span>
@@ -300,17 +276,9 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
<button
onClick={() => openSettings()}
className={settingsOpen ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
className={settingsOpen ? 'ol-nav-btn ol-nav-btn-active ol-aura-sidebar-settings' : 'ol-nav-btn ol-aura-sidebar-settings'}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '7px 10px',
borderRadius: 8, border: 0,
background: settingsOpen ? 'var(--ol-surface)' : 'transparent',
boxShadow: settingsOpen ? '0 1px 2px rgba(0,0,0,.05), 0 0 0 0.5px rgba(0,0,0,.06)' : 'none',
fontFamily: 'inherit', fontSize: 13,
cursor: 'default',
transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)',
textAlign: 'left',
}}
>
<Icon name="settings" size={14} />
@@ -319,21 +287,13 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
</div>
</aside>
{/* Main content — v1.3.1-8 用户希望"sidebar / main panel / caption 三处玻璃统一"。
从 var(--ol-surface) 不透明白底 改成半透明白 + backdrop-filter,跟 sidebar
一起坐在 WindowChrome 的磨砂底板上,整体一块连续玻璃。 */}
{/* Main content — Linux 禁用透明窗口后使用不透明面;其他平台保留玻璃层。 */}
<div style={{ flex: 1, minWidth: 0, padding: '4px 8px 6px 0', display: 'flex' }}>
<main
className="ol-console-main"
className="ol-console-main ol-aura-panel ol-aura-console-main"
style={{
flex: 1, minWidth: 0,
overflow: 'hidden',
background: 'rgba(255, 255, 255, 0.62)',
backdropFilter: 'blur(18px) saturate(170%)',
WebkitBackdropFilter: 'blur(18px) saturate(170%)',
borderRadius: 'var(--ol-window-console-radius)',
border: '0.5px solid rgba(0,0,0,0.06)',
boxShadow: '0 1px 0 rgba(255,255,255,0.8) inset, 0 8px 24px -12px rgba(15,17,22,0.10), 0 2px 6px -2px rgba(15,17,22,0.06)',
display: 'flex',
flexDirection: 'column',
}}
@@ -402,6 +362,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
onOpenSettings={openHotkeyRecordingSettings}
/>
) : null}
<AudioCueListener />
{/* tab 切换 + provider prompt + footer popover 公用的入场关键帧 */}
<style>{`
@@ -415,12 +376,112 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
color: var(--ol-ink-3);
font-weight: 500;
}
.ol-aura-sidebar {
padding: 14px 12px 14px;
background: var(--ol-sidebar-bg);
border-right: 1px solid var(--ol-sidebar-border);
}
.ol-aura-sidebar-brand {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 10px 16px;
margin-bottom: 6px;
border-radius: 0;
background: var(--ol-sidebar-brand-bg);
border: 1px solid var(--ol-sidebar-brand-border);
box-shadow: none;
}
.ol-aura-sidebar-brand-mark {
width: 26px;
height: 26px;
border-radius: 8px;
box-shadow: none;
}
.ol-aura-sidebar-brand-title {
font-size: 14px;
font-weight: 600;
font-family: var(--ol-font-display);
color: var(--ol-ink);
}
.ol-aura-sidebar-brand-kicker {
font-size: 10.5px;
color: var(--ol-ink-4);
font-family: var(--ol-font-mono);
letter-spacing: .08em;
}
.ol-aura-sidebar-pill {
background: var(--ol-sidebar-pill-bg);
border-radius: 12px;
border: 1px solid var(--ol-sidebar-pill-border);
box-shadow: none;
}
.ol-aura-sidebar-nav-btn {
padding: 8px 12px;
border-radius: 12px;
border: 0;
background: transparent;
font-family: inherit;
font-size: 13px;
cursor: default;
transition: color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick);
text-align: left;
position: relative;
z-index: 1;
}
.ol-aura-sidebar-footer {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 10px 0;
margin-top: 10px;
border-top: 1px solid var(--ol-sidebar-footer-border);
}
.ol-aura-sidebar-version {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 10px 12px;
font-family: var(--ol-font-sans);
font-size: 11px;
color: var(--ol-ink-4);
background: var(--ol-sidebar-version-bg);
border: 1px solid var(--ol-sidebar-version-border);
border-radius: var(--ol-pill-radius);
box-shadow: none;
}
.ol-aura-beta-tag {
display: inline-block;
padding: 2px 8px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--ol-blue);
background: rgba(37,99,235,0.10);
border-radius: 999px;
}
.ol-aura-sidebar-settings {
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--ol-sidebar-settings-border);
background: var(--ol-sidebar-settings-bg);
box-shadow: none;
}
.ol-aura-sidebar-settings.ol-nav-btn-active {
background: var(--ol-sidebar-settings-active-bg);
box-shadow: none;
}
.ol-aura-console-main {
border-radius: var(--ol-panel-radius);
}
.ol-nav-btn.ol-nav-btn-active {
color: var(--ol-ink);
font-weight: 600;
}
.ol-nav-btn:not(.ol-nav-btn-active):hover {
background: rgba(0,0,0,0.04);
background: var(--ol-nav-hover-bg);
color: var(--ol-ink);
}
@keyframes ol-page-slide {
@@ -456,7 +517,7 @@ function ProviderSetupPrompt({ onLater, onOpenSettings }: { onLater: () => void;
alignItems: 'center',
justifyContent: 'center',
padding: 28,
background: 'rgba(15,17,22,0.28)',
background: 'var(--ol-overlay-bg)',
backdropFilter: 'blur(6px) saturate(140%)',
WebkitBackdropFilter: 'blur(6px) saturate(140%)',
animation: 'ol-prompt-fade 0.2s var(--ol-motion-soft)',
@@ -521,7 +582,7 @@ function ProviderSetupPrompt({ onLater, onOpenSettings }: { onLater: () => void;
borderRadius: 8,
border: 0,
background: 'var(--ol-ink)',
color: '#fff',
color: 'var(--ol-on-accent)',
fontFamily: 'inherit',
fontSize: 12.5,
fontWeight: 500,
@@ -549,7 +610,7 @@ function HotkeyModeMigrationPrompt({ onLater, onOpenSettings }: { onLater: () =>
alignItems: 'center',
justifyContent: 'center',
padding: 28,
background: 'rgba(15,17,22,0.28)',
background: 'var(--ol-overlay-bg)',
backdropFilter: 'blur(6px) saturate(140%)',
WebkitBackdropFilter: 'blur(6px) saturate(140%)',
animation: 'ol-prompt-fade 0.2s var(--ol-motion-soft)',
@@ -614,7 +675,7 @@ function HotkeyModeMigrationPrompt({ onLater, onOpenSettings }: { onLater: () =>
borderRadius: 8,
border: 0,
background: 'var(--ol-ink)',
color: '#fff',
color: 'var(--ol-on-accent)',
fontFamily: 'inherit',
fontSize: 12.5,
fontWeight: 500,
@@ -629,4 +690,3 @@ function HotkeyModeMigrationPrompt({ onLater, onOpenSettings }: { onLater: () =>
</div>
);
}
@@ -35,7 +35,7 @@ export function MarketplaceModal({ onClose }: MarketplaceModalProps) {
onClick={onClose}
style={{
position: 'absolute', inset: 0,
background: 'rgba(15,17,22,0.32)',
background: 'var(--ol-overlay-bg)',
backdropFilter: 'blur(8px) saturate(140%)',
WebkitBackdropFilter: 'blur(8px) saturate(140%)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
@@ -48,10 +48,10 @@ export function MarketplaceModal({ onClose }: MarketplaceModalProps) {
onClick={(e) => e.stopPropagation()}
style={{
width: '100%', maxWidth: 1080, height: '100%', maxHeight: 720,
background: 'var(--ol-surface)',
borderRadius: 14,
border: '0.5px solid rgba(0,0,0,.08)',
boxShadow: '0 30px 80px -20px rgba(15,17,22,.35), 0 0 0 0.5px rgba(0,0,0,.06)',
background: 'var(--ol-style-editor-bg)',
borderRadius: 24,
border: '1px solid var(--ol-style-editor-border)',
boxShadow: 'var(--ol-style-editor-shadow)',
overflow: 'hidden',
position: 'relative',
display: 'flex', flexDirection: 'column',
@@ -71,12 +71,12 @@ export function MarketplaceModal({ onClose }: MarketplaceModalProps) {
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '4px 10px', borderRadius: 999,
border: loggedIn ? '0.5px solid var(--ol-line)' : '0.5px solid rgba(239,68,68,0.32)',
background: loggedIn ? 'rgba(255,255,255,0.85)' : 'rgba(239,68,68,0.08)',
border: loggedIn ? '1px solid var(--ol-style-subtle-border)' : '1px solid rgba(239,68,68,0.32)',
background: loggedIn ? 'var(--ol-style-subtle-bg)' : 'rgba(239,68,68,0.10)',
color: loggedIn ? 'var(--ol-ink-2)' : 'var(--ol-red, #ef4444)',
fontSize: 11.5, fontWeight: 500,
cursor: 'default',
boxShadow: '0 1px 2px rgba(15,17,22,0.05)',
boxShadow: 'var(--ol-style-subtle-shadow)',
backdropFilter: 'blur(8px) saturate(140%)',
WebkitBackdropFilter: 'blur(8px) saturate(140%)',
}}
@@ -91,23 +91,23 @@ export function MarketplaceModal({ onClose }: MarketplaceModalProps) {
style={{
position: 'absolute', top: 16, right: 16, zIndex: 3,
width: 32, height: 32,
border: '0.5px solid var(--ol-line)',
border: '1px solid var(--ol-style-subtle-border)',
borderRadius: 8,
background: 'rgba(255,255,255,0.85)',
background: 'var(--ol-style-subtle-bg)',
color: 'var(--ol-ink-2)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'default',
boxShadow: '0 1px 2px rgba(15,17,22,0.05)',
boxShadow: 'var(--ol-style-subtle-shadow)',
backdropFilter: 'blur(8px) saturate(140%)',
WebkitBackdropFilter: 'blur(8px) saturate(140%)',
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = 'var(--ol-surface)';
(e.currentTarget as HTMLButtonElement).style.background = 'var(--ol-control-solid)';
(e.currentTarget as HTMLButtonElement).style.color = 'var(--ol-ink)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = 'rgba(255,255,255,0.85)';
(e.currentTarget as HTMLButtonElement).style.background = 'var(--ol-style-subtle-bg)';
(e.currentTarget as HTMLButtonElement).style.color = 'var(--ol-ink-2)';
}}
aria-label={t('common.close')}
@@ -74,7 +74,7 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
onClick={onClose}
style={{
position: 'absolute', inset: 0,
background: 'rgba(15,17,22,0.32)',
background: 'var(--ol-overlay-bg)',
backdropFilter: 'blur(8px) saturate(140%)',
WebkitBackdropFilter: 'blur(8px) saturate(140%)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
@@ -84,32 +84,34 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
}}>
<div
className="ol-aura-settings"
onClick={(e) => e.stopPropagation()}
style={{
width: '100%', maxWidth: 880, height: '100%', maxHeight: 600,
background: 'var(--ol-surface)',
borderRadius: 14,
border: '0.5px solid rgba(0,0,0,.08)',
boxShadow: '0 30px 80px -20px rgba(15,17,22,.35), 0 0 0 0.5px rgba(0,0,0,.06)',
display: 'flex', overflow: 'hidden',
width: '100%',
maxWidth: 920,
height: '100%',
maxHeight: 620,
display: 'flex',
overflow: 'hidden',
animation: 'ol-modal-card-in 0.24s var(--ol-motion-spring)',
position: 'relative',
}}>
{/* ─── 单层侧栏 ────────────────────────────────────────────── */}
<aside
className="ol-aura-settings-rail"
style={{
width: 200, flexShrink: 0,
background: 'rgba(247,247,250,0.7)',
borderRight: '0.5px solid var(--ol-line-soft)',
padding: '18px 12px',
display: 'flex', flexDirection: 'column', gap: 14,
width: 214,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
}}>
{/* tab 组 */}
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 1 }}>
{pillRect && (
<div
className="ol-aura-settings-pill"
aria-hidden
style={{
position: 'absolute',
@@ -117,9 +119,6 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
right: 0,
top: pillRect.top,
height: pillRect.height,
background: '#fff',
borderRadius: 8,
boxShadow: '0 1px 2px rgba(0,0,0,.05), 0 0 0 0.5px rgba(0,0,0,.06)',
transition: 'top 0.36s var(--ol-motion-spring), height 0.36s var(--ol-motion-spring)',
pointerEvents: 'none',
zIndex: 0,
@@ -133,7 +132,7 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
key={it.id}
ref={el => { tabRefs.current[idx] = el; }}
onClick={() => setSection(it.id as SettingsSectionId)}
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
className={active ? 'ol-nav-btn ol-nav-btn-active ol-aura-settings-nav-btn' : 'ol-nav-btn ol-aura-settings-nav-btn'}
style={navBtnStyle}>
<Icon name={it.icon} size={14} />
<span style={{ flex: 1 }}>{t(`modal.sections.${it.id}`)}</span>
@@ -143,12 +142,12 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
</div>
{/* 外链组 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 1, paddingTop: 8, borderTop: '0.5px solid var(--ol-line-soft)' }}>
<div className="ol-aura-settings-links">
{LINK_ITEMS.map(it => (
<button
key={it.id}
onClick={() => { if (it.href) void openExternal(it.href); }}
className="ol-nav-btn"
className="ol-nav-btn ol-aura-settings-nav-btn"
style={navBtnStyle}>
<Icon name={it.icon} size={14} />
<span style={{ flex: 1 }}>{t(`modal.sections.${it.id}`)}</span>
@@ -161,7 +160,17 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
{/* ─── 内容区 ──────────────────────────────────────────────
父容器 overflow:hidden + 列向 flex;关闭按钮、section 标题固定在头部,
只有最里层的 scroll wrapper 真正滚动。 */}
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', position: 'relative', display: 'flex', flexDirection: 'column' }}>
<div
className="ol-aura-settings-content"
style={{
flex: 1,
minWidth: 0,
overflow: 'hidden',
position: 'relative',
display: 'flex',
flexDirection: 'column',
}}
>
{/* "已保存" toast:right:54 避开 28×28 关闭按钮 + 12px gap。 */}
<SavedToast
saveState={savedToast.state}
@@ -170,22 +179,19 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
offsetStyle={{ position: 'absolute', top: 16, right: 54 }}
/>
<button
className="ol-aura-settings-close"
onClick={onClose}
style={{
position: 'absolute', top: 14, right: 14, zIndex: 2,
width: 28, height: 28, border: 0, borderRadius: 999,
background: 'transparent', color: 'var(--ol-ink-3)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'default',
transition: 'background 0.16s var(--ol-motion-quick)',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.05)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
title={t('common.close')}>
<Icon name="close" size={14} />
</button>
<h2 style={{ margin: 0, padding: '22px 28px 8px', fontSize: 22, fontWeight: 600, letterSpacing: '-0.02em', flexShrink: 0 }}>
<h2 className="ol-aura-settings-title" style={{ margin: 0, flexShrink: 0 }}>
{t(`modal.sections.${section}`)}
</h2>
@@ -205,18 +211,69 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
</div>
</div>
</div>
<style>{`
.ol-aura-settings {
background: var(--ol-panel-bg);
border-radius: var(--ol-shell-radius);
border: 1px solid var(--ol-panel-border);
box-shadow: var(--ol-panel-shadow);
}
.ol-aura-settings-rail {
padding: 20px 14px;
gap: 16px;
background: var(--ol-settings-rail-bg);
border-right: 1px solid var(--ol-settings-rail-border);
}
.ol-aura-settings-pill {
background: var(--ol-sidebar-pill-bg);
border-radius: 12px;
border: 1px solid var(--ol-sidebar-pill-border);
box-shadow: none;
}
.ol-aura-settings-nav-btn {
padding: 7px 10px;
border-radius: 12px;
border: 0;
background: transparent;
font-family: inherit;
font-size: 13px;
cursor: default;
text-align: left;
position: relative;
z-index: 1;
transition: color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick);
}
.ol-aura-settings-links {
display: flex;
flex-direction: column;
gap: 1px;
padding-top: 10px;
border-top: 1px solid var(--ol-settings-links-border);
}
.ol-aura-settings-content {
background: var(--ol-settings-content-bg);
}
.ol-aura-settings-close {
background: var(--ol-settings-close-bg);
color: var(--ol-ink-3);
transition: background 0.16s var(--ol-motion-quick);
}
.ol-aura-settings-close:hover {
background: var(--ol-settings-close-hover-bg);
}
.ol-aura-settings-title {
padding: 24px 28px 10px;
font-size: 22px;
font-weight: 600;
letter-spacing: -0.02em;
font-family: var(--ol-font-display);
color: var(--ol-ink);
}
`}</style>
</div>
);
}
const navBtnStyle = {
display: 'flex', alignItems: 'center', gap: 10,
padding: '7px 10px',
borderRadius: 8, border: 0,
background: 'transparent',
fontFamily: 'inherit', fontSize: 13,
cursor: 'default', textAlign: 'left' as const,
position: 'relative' as const,
zIndex: 1,
transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)',
};
@@ -9,11 +9,19 @@ export function ShortcutRecorder({
onSave,
alignRecordButton = false,
disabled = false,
onDisable,
disableLabel,
comboOnly = false,
}: {
value: ShortcutBinding;
onSave: (binding: ShortcutBinding) => Promise<void>;
alignRecordButton?: boolean;
disabled?: boolean;
/** 提供则在「录制」按钮左侧并排渲染一个「停用」旋钮。 */
onDisable?: () => void | Promise<void>;
disableLabel?: string;
/** 仅允许组合键(修饰键+主键 / 功能键);拒绝单修饰键,因为全局热键无法注册它。 */
comboOnly?: boolean;
}) {
const { t } = useTranslation();
const [recording, setRecording] = useState(false);
@@ -70,6 +78,11 @@ export function ShortcutRecorder({
return;
}
if (isModifierKey(e.key)) {
// comboOnly:快速 Agent 等全局热键不支持单修饰键,提示用户配真正的组合键。
if (comboOnly) {
setError(t('settings.recording.comboNeedKey', '请配组合键(如 ⌘⇧J),不支持单独的修饰键'));
return;
}
const primary = modifierPrimaryFromCode(e.code, e.key);
if (!primary || pendingModifier.current?.primary === primary) return;
clearPendingModifier();
@@ -123,6 +136,24 @@ export function ShortcutRecorder({
fontWeight: 500,
cursor: recording || disabled ? 'default' : 'pointer',
opacity: disabled ? 0.68 : 1,
};
// 「停用」旋钮:与「录制快捷键」同高、紧贴在它左边,组成两个并排的小旋钮。
const disableKnobStyle: CSSProperties = {
fontSize: 12,
padding: '5px 12px',
background: 'transparent',
color: 'var(--ol-ink-4)',
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 6,
fontFamily: 'inherit',
fontWeight: 500,
cursor: recording ? 'default' : 'pointer',
};
// 录制按钮(+ 可选停用旋钮)成组靠右,保证「停用」永远贴着「录制」。
const controlsGroupStyle: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
gap: 8,
marginLeft: alignRecordButton ? 'auto' : undefined,
};
@@ -132,18 +163,32 @@ export function ShortcutRecorder({
<span style={{ padding: '4px 10px', borderRadius: 6, background: 'rgba(0,0,0,0.06)', fontSize: 13, fontFamily: 'var(--ol-font-mono)', fontWeight: 500, color: 'var(--ol-ink)' }}>
{formatComboLabel(value)}
</span>
<button
onClick={() => {
if (disabled) return;
setRecording(true);
setError(null);
clearPendingModifier();
}}
disabled={recording || disabled}
style={recordButtonStyle}
>
{recording ? t('settings.recording.comboRecordHint') : t('settings.recording.comboRecordBtn')}
</button>
<div style={controlsGroupStyle}>
{onDisable && (
<button
onClick={() => {
if (recording) return;
void onDisable();
}}
disabled={recording}
style={disableKnobStyle}
>
{disableLabel ?? t('settings.shortcuts.disable', 'Disable')}
</button>
)}
<button
onClick={() => {
if (disabled) return;
setRecording(true);
setError(null);
clearPendingModifier();
}}
disabled={recording || disabled}
style={recordButtonStyle}
>
{recording ? t('settings.recording.comboRecordHint') : t('settings.recording.comboRecordBtn')}
</button>
</div>
</div>
{recording && (
<div
@@ -37,17 +37,13 @@ export function WindowChrome({
const consoleRadius = os === 'mac' ? 20 : os === 'win' ? WIN_CONSOLE_RADIUS : 14;
const titlebarHeight = os === 'mac' ? MAC_TITLEBAR_HEIGHT : os === 'linux' ? LINUX_TITLEBAR_HEIGHT : 0;
// 三个平台共用半透明玻璃 background + backdropFilter。
// macOS / Windows 共用半透明玻璃 background + backdropFilter。
// macOS: NSVisualEffectView 提供材质;Windows: Tauri apply_mica 提供 Mica;
// Linux: decorations:false 后 CSS 磨砂玻璃自成背景。
//
// 注意:三层渐变的参数与 global.css 中 [data-ol-no-compositing] .ol-winchrome
// 的回退 background 同步(只 opacity 从 0.92 提到 0.96)。改这里时请同步更新 CSS。
const background = `
radial-gradient(120% 80% at 0% 0%, rgba(255,255,255,0.55) 0%, rgba(255,255,255,0) 60%),
radial-gradient(100% 70% at 100% 100%, rgba(37,99,235,0.07) 0%, rgba(37,99,235,0) 55%),
linear-gradient(180deg, rgba(245,245,247,0.92) 0%, rgba(232,232,236,0.92) 100%)
`;
const background = 'var(--ol-window-bg)';
return (
<div
@@ -64,7 +60,7 @@ export function WindowChrome({
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
border: os === 'win' ? 'none' : os === 'mac' ? 'none' : '0.5px solid rgba(0,0,0,.10)',
border: os === 'win' ? 'none' : os === 'mac' ? 'none' : '0.5px solid var(--ol-window-border)',
background,
backdropFilter: 'blur(var(--ol-glass-blur-strong)) saturate(190%)',
WebkitBackdropFilter: 'blur(var(--ol-glass-blur-strong)) saturate(190%)',
@@ -160,10 +156,10 @@ function LinuxTitlebar() {
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 6px 0 14px',
background: 'rgba(245,245,247,0.85)',
background: 'var(--ol-linux-titlebar-bg)',
backdropFilter: 'blur(12px) saturate(180%)',
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
borderBottom: '0.5px solid rgba(0,0,0,0.08)',
borderBottom: '0.5px solid var(--ol-linux-titlebar-border)',
color: 'var(--ol-ink-3)',
fontSize: 13,
fontWeight: 500,
@@ -10,7 +10,7 @@ interface SegSimpleProps {
export function SegSimple({ options, active }: SegSimpleProps) {
const [v, setV] = useState(active);
return (
<div style={{ display: 'inline-flex', padding: 2, borderRadius: 8, background: 'rgba(0,0,0,0.05)' }}>
<div style={{ display: 'inline-flex', padding: 2, borderRadius: 8, background: 'var(--ol-segmented-bg)' }}>
{options.map((o) => (
<button
key={o}
@@ -18,9 +18,9 @@ export function SegSimple({ options, active }: SegSimpleProps) {
style={{
padding: '5px 12px', fontSize: 12, fontWeight: 500, border: 0, borderRadius: 6,
fontFamily: 'inherit',
background: v === o ? '#fff' : 'transparent',
background: v === o ? 'var(--ol-segmented-active-bg)' : 'transparent',
color: v === o ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
boxShadow: v === o ? '0 1px 2px rgba(0,0,0,.08)' : 'none',
boxShadow: v === o ? 'var(--ol-segmented-active-shadow)' : 'none',
cursor: 'default',
}}
>
@@ -15,7 +15,7 @@ export function SwitchLite({ on: initial = false }: SwitchLiteProps) {
onClick={() => setOn(!on)}
style={{
position: 'relative', width: 32, height: 18, borderRadius: 999, border: 0,
background: on ? 'var(--ol-blue)' : 'rgba(0,0,0,0.18)',
background: on ? 'var(--ol-blue)' : 'var(--ol-toggle-off-bg)',
cursor: 'default',
outline: 'none',
}}
@@ -23,7 +23,7 @@ export function SwitchLite({ on: initial = false }: SwitchLiteProps) {
<span
style={{
position: 'absolute', top: 2, left: on ? 16 : 2,
width: 14, height: 14, borderRadius: 999, background: '#fff',
width: 14, height: 14, borderRadius: 999, background: 'var(--ol-toggle-knob)',
boxShadow: '0 1px 2px rgba(0,0,0,.25)', transition: 'left .16s var(--ol-motion-spring)',
}}
/>
+84
View File
@@ -31,6 +31,7 @@ export const en: typeof zhCN = {
},
capsule: {
thinking: 'thinking',
using: 'using',
cancelled: 'Cancelled',
error: 'Something went wrong',
inserted: 'Inserted {{count}}',
@@ -53,6 +54,21 @@ export const en: typeof zhCN = {
statusThinking: 'Thinking',
statusError: 'Error',
},
lessComputer: {
you: 'You',
working: 'Operating…',
tool: 'Used {{name}}',
done: 'Done',
cost: '${{cost}}',
error: 'Failed. Try again.',
closeTooltip: 'Close',
approvalTitle: 'Run blocked command?',
approvalRerunWarning: 'Note: approving re-runs on an already-modified workspace and may have side effects on non-idempotent operations.',
approve: 'Approve',
deny: 'Deny',
approved: 'Approved',
denied: 'Denied',
},
nav: {
overview: 'Overview',
history: 'History',
@@ -499,6 +515,61 @@ export const en: typeof zhCN = {
title: 'Data storage',
desc: 'Conversation history and context kept on this device.',
},
codingConsole: {
title: 'Claude Console',
desc: 'Detect your local Claude Code and MCP (computer use) status, then run Claude headlessly behind guardrails and watch the streamed output and cost.',
guardNote: 'Reversible actions are allowed by default; high-risk commands (rm -rf, sudo, force push) are blocked; if the working dir is a git repo, a snapshot is taken before each run for rollback.',
status: 'Status',
detect: 'Detect',
detecting: 'Detecting…',
installed: 'Claude detected',
notInstalled: 'claude not found',
notInstalledHint: 'Install Claude Code first (see docs.anthropic.com/claude-code), or enter the full path to its executable below.',
mcpServers: '{{count}} MCP server(s) configured',
computerUsePresent: 'Desktop-control (computer use) MCP configured',
computerUseAbsent: 'No desktop-control MCP (light actions like copy/paste work via Bash — not required)',
exePath: 'Executable',
workdir: 'Working directory',
workdirDesc: 'Optional. Claude runs inside this dir; a git repo enables a pre-run snapshot for rollback.',
workdirPlaceholder: 'Empty = run in a temp dir',
permissionMode: 'Permission mode',
mode: {
acceptEdits: 'Allow (reversible)',
plan: 'Read-only / plan',
default: 'Default (ask each)',
bypassPermissions: 'Full bypass (risky)',
},
promptPlaceholder: 'Ask Claude to do something, e.g. list files in the current directory',
run: 'Run',
running: 'Running…',
cancel: 'Cancel',
clear: 'Clear',
riskWarn: 'High-risk intent detected: {{reason}}. The guardrail blocks high-risk commands at execution time.',
toolUse: 'tool {{name}}',
done: 'Done',
doneCost: 'Done · cost ${{cost}}',
cancelled: 'Cancelled',
outputPlaceholder: 'Output streams here…',
},
codingAgent: {
title: 'Less Computer',
desc: 'Hold a key, speak, and Claude operates your computer. macOS only.',
enable: 'Enable Less Computer',
comingSoonNote: 'Config is saved now; hotkey triggering and the execution flow land in a later version.',
hotkeyHint: 'When enabled, hold the shortcut below to talk; release it and Claude shows the result in the capsule.',
voiceHotkey: 'Hold-to-talk key',
voiceHotkeyDesc: 'Hold to talk, release to run. Supports Ctrl/Option/Fn single keys.',
provider: 'Agent backend',
providerOpenCodeSoon: 'OpenCode (coming soon)',
panelHotkey: 'Panel hotkey (voice agent)',
panelHotkeyDesc: 'Record voice → ASR → Claude → streamed into a panel. Default Cmd/Ctrl+Shift+Enter.',
quickHotkey: 'Quick-take hotkey',
quickHotkeyDesc: 'Take selected text → Claude → result back at the cursor. No panel, faster.',
model: 'Model',
modelPlaceholder: 'Default: sonnet',
modelDefault: 'Default (auto sonnet)',
modelHint: 'Haiku = fastest · Sonnet = balanced · Opus = strongest',
},
debug: {
title: 'Debug tools',
desc: 'For troubleshooting recognition issues; off by default.',
@@ -559,6 +630,7 @@ export const en: typeof zhCN = {
comboRecordDesc: 'Click, then press your desired key combination (e.g. \u2318\u21E7D). Supports Toggle and Push-to-talk modes.',
comboRecordBtn: 'Record shortcut',
comboRecordHint: 'Press your shortcut combination\u2026',
comboNeedKey: 'Use a key combo (e.g. \u2318\u21e7J); a lone modifier will not work',
comboRecorded: 'Recorded',
comboClear: 'Clear',
comboConflict: 'This shortcut combination is not available',
@@ -611,6 +683,7 @@ export const en: typeof zhCN = {
openrouterFree: 'OpenRouter (free models)',
alibabaCoding: 'Alibaba Cloud Coding Plan',
codingPlanX: 'CodingPlanX',
minimax: 'MiniMax (M3)',
custom: 'Custom',
asrVolcengine: 'Volcengine bigasr',
asrBailian: 'Alibaba Bailian realtime ASR',
@@ -619,6 +692,7 @@ export const en: typeof zhCN = {
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper (compatible)',
asrOpenrouter: 'OpenRouter Whisper',
asrXiaomiMimo: 'Xiaomi MiMo ASR',
asrSherpaOnnxLocal: 'Local sherpa-onnx (experimental)',
asrFoundryLocalWhisper: 'Local Whisper (Foundry Local)',
asrLocalQwen3: 'Local Qwen3-ASR',
@@ -685,6 +759,12 @@ export const en: typeof zhCN = {
confirm: 'Confirm capsule insertion',
switchStyle: 'Switch to previous style',
openApp: 'Open OpenLess',
agentPolish: 'Polish selected text',
agentPolishDesc: 'Select text → press → Claude polishes it → replaces the selection.',
agentVoice: 'Less Computer',
agentVoiceDesc: 'Hold a custom key → speak → Claude runs the task → result shown in a capsule.',
agentVoiceHint: 'Set the hold-to-talk key under Advanced → Less Computer.',
agentVoiceTrigger: 'Less Computer hold-to-talk key',
enable: 'Enable',
disable: 'Disable',
confirmHint: 'Click ✓ on the capsule',
@@ -856,6 +936,10 @@ export const en: typeof zhCN = {
releaseNotes: 'Release notes',
},
personalize: {
theme: 'Theme',
themeDesc: 'Switch the app shell between light and dark appearance.',
themeLight: 'Light',
themeDark: 'Dark',
font: 'Font size',
fontDesc: 'Scale the entire UI font size — applies instantly.',
fontSmall: 'Small',
+84
View File
@@ -33,6 +33,7 @@ export const ja: typeof zhCN = {
},
capsule: {
thinking: 'thinking',
using: 'using',
cancelled: 'キャンセルしました',
error: 'エラーが発生しました',
inserted: '{{count}} 文字を入力しました',
@@ -55,6 +56,21 @@ export const ja: typeof zhCN = {
statusThinking: '思考中',
statusError: 'エラー',
},
lessComputer: {
you: 'あなた',
working: '操作中…',
tool: '{{name}} を使用',
done: '完了',
cost: '${{cost}}',
error: '失敗しました。再試行してください。',
closeTooltip: '閉じる',
approvalTitle: 'ブロックされたコマンドを実行?',
approvalRerunWarning: '注意:承認すると、すでに変更されたワークスペース上で再実行され、冪等でない操作に副作用が生じる可能性があります。',
approve: '許可',
deny: '拒否',
approved: '許可済み',
denied: '拒否済み',
},
nav: {
overview: '概要',
history: '履歴',
@@ -501,6 +517,61 @@ export const ja: typeof zhCN = {
title: 'データ保存',
desc: 'この端末に保存される会話履歴とコンテキスト。',
},
codingConsole: {
title: 'Claude コンソール',
desc: 'ローカルの Claude Code と MCP(computer use)の状態を検出し、ガードレール付きで Claude をヘッドレス実行して、出力とコストをストリーミング表示します。',
guardNote: '復元可能な操作はデフォルトで許可。rm -rf / sudo / 強制プッシュなどの高リスクコマンドはブロック。作業ディレクトリが git リポジトリなら実行前にスナップショットを作成し巻き戻し可能。',
status: '状態',
detect: '検出',
detecting: '検出中…',
installed: 'Claude を検出',
notInstalled: 'claude が見つかりません',
notInstalledHint: 'まず Claude Code をインストールしてください(docs.anthropic.com/claude-code 参照)。または下に実行ファイルのフルパスを入力してください。',
mcpServers: 'MCP サーバー {{count}} 件',
computerUsePresent: 'デスクトップ操作(computer use)MCP を検出',
computerUseAbsent: 'デスクトップ操作 MCP なし(コピー / 貼り付けなどの軽い操作は Bash で可能、不要)',
exePath: '実行ファイル',
workdir: '作業ディレクトリ',
workdirDesc: '任意。Claude はこのディレクトリ内で実行。git リポジトリなら実行前スナップショットで巻き戻し可能。',
workdirPlaceholder: '空欄なら一時ディレクトリで実行',
permissionMode: '権限モード',
mode: {
acceptEdits: '許可(復元可能)',
plan: '読み取り専用 / 計画',
default: 'デフォルト(都度確認)',
bypassPermissions: '完全許可(高リスク)',
},
promptPlaceholder: 'Claude に指示、例:カレントディレクトリのファイル名を一覧表示',
run: '実行',
running: '実行中…',
cancel: 'キャンセル',
clear: 'クリア',
riskWarn: '高リスクの意図を検出:{{reason}}。ガードレールが実行時に高リスクコマンドをブロックします。',
toolUse: 'ツール {{name}}',
done: '完了',
doneCost: '完了 · コスト ${{cost}}',
cancelled: 'キャンセル済み',
outputPlaceholder: '出力はここにストリーミング表示されます…',
},
codingAgent: {
title: 'Less Computer',
desc: 'キーを押して話すと Claude が PC を操作します。macOS のみ。',
enable: 'Less Computer を有効化',
comingSoonNote: '設定はすぐ保存されます。ホットキー起動と実行フローは今後のバージョンで対応。',
hotkeyHint: '有効にすると、下のショートカットを押しながら話し、離すと Claude の結果がカプセルに表示されます。',
voiceHotkey: '押しながら話すキー',
voiceHotkeyDesc: '押して話す、離して実行。Ctrl/Option/Fn などの単キー対応。',
provider: 'Agent バックエンド',
providerOpenCodeSoon: 'OpenCode(近日対応)',
panelHotkey: 'パネルキー(音声 Agent)',
panelHotkeyDesc: '録音 → ASR → Claude → パネルにストリーミング表示。デフォルト Cmd/Ctrl+Shift+Enter。',
quickHotkey: 'クイック取得キー',
quickHotkeyDesc: '選択テキストを取得 → Claude → 結果をカーソル位置へ。パネルなし、より高速。',
model: 'モデル',
modelPlaceholder: 'デフォルト: sonnet',
modelDefault: 'デフォルト(自動 sonnet)',
modelHint: 'Haiku = 最速 · Sonnet = バランス · Opus = 最強',
},
debug: {
title: 'デバッグツール',
desc: '認識の問題を調査するときに使用。通常はオフのままで構いません。',
@@ -561,6 +632,7 @@ export const ja: typeof zhCN = {
comboRecordDesc: 'クリック後、希望するキーの組み合わせ(例:⌘⇧D)を押してください。トグル / 押し続けの両方に対応。',
comboRecordBtn: 'ショートカットを記録',
comboRecordHint: 'ショートカットの組み合わせを押してください…',
comboNeedKey: '組み合わせキー(例: ⌘⇧J)を設定してください。修飾キー単体は使えません',
comboRecorded: '記録済み',
comboClear: 'クリア',
comboConflict: 'このショートカットの組み合わせは使用できません',
@@ -613,6 +685,7 @@ export const ja: typeof zhCN = {
openrouterFree: 'OpenRouter(無料モデル)',
alibabaCoding: 'Alibaba Cloud Coding Plan',
codingPlanX: 'CodingPlanX',
minimax: 'MiniMax(M3)',
custom: 'カスタム',
asrVolcengine: 'Volcengine bigasr',
asrBailian: 'Alibaba Bailian リアルタイム ASR',
@@ -621,6 +694,7 @@ export const ja: typeof zhCN = {
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper(互換)',
asrOpenrouter: 'OpenRouter Whisper',
asrXiaomiMimo: 'Xiaomi MiMo ASR',
asrSherpaOnnxLocal: 'ローカル sherpa-onnx(実験的)',
asrFoundryLocalWhisper: 'ローカル Whisper(Foundry Local)',
asrLocalQwen3: 'ローカル Qwen3-ASR',
@@ -687,6 +761,12 @@ export const ja: typeof zhCN = {
confirm: 'カプセル入力を確定',
switchStyle: '前のスタイルに切り替え',
openApp: 'OpenLess を開く',
agentPolish: '選択テキストを推敲',
agentPolishDesc: 'テキスト選択 → キー → Claude が推敲 → 選択範囲を置換。',
agentVoice: 'Less Computer',
agentVoiceDesc: 'カスタムキーを押しながら話す → Claude がタスクを実行 → 結果をカプセル表示。',
agentVoiceHint: '「詳細 → Less Computer」で押しながら話すキーを設定してください。',
agentVoiceTrigger: 'Less Computer 押しながら話すキー',
enable: '有効化',
disable: '無効化',
confirmHint: '右側の ✓ をクリック',
@@ -858,6 +938,10 @@ export const ja: typeof zhCN = {
releaseNotes: 'リリースノート',
},
personalize: {
theme: 'テーマ',
themeDesc: 'アプリ外観をライトとダークで切り替えます。',
themeLight: 'ライト',
themeDark: 'ダーク',
font: 'フォントサイズ',
fontDesc: 'UI のフォントサイズを全体的にスケール。即時反映。',
fontSmall: '小',
+84
View File
@@ -33,6 +33,7 @@ export const ko: typeof zhCN = {
},
capsule: {
thinking: 'thinking',
using: 'using',
cancelled: '취소됨',
error: '오류 발생',
inserted: '{{count}}자 입력됨',
@@ -55,6 +56,21 @@ export const ko: typeof zhCN = {
statusThinking: '생각 중',
statusError: '오류',
},
lessComputer: {
you: '나',
working: '조작 중…',
tool: '{{name}} 사용',
done: '완료',
cost: '${{cost}}',
error: '실패했습니다. 다시 시도하세요.',
closeTooltip: '닫기',
approvalTitle: '차단된 명령을 실행할까요?',
approvalRerunWarning: '주의: 승인하면 이미 수정된 작업 공간에서 다시 실행되어 멱등하지 않은 작업에 부작용이 생길 수 있습니다.',
approve: '허용',
deny: '거부',
approved: '허용됨',
denied: '거부됨',
},
nav: {
overview: '개요',
history: '기록',
@@ -501,6 +517,61 @@ export const ko: typeof zhCN = {
title: '데이터 저장',
desc: '이 기기에 보관되는 대화 기록과 컨텍스트.',
},
codingConsole: {
title: 'Claude 콘솔',
desc: '로컬 Claude Code 와 MCP(computer use) 상태를 감지하고, 가드레일 아래에서 Claude 를 헤드리스로 실행하여 출력과 비용을 스트리밍으로 확인합니다.',
guardNote: '복구 가능한 작업은 기본 허용; rm -rf / sudo / 강제 푸시 등 고위험 명령은 차단; 작업 디렉터리가 git 저장소이면 실행 전 스냅샷을 만들어 되돌릴 수 있습니다.',
status: '상태',
detect: '감지',
detecting: '감지 중…',
installed: 'Claude 감지됨',
notInstalled: 'claude 를 찾을 수 없음',
notInstalledHint: '먼저 Claude Code 를 설치하세요(docs.anthropic.com/claude-code 참고). 또는 아래에 실행 파일 전체 경로를 입력하세요.',
mcpServers: 'MCP 서버 {{count}}개 구성됨',
computerUsePresent: '데스크톱 제어(computer use) MCP 구성됨',
computerUseAbsent: '데스크톱 제어 MCP 없음(복사/붙여넣기 같은 가벼운 작업은 Bash 로 가능, 불필요)',
exePath: '실행 파일',
workdir: '작업 디렉터리',
workdirDesc: '선택 사항. Claude 가 이 디렉터리에서 실행됩니다. git 저장소이면 실행 전 스냅샷으로 되돌릴 수 있습니다.',
workdirPlaceholder: '비우면 임시 디렉터리에서 실행',
permissionMode: '권한 모드',
mode: {
acceptEdits: '허용(복구 가능)',
plan: '읽기 전용 / 계획',
default: '기본(매번 확인)',
bypassPermissions: '완전 허용(위험)',
},
promptPlaceholder: 'Claude 에게 작업 지시, 예: 현재 디렉터리 파일 목록',
run: '실행',
running: '실행 중…',
cancel: '취소',
clear: '지우기',
riskWarn: '고위험 의도 감지: {{reason}}. 가드레일이 실행 시 고위험 명령을 차단합니다.',
toolUse: '도구 {{name}}',
done: '완료',
doneCost: '완료 · 비용 ${{cost}}',
cancelled: '취소됨',
outputPlaceholder: '출력이 여기에 스트리밍됩니다…',
},
codingAgent: {
title: 'Less Computer',
desc: '키를 누르고 말하면 Claude가 PC를 조작합니다. macOS 전용.',
enable: 'Less Computer 켜기',
comingSoonNote: '설정은 즉시 저장됩니다. 단축키 트리거와 실행 흐름은 이후 버전에서 제공됩니다.',
hotkeyHint: '켜면 아래 단축키를 누른 채 말하고, 놓으면 Claude 결과가 캡슐에 표시됩니다.',
voiceHotkey: '누르고 말하기 키',
voiceHotkeyDesc: '누르고 말하고 놓으면 실행. Ctrl/Option/Fn 단일 키 지원.',
provider: 'Agent 백엔드',
providerOpenCodeSoon: 'OpenCode(곧 지원)',
panelHotkey: '패널 키(음성 Agent)',
panelHotkeyDesc: '녹음 → ASR → Claude → 패널에 스트리밍. 기본 Cmd/Ctrl+Shift+Enter.',
quickHotkey: '빠른 가져오기 키',
quickHotkeyDesc: '선택한 텍스트 → Claude → 결과를 커서 위치로. 패널 없이 더 빠름.',
model: '모델',
modelPlaceholder: '기본: sonnet',
modelDefault: '기본(자동 sonnet)',
modelHint: 'Haiku = 가장 빠름 · Sonnet = 균형 · Opus = 최강',
},
debug: {
title: '디버그 도구',
desc: '인식 문제를 진단할 때 사용합니다. 평소에는 꺼두어도 됩니다.',
@@ -561,6 +632,7 @@ export const ko: typeof zhCN = {
comboRecordDesc: '클릭 후 원하는 단축키 조합(예: ⌘⇧D)을 누르세요. 토글 및 누르기 모드 모두 지원합니다.',
comboRecordBtn: '단축키 녹화',
comboRecordHint: '단축키 조합을 눌러 주세요…',
comboNeedKey: '조합 키(예: ⌘⇧J)를 설정하세요. 단일 보조 키는 사용할 수 없습니다',
comboRecorded: '녹화됨',
comboClear: '지우기',
comboConflict: '이 단축키 조합은 사용할 수 없습니다',
@@ -613,6 +685,7 @@ export const ko: typeof zhCN = {
openrouterFree: 'OpenRouter(무료 모델)',
alibabaCoding: 'Alibaba Cloud Coding Plan',
codingPlanX: 'CodingPlanX',
minimax: 'MiniMax (M3)',
custom: '사용자 정의',
asrVolcengine: 'Volcengine bigasr',
asrBailian: 'Alibaba Bailian 실시간 ASR',
@@ -621,6 +694,7 @@ export const ko: typeof zhCN = {
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper(호환)',
asrOpenrouter: 'OpenRouter Whisper',
asrXiaomiMimo: 'Xiaomi MiMo ASR',
asrSherpaOnnxLocal: '로컬 sherpa-onnx(실험적)',
asrFoundryLocalWhisper: '로컬 Whisper(Foundry Local)',
asrLocalQwen3: '로컬 Qwen3-ASR',
@@ -687,6 +761,12 @@ export const ko: typeof zhCN = {
confirm: '캡슐 입력 확정',
switchStyle: '이전 스타일로 전환',
openApp: 'OpenLess 열기',
agentPolish: '선택 텍스트 다듬기',
agentPolishDesc: '텍스트 선택 → 키 → Claude 다듬기 → 선택 영역 교체.',
agentVoice: 'Less Computer',
agentVoiceDesc: '사용자 지정 키를 누른 채 말하기 → Claude 작업 실행 → 결과 캡슐 표시.',
agentVoiceHint: '「고급 → Less Computer」에서 누르고 말하기 키를 설정하세요.',
agentVoiceTrigger: 'Less Computer 누르고 말하기 키',
enable: '활성화',
disable: '비활성화',
confirmHint: '오른쪽 ✓ 클릭',
@@ -858,6 +938,10 @@ export const ko: typeof zhCN = {
releaseNotes: '릴리스 노트',
},
personalize: {
theme: '테마',
themeDesc: '앱 외관을 라이트와 다크로 전환합니다.',
themeLight: '라이트',
themeDark: '다크',
font: '글꼴 크기',
fontDesc: 'UI 글꼴 크기를 전체 스케일. 즉시 반영.',
fontSmall: '소',
+84
View File
@@ -29,6 +29,7 @@ export const zhCN = {
},
capsule: {
thinking: 'thinking',
using: 'using',
cancelled: '已取消',
error: '出错了',
inserted: '已插入 {{count}}',
@@ -51,6 +52,21 @@ export const zhCN = {
statusThinking: '思考中',
statusError: '出错了',
},
lessComputer: {
you: '你',
working: '正在操控电脑…',
tool: '调用了 {{name}}',
done: '完成',
cost: '${{cost}}',
error: '失败,请重试。',
closeTooltip: '关闭',
approvalTitle: '执行被拦截的命令?',
approvalRerunWarning: '注意:批准后将在已被修改的工作区上重新运行,可能对不可重入操作产生副作用',
approve: '允许',
deny: '拒绝',
approved: '已允许',
denied: '已拒绝',
},
nav: {
overview: '概览',
history: '历史',
@@ -497,6 +513,61 @@ export const zhCN = {
title: '数据存储',
desc: '本机保留的历史会话与对话上下文。',
},
codingConsole: {
title: 'Claude 控制台',
desc: '检测本机 Claude Code 与 MCP(computer use)状态,并护栏化地无头跑一次 Claude、流式查看输出与用量。',
guardNote: '默认放行可恢复操作;rm -rf / sudo / 强制推送等高风险命令被拦截;若工作目录是 git 仓库,运行前自动生成快照可回滚。',
status: '状态',
detect: '检测',
detecting: '检测中…',
installed: '已检测到 Claude',
notInstalled: '未检测到 claude',
notInstalledHint: '请先安装 Claude Code(参见 docs.anthropic.com/claude-code),或在下方填写其可执行文件完整路径。',
mcpServers: '已配置 {{count}} 个 MCP 服务',
computerUsePresent: '已配置桌面控制(computer use)MCP',
computerUseAbsent: '未配置桌面控制 MCP(复制/粘贴等轻动作用 Bash 即可,无需此项)',
exePath: '可执行文件',
workdir: '工作目录',
workdirDesc: '可选。Claude 在此目录内运行;填写 git 仓库可启用运行前快照回滚。',
workdirPlaceholder: '留空则在临时目录运行',
permissionMode: '权限模式',
mode: {
acceptEdits: '放行(可恢复操作)',
plan: '只读 / 计划',
default: '默认(逐项确认)',
bypassPermissions: '完全放行(高风险)',
},
promptPlaceholder: '让 Claude 做点什么,例如:把当前目录的文件名列出来',
run: '运行',
running: '运行中…',
cancel: '取消',
clear: '清空',
riskWarn: '检测到高风险意图:{{reason}}。护栏会在执行层拦截高风险命令。',
toolUse: '调用工具 {{name}}',
done: '完成',
doneCost: '完成 · 用量 ${{cost}}',
cancelled: '已取消',
outputPlaceholder: '输出会流式显示在这里…',
},
codingAgent: {
title: 'Less Computer',
desc: '按住一个键说话,Claude 帮你操作电脑。仅 macOS。',
enable: '启用 Less Computer',
comingSoonNote: '配置即时保存;热键触发与执行链路随后续版本生效。',
hotkeyHint: '开启后,按住下方快捷键说话,松开后 Claude 处理并把结果显示在胶囊里。',
voiceHotkey: '按住说话键',
voiceHotkeyDesc: '按住说话、松开执行。支持 Ctrl/Option/Fn 等单键。',
provider: 'Agent 后端',
providerOpenCodeSoon: 'OpenCode(即将支持)',
panelHotkey: '面板键(语音 Agent)',
panelHotkeyDesc: '录音 → ASR → Claude → 结果流式进面板。默认 Cmd/Ctrl+Shift+Enter。',
quickHotkey: '快取用键',
quickHotkeyDesc: '拿当前选中文本 → Claude → 结果回插光标处。不开面板、更快。',
model: '模型',
modelPlaceholder: '默认 sonnet',
modelDefault: '默认(自动 sonnet)',
modelHint: 'Haiku 最快 · Sonnet 均衡 · Opus 最强',
},
debug: {
title: '调试工具',
desc: '排查识别问题时使用,平时无需开启。',
@@ -557,6 +628,7 @@ export const zhCN = {
comboRecordDesc: '点击后按下想要的快捷键组合(如 ⌘⇧D)。',
comboRecordBtn: '录制快捷键',
comboRecordHint: '请按下快捷键组合…',
comboNeedKey: '请配组合键(如 ⌘⇧J),不支持单独的修饰键',
comboRecorded: '已录制',
comboClear: '清除',
comboConflict: '该快捷键组合不可用',
@@ -609,6 +681,7 @@ export const zhCN = {
openrouterFree: 'OpenRouter(免费模型)',
alibabaCoding: '阿里云 Coding Plan',
codingPlanX: 'CodingPlanX',
minimax: 'MiniMax(M3)',
custom: '自定义',
asrVolcengine: '火山引擎 bigasr',
asrBailian: '阿里云百炼实时 ASR',
@@ -617,6 +690,7 @@ export const zhCN = {
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper(兼容)',
asrOpenrouter: 'OpenRouter Whisper',
asrXiaomiMimo: '小米 MiMo ASR',
asrSherpaOnnxLocal: '本地 sherpa-onnx(实验性)',
asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)',
asrLocalQwen3: '本地 Qwen3-ASR',
@@ -683,6 +757,12 @@ export const zhCN = {
confirm: '胶囊确认插入',
switchStyle: '切换到上一个风格',
openApp: '打开 OpenLess',
agentPolish: '选中文本润色',
agentPolishDesc: '选中文本 → 按键 → Claude 润色 → 替换选区。',
agentVoice: 'Less Computer',
agentVoiceDesc: '按住自定义按键 → 说话 → Claude 执行任务 → 结果弹胶囊显示。',
agentVoiceHint: '在「高级 → Less Computer」里设置按住说话键。',
agentVoiceTrigger: 'Less Computer 按住说话键',
enable: '启用',
disable: '停用',
confirmHint: '点击右侧 ✓',
@@ -854,6 +934,10 @@ export const zhCN = {
releaseNotes: '发布日志',
},
personalize: {
theme: '主题',
themeDesc: '切换应用外观的浅色与深色版本。',
themeLight: '浅色',
themeDark: '深色',
font: '字体大小',
fontDesc: '整体缩放界面字号,立即生效。',
fontSmall: '小',
+84
View File
@@ -31,6 +31,7 @@ export const zhTW: typeof zhCN = {
},
capsule: {
thinking: 'thinking',
using: 'using',
cancelled: '已取消',
error: '出錯了',
inserted: '已插入 {{count}}',
@@ -53,6 +54,21 @@ export const zhTW: typeof zhCN = {
statusThinking: '思考中',
statusError: '出錯了',
},
lessComputer: {
you: '你',
working: '正在操控電腦…',
tool: '呼叫了 {{name}}',
done: '完成',
cost: '${{cost}}',
error: '失敗,請重試。',
closeTooltip: '關閉',
approvalTitle: '執行被攔截的指令?',
approvalRerunWarning: '注意:批准後將在已被修改的工作區上重新執行,可能對不可重入操作產生副作用',
approve: '允許',
deny: '拒絕',
approved: '已允許',
denied: '已拒絕',
},
nav: {
overview: '概覽',
history: '歷史',
@@ -499,6 +515,61 @@ export const zhTW: typeof zhCN = {
title: '資料儲存',
desc: '本機保留的歷史會話與對話上下文。',
},
codingConsole: {
title: 'Claude 主控台',
desc: '偵測本機 Claude Code 與 MCP(computer use)狀態,並以護欄方式無頭執行一次 Claude、串流檢視輸出與用量。',
guardNote: '預設放行可復原操作;rm -rf / sudo / 強制推送等高風險指令會被攔截;若工作目錄為 git 儲存庫,執行前自動建立快照可回滾。',
status: '狀態',
detect: '偵測',
detecting: '偵測中…',
installed: '已偵測到 Claude',
notInstalled: '未偵測到 claude',
notInstalledHint: '請先安裝 Claude Code(參見 docs.anthropic.com/claude-code),或在下方填入其執行檔完整路徑。',
mcpServers: '已設定 {{count}} 個 MCP 服務',
computerUsePresent: '已設定桌面控制(computer use)MCP',
computerUseAbsent: '未設定桌面控制 MCP(複製/貼上等輕動作用 Bash 即可,無需此項)',
exePath: '執行檔',
workdir: '工作目錄',
workdirDesc: '選填。Claude 在此目錄內執行;填入 git 儲存庫可啟用執行前快照回滾。',
workdirPlaceholder: '留空則於暫存目錄執行',
permissionMode: '權限模式',
mode: {
acceptEdits: '放行(可復原操作)',
plan: '唯讀 / 計畫',
default: '預設(逐項確認)',
bypassPermissions: '完全放行(高風險)',
},
promptPlaceholder: '讓 Claude 做點什麼,例如:列出目前目錄的檔名',
run: '執行',
running: '執行中…',
cancel: '取消',
clear: '清空',
riskWarn: '偵測到高風險意圖:{{reason}}。護欄會在執行層攔截高風險指令。',
toolUse: '呼叫工具 {{name}}',
done: '完成',
doneCost: '完成 · 用量 ${{cost}}',
cancelled: '已取消',
outputPlaceholder: '輸出會串流顯示在這裡…',
},
codingAgent: {
title: 'Less Computer',
desc: '按住一個鍵說話,Claude 幫你操作電腦。僅 macOS。',
enable: '啟用 Less Computer',
comingSoonNote: '設定即時儲存;熱鍵觸發與執行鏈路隨後續版本生效。',
hotkeyHint: '開啟後,按住下方快捷鍵說話,放開後 Claude 處理並把結果顯示在膠囊裡。',
voiceHotkey: '按住說話鍵',
voiceHotkeyDesc: '按住說話、放開執行。支援 Ctrl/Option/Fn 等單鍵。',
provider: 'Agent 後端',
providerOpenCodeSoon: 'OpenCode(即將支援)',
panelHotkey: '面板鍵(語音 Agent)',
panelHotkeyDesc: '錄音 → ASR → Claude → 結果串流進面板。預設 Cmd/Ctrl+Shift+Enter。',
quickHotkey: '快取用鍵',
quickHotkeyDesc: '取目前選取文字 → Claude → 結果回插游標處。不開面板、更快。',
model: '模型',
modelPlaceholder: '預設 sonnet',
modelDefault: '預設(自動 sonnet)',
modelHint: 'Haiku 最快 · Sonnet 均衡 · Opus 最強',
},
debug: {
title: '除錯工具',
desc: '排查辨識問題時使用,平時無需開啟。',
@@ -534,6 +605,7 @@ export const zhTW: typeof zhCN = {
comboRecordDesc: '點擊後按下你想要的快捷鍵組合(如 ⌘⇧D),支援 Toggle 與 Hold 模式。',
comboRecordBtn: '錄製快捷鍵',
comboRecordHint: '請按下快捷鍵組合…',
comboNeedKey: '請設定組合鍵(如 ⌘⇧J),不支援單獨的修飾鍵',
comboRecorded: '已錄製',
comboClear: '清除',
comboConflict: '此快捷鍵組合不可用',
@@ -611,6 +683,7 @@ export const zhTW: typeof zhCN = {
openrouterFree: 'OpenRouter(免費模型)',
alibabaCoding: '阿里雲 Coding Plan',
codingPlanX: 'CodingPlanX',
minimax: 'MiniMax(M3)',
custom: '自定義',
asrVolcengine: '火山引擎 bigasr',
asrBailian: '阿里雲百煉即時 ASR',
@@ -619,6 +692,7 @@ export const zhTW: typeof zhCN = {
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper(兼容)',
asrOpenrouter: 'OpenRouter Whisper',
asrXiaomiMimo: '小米 MiMo ASR',
asrSherpaOnnxLocal: '本地 sherpa-onnx(實驗性)',
asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)',
asrLocalQwen3: '本地 Qwen3-ASR',
@@ -685,6 +759,12 @@ export const zhTW: typeof zhCN = {
confirm: '膠囊確認插入',
switchStyle: '切換到上一個風格',
openApp: '打開 OpenLess',
agentPolish: '選取文字潤色',
agentPolishDesc: '選取文字 → 按鍵 → Claude 潤色 → 取代選取。',
agentVoice: 'Less Computer',
agentVoiceDesc: '按住自訂按鍵 → 說話 → Claude 執行任務 → 結果彈膠囊顯示。',
agentVoiceHint: '在「進階 → Less Computer」裡設定按住說話鍵。',
agentVoiceTrigger: 'Less Computer 按住說話鍵',
enable: '啟用',
disable: '停用',
confirmHint: '點擊右側 ✓',
@@ -856,6 +936,10 @@ export const zhTW: typeof zhCN = {
releaseNotes: '發佈日誌',
},
personalize: {
theme: '主題',
themeDesc: '切換應用外觀的淺色與深色版本。',
themeLight: '淺色',
themeDark: '深色',
font: '字體大小',
fontDesc: '整體縮放界面字號,立即生效。',
fontSmall: '小',
+26
View File
@@ -0,0 +1,26 @@
export type AppThemeId = 'light' | 'dark';
export const APP_THEME_KEY = 'ol-app-theme';
export function readAppTheme(): AppThemeId {
try {
const value = window.localStorage.getItem(APP_THEME_KEY);
if (value === 'light' || value === 'dark') return value;
} catch {
// Ignore storage access failures and fall back to the default theme.
}
return 'light';
}
export function applyAppTheme(theme: AppThemeId): void {
document.documentElement.dataset.olTheme = theme;
}
export function setAppTheme(theme: AppThemeId): void {
applyAppTheme(theme);
try {
window.localStorage.setItem(APP_THEME_KEY, theme);
} catch {
// Ignore storage write failures.
}
}
+4
View File
@@ -22,6 +22,10 @@ export function defaultOpenAppShortcut(): ShortcutBinding {
return { primary: 'O', modifiers: defaultAppShortcutModifiers() };
}
export function defaultLessComputerShortcut(): ShortcutBinding {
return { primary: 'LeftControl', modifiers: [] };
}
export function getHotkeyTriggerLabel(trigger: HotkeyTrigger | null | undefined): string {
if (!trigger) return i18n.t('hotkey.fallback');
if (trigger === 'custom') return i18n.t('hotkey.triggers.custom');
+105
View File
@@ -15,6 +15,7 @@ import type {
HotkeyStatus,
MicrophoneDevice,
PermissionStatus,
CodingAgentPermissionMode,
PolishMode,
QaHotkeyBinding,
ShortcutBinding,
@@ -100,6 +101,14 @@ let mockSettings: UserPreferences = {
modifiers: defaultAppShortcutModifiers(),
},
openAppHotkey: { primary: "O", modifiers: defaultAppShortcutModifiers() },
codingAgentEnabled: false,
codingAgentProvider: "claude-code-cli",
codingAgentModel: null,
codingAgentPermissionMode: "acceptEdits",
codingAgentWorkdir: null,
codingAgentVoiceHotkey: { primary: "LeftControl", modifiers: [] },
codingAgentPanelHotkey: { primary: "Enter", modifiers: ["cmd", "shift"] },
codingAgentQuickHotkey: null,
localAsrActiveModel: "qwen3-asr-0.6b",
localAsrMirror: "huggingface",
localAsrKeepLoadedSecs: 300,
@@ -1107,6 +1116,33 @@ export function qaWindowPin(pinned: boolean): Promise<void> {
return invokeOrMock("qa_window_pin", { pinned }, () => undefined)
}
// ── Less Computer 浮窗 ────────────────────────────────────────────────
/** 用户点 ✕ / 按 Esc 关闭 Less Computer 浮窗(隐藏窗口)。 */
export function lessComputerWindowDismiss(): Promise<void> {
return invokeOrMock("less_computer_window_dismiss", undefined, () => undefined)
}
/** 内联审批卡的 Approve / Deny 回执。token 关联到等待中的拦截动作。 */
export function lessComputerApprove(
token: string,
approved: boolean,
): Promise<void> {
return invokeOrMock(
"less_computer_approve",
{ token, approved },
() => undefined,
)
}
/** 前端按内容测高后回传,后端 clamp + bottom-anchored 重新摆放浮窗。 */
export function lessComputerWindowResize(height: number): Promise<void> {
return invokeOrMock(
"less_computer_window_resize",
{ height },
() => undefined,
)
}
// ── Combo Hotkey (自定义录音组合键) ───────────────────────────────────
export function validateComboHotkey(binding: ComboBinding): Promise<void> {
return invokeOrMock("validate_combo_hotkey", { binding }, () => undefined)
@@ -1186,6 +1222,75 @@ export async function exportErrorLog(
export { isTauri }
// ── Coding Agent / Claude 控制台 ───────────────────────────────────────
export type { CodingAgentPermissionMode }
export type McpHealth = "connected" | "failed" | "needs_auth" | "unknown"
export interface McpServerStatus {
name: string
detail: string
health: McpHealth
}
export interface ClaudeDetection {
installed: boolean
version: string | null
exe: string
mcpServers: McpServerStatus[]
hasComputerUse: boolean
}
/** 无头 Claude 运行事件,由后端 `coding-agent:test` 流式推送(tag 为 `kind`)。 */
export type CodingAgentEvent =
| { kind: "started"; session_id: string }
| { kind: "delta"; session_id: string; text: string }
| { kind: "tool_use"; session_id: string; name: string }
| {
kind: "completed"
session_id: string
text: string
cost_usd: number | null
duration_ms: number | null
}
| { kind: "cancelled"; session_id: string }
| { kind: "error"; session_id: string; message: string }
export function codingAgentDetect(exe?: string): Promise<ClaudeDetection> {
return invokeOrMock(
"coding_agent_detect",
{ exe },
() => ({
installed: false,
version: null,
exe: exe || "claude",
mcpServers: [],
hasComputerUse: false,
}),
)
}
export interface CodingAgentRunTestArgs {
prompt: string
exe?: string
permissionMode?: CodingAgentPermissionMode
workdir?: string
model?: string
maxBudgetUsd?: number
}
export function codingAgentRunTest(args: CodingAgentRunTestArgs): Promise<void> {
return invokeOrMock("coding_agent_run_test", { ...args }, () => undefined)
}
export function codingAgentCancelTest(): Promise<void> {
return invokeOrMock("coding_agent_cancel_test", undefined, () => undefined)
}
export function codingAgentCommandRisk(command: string): Promise<string | null> {
return invokeOrMock("coding_agent_command_risk", { command }, () => null)
}
// ── Marketplace (Phase A) ─────────────────────────────────────────────
// 5 个 IPC wrapper —— marketplace-backend HTTP 通过 Rust IPC 转发。Mock fallback
// 让 vite dev 在浏览器里也能预览 UI(返回空列表 / 假数据)。
@@ -49,6 +49,14 @@ const previousPrefs: UserPreferences = {
translationHotkey: { primary: 'Shift', modifiers: [] },
switchStyleHotkey: { primary: 'S', modifiers: ['alt'] },
openAppHotkey: { primary: 'O', modifiers: ['alt'] },
codingAgentEnabled: false,
codingAgentProvider: 'claude-code-cli',
codingAgentModel: null,
codingAgentPermissionMode: 'acceptEdits',
codingAgentWorkdir: null,
codingAgentVoiceHotkey: { primary: 'LeftControl', modifiers: [] },
codingAgentPanelHotkey: { primary: 'Enter', modifiers: ['cmd', 'shift'] },
codingAgentQuickHotkey: null,
localAsrActiveModel: '',
localAsrMirror: 'huggingface',
localAsrKeepLoadedSecs: 300,
+47
View File
@@ -117,6 +117,13 @@ export type QaHotkeyBinding = ShortcutBinding;
/** 自定义录音组合键绑定。当 hotkey.trigger == 'custom' 时使用。 */
export type ComboBinding = ShortcutBinding;
export type CodingAgentProviderId = "claude-code-cli" | "opencode-cli";
export type CodingAgentPermissionMode =
| "plan"
| "default"
| "acceptEdits"
| "bypassPermissions";
/** 模拟粘贴时按下的快捷键。仅 Windows/Linux 生效;macOS 走 AX 直写。
* - ctrlV : 标准粘贴(默认;大多数编辑器、浏览器、IDE)
* - ctrlShiftV : kitty / alacritty / wezterm / gnome-terminal / foot 等终端
@@ -259,6 +266,22 @@ export interface UserPreferences {
switchStyleHotkey: ShortcutBinding | null;
/** 打开 OpenLess 主窗口的全局快捷键。null = 用户已停用(issue #576)。 */
openAppHotkey: ShortcutBinding | null;
/** Less Computer:是否启用。默认关闭。 */
codingAgentEnabled: boolean;
/** Agent 后端:claude-code-cli(默认)/ opencode-cli。 */
codingAgentProvider: CodingAgentProviderId;
/** Agent 模型,null = 运行时取便宜默认(sonnet)。 */
codingAgentModel: string | null;
/** 权限模式:plan/default/acceptEdits/bypassPermissions。 */
codingAgentPermissionMode: CodingAgentPermissionMode;
/** Agent 工作目录,null = 临时目录。 */
codingAgentWorkdir: string | null;
/** Less Computer 按住说话快捷键。null = 停用;目前仅 macOS 显示/生效。 */
codingAgentVoiceHotkey: ShortcutBinding | null;
/** 热键 1:语音 Agent 面板键。null = 停用。 */
codingAgentPanelHotkey: ShortcutBinding | null;
/** 热键 2:快取用键(选中→Claude→回插)。null = 未配置。 */
codingAgentQuickHotkey: ShortcutBinding | null;
/** 本地 Qwen3-ASR 当前激活的模型 id。仅在 activeAsrProvider === 'local-qwen3' 时有意义。 */
localAsrActiveModel: string;
/** 本地模型下载源镜像('huggingface' / 'hf-mirror')。 */
@@ -386,6 +409,28 @@ export interface QaStatePayload {
chunk?: string;
}
/**
* Less Computer 语音 Agent 浮窗事件(窗口 label = "less-computer",事件名
* `less-computer:event`)。后端按 `kind` 标记,前端据此把交互渲染成聊天结构。
*/
export type LessComputerEvent =
/** 一轮用户气泡(语音指令转写)。fresh=true 表示新会话(清空历史);否则追加为后续轮次。 */
| { kind: 'user'; text: string; fresh?: boolean }
/** Agent 启动,进入运行态。 */
| { kind: 'started' }
/** 流式回复增量(来自 CodingAgentEvent::Delta)。 */
| { kind: 'delta'; text: string }
/** 工具调用提示(来自 CodingAgentEvent::ToolUse,如 "Bash")。 */
| { kind: 'tool'; name: string }
/** 内联审批卡:高风险动作被护栏拦下,等用户 Approve / Deny。 */
| { kind: 'approval'; token: string; command: string; reason: string }
/** 运行完成:最终结果 + 成本(美元)。 */
| { kind: 'completed'; text: string; costUsd?: number | null }
/** 用户从胶囊取消正在运行的 Agent。 */
| { kind: 'cancelled' }
/** 运行出错。 */
| { kind: 'error'; message: string };
/** 内置语言列表 — 前端 Settings UI 用,后端只接收原生名字符串拼 prompt。
* 添加新语言时直接在这里加一项(原生名),无需修改后端。 */
export const SUPPORTED_LANGUAGES: readonly string[] = [
@@ -423,6 +468,8 @@ export interface CapsulePayload {
insertedChars: number | null;
/** 当前 session 是否处于翻译模式(用户已按过 Shift)。详见 issue #4。 */
translation: boolean;
/** 当前是否是 Less Computer 会话:处理态文案显示 "using" 而非 "thinking"。 */
operating?: boolean;
}
export interface CredentialsStatus {
+15 -1
View File
@@ -1,7 +1,9 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import { detectOS } from "./components/WindowChrome";
import i18n from "./i18n"; // 副作用:触发 i18next init
import { applyAppTheme, readAppTheme } from "./lib/appTheme";
import "./styles/tokens.css";
import "./styles/global.css";
@@ -11,14 +13,26 @@ const params = new URLSearchParams(window.location.search);
const windowKind = params.get("window");
const isCapsule = windowKind === "capsule";
const isQa = windowKind === "qa";
const isLessComputer = windowKind === "less-computer";
const isLessComputerGlow = windowKind === "less-computer-glow";
const osQuery = params.get("os") as OS | null;
const os = osQuery ?? detectOS();
document.documentElement.dataset.olPlatform = os;
applyAppTheme(readAppTheme());
const root = ReactDOM.createRoot(document.getElementById("root")!);
const renderApp = () => {
root.render(
<React.StrictMode>
<App isCapsule={isCapsule} isQa={isQa} forcedOs={osQuery} />
<App
isCapsule={isCapsule}
isQa={isQa}
isLessComputer={isLessComputer}
isLessComputerGlow={isLessComputerGlow}
forcedOs={os}
/>
</React.StrictMode>,
);
};
@@ -0,0 +1,102 @@
// Less Computer 全屏彩虹边缘亮条(独立窗口 window=less-computer-glow)。
// 只画贴边光带,不铺暗场;彩色弧段沿边缘流动,模拟 Apple Intelligence 的粗细变化。
// 纯视觉:pointer-events:none,后端再 set_ignore_cursor_events(true)。仅 macOS 显示。
const glowCss = `
@property --lcg-angle { syntax: '<angle>'; initial-value: 0deg; inherits: false; }
@keyframes lcg-spin { to { --lcg-angle: 360deg; } }
@keyframes lcg-breathe { 0%, 100% { opacity: .72; } 50% { opacity: .92; } }
@keyframes lcg-flow { 0%, 100% { opacity: .44; } 48% { opacity: .74; } }
html, body, #root { background: transparent !important; margin: 0; height: 100%; overflow: hidden; }
/* 全屏裁剪容器:圆角贴合屏幕物理圆角;overflow:hidden 把外溢模糊裁在屏幕边缘。 */
.lcg-root {
--lcg-spectrum: conic-gradient(from calc(var(--lcg-angle) - 74deg),
#4e9dff 0deg,
#6cc9ff 40deg,
#9d82ff 82deg,
#e77dff 124deg,
#ff7aa8 162deg,
#ff9765 198deg,
#ffe070 236deg,
#bff47a 266deg,
#63e8a2 304deg,
#63d4ff 334deg,
#4e9dff 360deg);
position: fixed;
inset: 0;
pointer-events: none;
overflow: hidden;
border-radius: var(--lcg-radius, 42px);
}
.lcg-edge,
.lcg-flow {
position: absolute;
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
will-change: transform, filter, opacity, --lcg-angle;
}
/* 贴边主光带:只保留边缘亮条,避免在屏幕中央铺暗场或彩雾。 */
.lcg-edge {
inset: -4px;
border-radius: calc(var(--lcg-radius, 42px) + 4px);
padding: 12px;
background: var(--lcg-spectrum);
filter: blur(1.1px) saturate(1.36) brightness(1.08)
drop-shadow(0 0 7px rgba(95, 185, 255, .44))
drop-shadow(0 0 10px rgba(255, 126, 168, .30));
opacity: .84;
animation: lcg-spin 7.5s linear infinite, lcg-breathe 4.8s ease-in-out infinite;
}
/* 彩色粗细流动层:仍然是边缘 ring,不向中间铺开。 */
.lcg-flow {
inset: -7px;
border-radius: calc(var(--lcg-radius, 42px) + 7px);
padding: 18px;
background: conic-gradient(from calc(var(--lcg-angle) + 28deg),
rgba(31,140,255,0) 0deg,
rgba(91,166,255,.74) 28deg,
rgba(167,134,255,.58) 54deg,
rgba(240,92,255,0) 82deg,
rgba(240,92,255,0) 132deg,
rgba(255,138,94,.70) 164deg,
rgba(255,220,103,.52) 192deg,
rgba(217,255,63,0) 222deg,
rgba(217,255,63,0) 266deg,
rgba(100,232,164,.68) 294deg,
rgba(93,210,255,.56) 326deg,
rgba(31,140,255,0) 360deg);
filter: blur(4.5px) saturate(1.42) brightness(1.08);
opacity: .58;
mix-blend-mode: screen;
animation: lcg-spin 6.8s linear infinite reverse, lcg-flow 3.8s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.lcg-edge,
.lcg-flow { animation: none; }
}
`;
if (typeof document !== 'undefined' && !document.getElementById('less-computer-glow-style')) {
const tag = document.createElement('style');
tag.id = 'less-computer-glow-style';
tag.textContent = glowCss;
document.head.appendChild(tag);
}
export function LessComputerGlow() {
return (
<div className="lcg-root" aria-hidden>
<span className="lcg-flow" />
<span className="lcg-edge" />
</div>
);
}
@@ -0,0 +1,647 @@
// LessComputerPanel.tsx — Less Computer 语音 Agent 浮窗(窗口 label = "less-computer")。
//
// 把「按住专用键说话 → Agent 操控电脑」的交互渲染成聊天结构:
// - 用户气泡:语音指令转写(`user` 事件,开启新会话并清空旧内容)。
// - 助手气泡:流式回复(`delta` 累积)+ 工具调用 chip(`tool`)。
// - 内联审批卡(`approval`):高风险动作被护栏拦下时弹 Approve / Deny。
// - 完成(`completed`)落最终结果 + 成本;出错(`error`)红色样式。
//
// 窗口随内容自适应高度(measure content → setSize),不可拖动、置顶、磨砂。
// 仅 macOS 实际触发(后端只在 macOS 注册 Less Computer 热键并 emit 事件)。
// 关闭:Esc / ✕ → less_computer_window_dismiss → 后端隐藏窗口。
import {
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from 'react';
import { useTranslation } from 'react-i18next';
import DOMPurify from 'dompurify';
import {
isTauri,
lessComputerApprove,
lessComputerWindowDismiss,
lessComputerWindowResize,
} from '../lib/ipc';
import type { LessComputerEvent } from '../lib/types';
import { renderQaMarkdown, renderQaPlainText } from '../lib/qaMarkdown';
type RunStatus = 'idle' | 'working' | 'done' | 'error' | 'cancelled';
interface ToolChip {
kind: 'tool';
name: string;
}
interface ApprovalCard {
kind: 'approval';
token: string;
command: string;
reason: string;
/** 用户已点过的结果,决定按钮禁用态。undefined = 待处理。 */
decision?: 'approved' | 'denied';
}
/** 助手回复流里穿插的工具 chip / 审批卡,按到达顺序排列。 */
type Activity = ToolChip | ApprovalCard;
/** 一轮对话:用户一句 + 助手流式回复 + 其间的工具/审批 + 本轮收尾态。连续对话累积成数组。 */
interface Turn {
user: string;
answer: string;
activities: Activity[];
status: RunStatus;
errorMsg: string;
costUsd: number | null;
}
/** 对 turns 数组「最后一轮」做不可变更新。 */
function updateLastTurn(turns: Turn[], fn: (t: Turn) => Turn): Turn[] {
if (turns.length === 0) return turns;
return [...turns.slice(0, -1), fn(turns[turns.length - 1])];
}
const WINDOW_MIN_HEIGHT = 120;
const WINDOW_MAX_HEIGHT = 520;
const TOOLBAR_HEIGHT = 28;
export function LessComputerPanel() {
const { t } = useTranslation();
// 连续对话:每按一次说话键追加一轮(除非后端标记 fresh=新会话则清空重开)。
const [turns, setTurns] = useState<Turn[]>([]);
// ── 后端事件订阅(mount 一次)────────────────────────────────────────
useEffect(() => {
if (!isTauri) return;
let unlisten: (() => void) | undefined;
let cancelled = false;
(async () => {
try {
const { listen } = await import('@tauri-apps/api/event');
const handle = await listen<LessComputerEvent>(
'less-computer:event',
event => applyEvent(event.payload),
);
if (cancelled) handle();
else unlisten = handle;
} catch (error) {
console.error('[LessComputer] listener setup failed', error);
}
})();
return () => {
cancelled = true;
unlisten?.();
};
}, []);
const applyEvent = (ev: LessComputerEvent) => {
switch (ev.kind) {
case 'user': {
// 一轮新对话。fresh=true(后端无可续会话→新会话)则清空历史重开;否则追加为后续轮次。
const fresh: Turn = {
user: ev.text,
answer: '',
activities: [],
status: 'working',
errorMsg: '',
costUsd: null,
};
setTurns(prev => (ev.fresh ? [fresh] : [...prev, fresh]));
break;
}
case 'started':
setTurns(prev => updateLastTurn(prev, tn => ({ ...tn, status: 'working' })));
break;
case 'delta':
setTurns(prev => updateLastTurn(prev, tn => ({ ...tn, answer: tn.answer + ev.text })));
break;
case 'tool':
setTurns(prev =>
updateLastTurn(prev, tn => ({
...tn,
activities: [...tn.activities, { kind: 'tool', name: ev.name }],
})),
);
break;
case 'approval':
setTurns(prev =>
updateLastTurn(prev, tn => ({
...tn,
activities: [
...tn.activities,
{ kind: 'approval', token: ev.token, command: ev.command, reason: ev.reason },
],
})),
);
break;
case 'completed':
setTurns(prev =>
updateLastTurn(prev, tn => ({
...tn,
answer: ev.text || tn.answer,
costUsd: ev.costUsd ?? null,
status: 'done',
})),
);
break;
case 'error':
setTurns(prev => updateLastTurn(prev, tn => ({ ...tn, errorMsg: ev.message, status: 'error' })));
break;
case 'cancelled':
setTurns(prev => updateLastTurn(prev, tn => ({ ...tn, status: 'cancelled' })));
break;
}
};
const onApproval = (token: string, approved: boolean) => {
setTurns(prev =>
prev.map(tn => ({
...tn,
activities: tn.activities.map(a =>
a.kind === 'approval' && a.token === token
? { ...a, decision: approved ? 'approved' : 'denied' }
: a,
),
})),
);
void lessComputerApprove(token, approved);
};
const onClose = () => void lessComputerWindowDismiss();
// ── Esc 关闭 ────────────────────────────────────────────────────────
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
void lessComputerWindowDismiss();
}
};
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
}, []);
// ── 内容自适应:measure 内容高 + toolbar → 回传后端 clamp + bottom-anchored 摆放,
// 让内容增长向上撑开。超出 max 则窗口内部滚动。
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isTauri) return;
const el = contentRef.current;
if (!el) return;
const measured = Math.ceil(el.scrollHeight) + TOOLBAR_HEIGHT;
const target = Math.min(WINDOW_MAX_HEIGHT, Math.max(WINDOW_MIN_HEIGHT, measured));
void lessComputerWindowResize(target);
}, [turns]);
// 自动滚动到底
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [turns]);
return (
<div className="ol-frost lc-shell" style={shellStyle}>
<div className="lc-bg" aria-hidden />
<Toolbar label={t('lessComputer.closeTooltip')} onClose={onClose} />
<div ref={scrollRef} style={scrollStyle}>
<div ref={contentRef} style={contentStyle}>
{turns.map((turn, ti) => (
<TurnView key={ti} turn={turn} onApproval={onApproval} t={t} />
))}
</div>
</div>
</div>
);
}
// ── 子组件 ────────────────────────────────────────────────────────────
/** 渲染单轮对话:用户气泡 → 工具/审批 → 助手流式回复 → 收尾(错误/花费)。 */
function TurnView({
turn,
onApproval,
t,
}: {
turn: Turn;
onApproval: (token: string, approved: boolean) => void;
t: ReturnType<typeof useTranslation>['t'];
}) {
return (
<>
<UserBubble text={turn.user} label={t('lessComputer.you')} />
{turn.activities.map((a, i) =>
a.kind === 'tool' ? (
<ToolChipRow key={`t${i}`} name={a.name} t={t} />
) : (
<ApprovalRow key={a.token} card={a} onDecide={onApproval} t={t} />
),
)}
{turn.answer && <AssistantBubble markdown={turn.answer} working={turn.status === 'working'} />}
{turn.status === 'working' && !turn.answer && <WorkingRow label={t('lessComputer.working')} />}
{turn.status === 'error' && <ErrorRow message={turn.errorMsg || t('lessComputer.error')} />}
{turn.status === 'cancelled' && <CostRow label={t('common.cancelled')} />}
{turn.status === 'done' && turn.costUsd != null && (
<CostRow label={t('lessComputer.cost', { cost: turn.costUsd.toFixed(3) })} />
)}
</>
);
}
function Toolbar({ label, onClose }: { label: string; onClose: () => void }) {
return (
// 顶栏作为拖动把手:按住空白处可把整个聊天框拖到屏幕任意位置(resize 会保住拖后的位置)。
<div data-tauri-drag-region style={{ ...toolbarStyle, cursor: 'grab' }}>
<div data-tauri-drag-region style={{ flex: 1, alignSelf: 'stretch' }} />
<button
onClick={onClose}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
title={label}
aria-label={label}
style={closeBtnStyle}
>
<svg width="11" height="11" viewBox="0 0 11 11">
<path
d="M1.5 1.5l8 8M9.5 1.5l-8 8"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
</button>
</div>
);
}
function UserBubble({ text, label }: { text: string; label: string }) {
return (
<div className="lc-enter" style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 3 }}>
<span style={roleLabelStyle}>{label}</span>
<div style={userBubbleStyle}>{text}</div>
</div>
);
}
function AssistantBubble({ markdown, working }: { markdown: string; working: boolean }) {
const html = useMemo(() => {
let rendered: string;
try {
rendered = renderQaMarkdown(markdown);
} catch (error) {
console.error('[LessComputer] markdown render failed', error);
rendered = renderQaPlainText(String(markdown ?? ''));
}
// 兜底再消毒:qaMarkdown 已转义 raw HTML token,这里 DOMPurify 多一道防线,
// 即便上游渲染逻辑回归也不会把恶意标记注入 DOM。
return DOMPurify.sanitize(rendered, { ADD_ATTR: ['target', 'rel'] });
}, [markdown]);
return (
<div className="lc-enter" style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 3 }}>
<div
className="lc-answer"
style={assistantBubbleStyle}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: html }}
/>
{working && <span style={caretStyle} />}
</div>
);
}
function ToolChipRow({
name,
t,
}: {
name: string;
t: ReturnType<typeof useTranslation>['t'];
}) {
return (
<div className="lc-enter" style={{ display: 'flex' }}>
<span style={toolChipStyle}>
<span aria-hidden style={{ marginRight: 4 }}>
{'\u{1F6E0}'}
</span>
{t('lessComputer.tool', { name })}
</span>
</div>
);
}
function WorkingRow({ label }: { label: string }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={dotStyle} />
<span style={{ fontSize: 12, color: 'var(--ol-ink-3)', fontWeight: 500 }}>{label}</span>
</div>
);
}
function ApprovalRow({
card,
onDecide,
t,
}: {
card: ApprovalCard;
onDecide: (token: string, approved: boolean) => void;
t: ReturnType<typeof useTranslation>['t'];
}) {
const decided = card.decision != null;
return (
<div className="lc-enter" style={approvalCardStyle}>
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--ol-ink)' }}>
{t('lessComputer.approvalTitle')}
</div>
<code style={approvalCmdStyle}>{card.command}</code>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-3)' }}>{card.reason}</div>
{!decided && (
<div style={approvalRerunWarningStyle}>
{t('lessComputer.approvalRerunWarning')}
</div>
)}
{decided ? (
<div style={{ fontSize: 11.5, fontWeight: 600, color: 'var(--ol-ink-3)' }}>
{card.decision === 'approved'
? t('lessComputer.approved')
: t('lessComputer.denied')}
</div>
) : (
<div style={{ display: 'flex', gap: 8 }}>
<button
style={denyBtnStyle}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => onDecide(card.token, false)}
>
{t('lessComputer.deny')}
</button>
<button
style={approveBtnStyle}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => onDecide(card.token, true)}
>
{t('lessComputer.approve')}
</button>
</div>
)}
</div>
);
}
function ErrorRow({ message }: { message: string }) {
return (
<div style={errorRowStyle}>
<span style={{ fontSize: 12.5, color: 'var(--ol-err)', lineHeight: 1.5 }}>{message}</span>
</div>
);
}
function CostRow({ label }: { label: string }) {
return (
<div style={{ display: 'flex' }}>
<span style={costChipStyle}>{label}</span>
</div>
);
}
// ── 样式 ──────────────────────────────────────────────────────────────
const shellStyle: CSSProperties = {
width: '100%',
height: '100vh',
display: 'flex',
flexDirection: 'column',
borderRadius: 14,
overflow: 'hidden',
border: '0.5px solid rgba(0, 0, 0, 0.12)',
background: 'rgba(246, 247, 250, 0.88)',
boxShadow: '0 18px 44px -18px rgba(15,17,22,.28), 0 0 0 0.5px rgba(255,255,255,.7) inset',
fontFamily: 'var(--ol-font-sans)',
color: 'var(--ol-ink)',
isolation: 'isolate',
};
const toolbarStyle: CSSProperties = {
height: 28,
display: 'flex',
alignItems: 'center',
padding: '0 8px',
borderBottom: '0.5px solid rgba(0, 0, 0, 0.08)',
background:
'linear-gradient(180deg, rgba(255,255,255,0.74), rgba(238,240,245,0.58))',
boxShadow: '0 1px 0 rgba(255,255,255,.55) inset',
backdropFilter: 'blur(18px) saturate(150%)',
WebkitBackdropFilter: 'blur(18px) saturate(150%)',
flexShrink: 0,
position: 'relative',
zIndex: 1,
};
const closeBtnStyle: CSSProperties = {
width: 22,
height: 22,
border: 0,
borderRadius: 6,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'default',
padding: 0,
background: 'transparent',
color: 'var(--ol-ink-3)',
transition: 'background 0.16s var(--ol-motion-quick)',
};
const scrollStyle: CSSProperties = {
flex: 1,
minHeight: 0,
overflow: 'auto',
position: 'relative',
zIndex: 1,
};
const contentStyle: CSSProperties = {
padding: 14,
display: 'flex',
flexDirection: 'column',
gap: 10,
};
const roleLabelStyle: CSSProperties = {
fontSize: 10.5,
color: 'var(--ol-ink-4)',
fontWeight: 600,
};
const userBubbleStyle: CSSProperties = {
maxWidth: '85%',
padding: '8px 12px',
borderRadius: 14,
borderBottomRightRadius: 4,
background: 'var(--ol-blue)',
color: '#fff',
fontSize: 13,
lineHeight: 1.55,
wordBreak: 'break-word',
};
const assistantBubbleStyle: CSSProperties = {
maxWidth: '92%',
padding: '8px 12px',
borderRadius: 14,
borderBottomLeftRadius: 4,
background: 'rgba(0,0,0,0.04)',
fontSize: 13,
lineHeight: 1.6,
color: 'var(--ol-ink)',
wordBreak: 'break-word',
alignSelf: 'flex-start',
};
const caretStyle: CSSProperties = {
display: 'inline-block',
width: 6,
height: 12,
background: 'var(--ol-blue)',
marginLeft: 12,
animation: 'lc-pulse 0.9s var(--ol-motion-soft) infinite',
borderRadius: 1,
};
const toolChipStyle: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
fontSize: 11.5,
fontWeight: 500,
color: 'var(--ol-ink-2)',
background: 'rgba(0,0,0,0.045)',
border: '0.5px solid rgba(0,0,0,0.06)',
borderRadius: 8,
padding: '4px 8px',
};
const costChipStyle: CSSProperties = {
fontSize: 11,
fontWeight: 500,
color: 'var(--ol-ink-4)',
fontFamily: 'var(--ol-font-mono)',
};
const dotStyle: CSSProperties = {
width: 8,
height: 8,
borderRadius: '50%',
background: 'var(--ol-blue)',
animation: 'lc-pulse 1.2s var(--ol-motion-soft) infinite',
};
const approvalCardStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 6,
padding: '10px 12px',
borderRadius: 12,
background: 'rgba(220,38,38,0.05)',
border: '0.5px solid rgba(220,38,38,0.20)',
};
const approvalCmdStyle: CSSProperties = {
fontFamily: 'var(--ol-font-mono)',
fontSize: 11.5,
color: 'var(--ol-ink)',
background: 'rgba(0,0,0,0.05)',
borderRadius: 6,
padding: '5px 8px',
wordBreak: 'break-all',
};
const approvalRerunWarningStyle: CSSProperties = {
fontSize: 11,
lineHeight: 1.45,
color: 'rgb(180,83,9)',
background: 'rgba(245,158,11,0.10)',
border: '0.5px solid rgba(245,158,11,0.25)',
borderRadius: 6,
padding: '5px 8px',
};
const approveBtnStyle: CSSProperties = {
flex: 1,
border: 0,
borderRadius: 8,
padding: '6px 10px',
fontSize: 12,
fontWeight: 600,
cursor: 'default',
background: 'var(--ol-blue)',
color: '#fff',
};
const denyBtnStyle: CSSProperties = {
flex: 1,
borderRadius: 8,
padding: '6px 10px',
fontSize: 12,
fontWeight: 600,
cursor: 'default',
background: 'transparent',
border: '0.5px solid rgba(0,0,0,0.14)',
color: 'var(--ol-ink-2)',
};
const errorRowStyle: CSSProperties = {
padding: '8px 12px',
borderRadius: 10,
background: 'rgba(220,38,38,0.06)',
border: '0.5px solid rgba(220,38,38,0.18)',
};
const globalCss = `
@keyframes lc-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(.94); }
}
/* 内容进场:工具芯片 / 气泡 / 审批卡出现时柔和淡入上滑,而不是直接闪出。 */
@keyframes lc-enter {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.lc-shell { position: relative; }
.lc-bg {
position: absolute;
inset: -1px;
border-radius: inherit;
background:
radial-gradient(120% 80% at 18% 0%, rgba(255,255,255,.72), rgba(255,255,255,0) 58%),
linear-gradient(180deg, rgba(248,249,252,.78), rgba(235,238,244,.72));
pointer-events: none;
z-index: 0;
}
.lc-enter { animation: lc-enter 0.30s var(--ol-motion-soft, cubic-bezier(.16,1,.3,1)) both; }
@media (prefers-reduced-motion: reduce) {
.lc-enter { animation: none; }
}
.lc-answer p { margin: 0 0 6px; }
.lc-answer p:last-child { margin-bottom: 0; }
.lc-answer ul,
.lc-answer ol { margin: 0 0 6px; padding-left: 18px; }
.lc-answer li { margin: 2px 0; }
.lc-answer code { font-family: var(--ol-font-mono); font-size: 12px;
padding: 1px 5px; border-radius: 4px;
background: rgba(0,0,0,0.05); }
.lc-answer pre { margin: 0 0 6px; padding: 8px 10px;
border-radius: 8px; background: rgba(0,0,0,0.05);
overflow-x: auto; }
.lc-answer pre code { padding: 0; background: transparent; }
.lc-answer a { color: var(--ol-blue); text-decoration: none; }
`;
if (typeof document !== 'undefined' && !document.getElementById('less-computer-panel-style')) {
const tag = document.createElement('style');
tag.id = 'less-computer-panel-style';
tag.textContent = globalCss;
document.head.appendChild(tag);
}
+14 -6
View File
@@ -31,6 +31,7 @@ const ASR_NAME_KEY_BY_ID: Record<string, string> = {
groq: 'asrGroq',
whisper: 'asrWhisper',
openrouter: 'asrOpenrouter',
'xiaomi-mimo-asr': 'asrXiaomiMimo',
'foundry-local-whisper': 'asrFoundryLocalWhisper',
'sherpa-onnx-local': 'asrSherpaOnnxLocal',
'local-qwen3': 'asrLocalQwen3',
@@ -169,9 +170,16 @@ export function Overview({ onOpenHistory }: OverviewProps) {
return (
<>
<PageHeader title={t('overview.title')} />
<PageHeader
kicker="AURA OVERVIEW"
title={t('overview.title')}
desc={t('overview.metricTotalTrend')}
/>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 18 }}>
<div
className="ol-overview-hero"
style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginBottom: 20 }}
>
<ProviderCard
kind={t('overview.asrKind')}
name={asrProviderName}
@@ -186,7 +194,7 @@ export function Overview({ onOpenHistory }: OverviewProps) {
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12, marginBottom: 18 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 14, marginBottom: 20 }}>
<Metric icon="hash" label={t('overview.metricChars')} value={historyError ? '—' : metrics.charsToday.toLocaleString()} trend={historyError ? t('overview.historyLoadError') : t('overview.metricSegments', { count: metrics.segmentsToday })} />
<Metric icon="mic" label={t('overview.metricDuration')} value={historyError ? '—' : formatDuration(metrics.totalDurationMs, t)} trend={historyError ? t('overview.historyLoadError') : ''} />
<Metric icon="clock" label={t('overview.metricAvg')} value={historyError ? '—' : formatDuration(metrics.avgLatencyMs, t)} trend={historyError ? t('overview.historyLoadError') : metrics.segmentsToday > 0 ? t('overview.metricAvgTrend') : t('overview.metricNoData')} />
@@ -196,8 +204,8 @@ export function Overview({ onOpenHistory }: OverviewProps) {
{/* 底部一行 = flex:1 撑满剩余高度(父 wrapper 是 display:flex/column)。
只有「最近识别」内部允许滚动;其他卡片按内容自然高度,不破裂底部圆角。
issue #243 follow-up:去掉外层 overflow 后底部圆角被裁的视觉问题。 */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.4fr', gap: 12, flex: 1, minHeight: 0 }}>
<Card padding={18} style={{ display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.4fr', gap: 14, flex: 1, minHeight: 0 }}>
<Card className="ol-overview-hero" padding={20} style={{ display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-2)' }}>{t('overview.weekTitle')}</span>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>{t('overview.weekUnit')}</span>
@@ -214,7 +222,7 @@ export function Overview({ onOpenHistory }: OverviewProps) {
</div>
</Card>
<Card padding={0} style={{ display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
<Card className="ol-overview-hero" padding={0} style={{ display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
<div style={{ padding: '14px 18px', borderBottom: '0.5px solid var(--ol-line)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-2)' }}>{t('overview.recentTitle')}</span>
<Btn size="sm" variant="ghost" onClick={onOpenHistory}>{t('overview.recentAll')}</Btn>
+2 -3
View File
@@ -611,9 +611,8 @@ function truncate(text: string, max: number): string {
// ── 样式 ──────────────────────────────────────────────────────────────
// 假毛玻璃外壳:玻璃质感(体渐变 + 高光扫面 + 噪点颗粒)全部由 .ol-frost 提供;
// 这里只管布局 + 内描边高光 + 柔和阴影。webview 模糊不了透明窗口背后的桌面
// (Tauri 上游限制),所以不写 background / backdrop-filter。
// 非 Linux 走 .ol-frost 假毛玻璃;Linux 禁用透明窗口后退成不透明面。
// 这里只管布局 + 内描边高光 + 柔和阴影,所以不写 background / backdrop-filter。
const shellStyle: CSSProperties = {
width: '100%',
height: '100vh',
+101 -48
View File
@@ -551,7 +551,20 @@ export function Style() {
/>
)}
<Card padding={0} style={{ overflow: 'hidden', flex: '1 1 0', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<Card
padding={0}
glassy
style={{
overflow: 'hidden',
flex: '1 1 0',
minHeight: 0,
display: 'flex',
flexDirection: 'column',
background: 'var(--ol-style-shell-bg)',
border: '1px solid var(--ol-style-shell-border)',
boxShadow: 'var(--ol-style-shell-shadow)',
}}
>
<div style={{ padding: 18, borderBottom: '0.5px solid var(--ol-line)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', minWidth: 0 }}>
@@ -572,13 +585,16 @@ export function Style() {
padding: '6px 12px',
borderRadius: 999,
border: '0.5px solid',
borderColor: rawPack.active ? 'var(--ol-blue)' : 'var(--ol-line-strong)',
background: rawPack.active ? 'var(--ol-blue-soft)' : 'transparent',
borderColor: rawPack.active ? 'var(--ol-blue)' : 'var(--ol-style-subtle-border)',
background: rawPack.active ? 'rgba(96,165,250,0.18)' : 'var(--ol-style-subtle-bg)',
color: rawPack.active ? 'var(--ol-blue)' : 'var(--ol-ink-2)',
fontSize: 12.5,
fontWeight: rawPack.active ? 600 : 500,
whiteSpace: 'nowrap',
cursor: rawPack.active ? 'default' : 'pointer',
backdropFilter: 'blur(18px) saturate(150%)',
WebkitBackdropFilter: 'blur(18px) saturate(150%)',
boxShadow: 'var(--ol-style-subtle-shadow)',
transition: 'border-color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)',
}}
>
@@ -591,7 +607,7 @@ export function Style() {
</div>
</div>
<div className="ol-thinscroll" style={{ padding: 18, overflow: 'auto', flex: '1 1 0', minHeight: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(220px, 100%), 1fr))', gap: 12 }}>
<AnimatePresence mode="sync">
{bodyPacks.map(pack => {
const isBuiltin = pack.kind === 'builtin';
@@ -612,24 +628,23 @@ export function Style() {
flexDirection: 'column',
textAlign: 'left',
position: 'relative',
border: '0.5px solid',
borderColor: pack.active ? 'var(--ol-blue)' : 'var(--ol-line)',
background: pack.active
? 'linear-gradient(180deg, rgba(239,246,255,0.92), rgba(255,255,255,0.98))'
: isBuiltin
? 'linear-gradient(180deg, rgba(248,250,252,0.92), rgba(241,245,249,0.85))'
: 'linear-gradient(180deg, rgba(255,255,255,0.98), rgba(248,250,252,0.92))',
border: '1px solid',
borderColor: pack.active ? 'var(--ol-style-card-border-active)' : 'var(--ol-style-card-border)',
background: pack.active ? 'var(--ol-style-card-bg-active)' : 'var(--ol-style-card-bg)',
backdropFilter: 'blur(28px) saturate(165%)',
WebkitBackdropFilter: 'blur(28px) saturate(165%)',
borderRadius: 18,
padding: 16,
boxShadow: pack.active ? '0 0 0 3px var(--ol-blue-ring)' : 'none',
boxShadow: pack.active ? 'var(--ol-style-card-shadow-active)' : 'var(--ol-style-card-shadow)',
cursor: 'default',
minHeight: 204,
overflow: 'hidden',
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 10, marginBottom: 10 }}>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontSize: 14, fontWeight: 600, color: isBuiltin && !pack.active ? 'var(--ol-ink-2)' : 'var(--ol-ink)' }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--ol-style-card-ink)' }}>
{pack.name}
</div>
<Pill tone={isBuiltin ? 'outline' : 'blue'} size="sm">
@@ -646,7 +661,7 @@ export function Style() {
<div
style={{
fontSize: 12.5,
color: 'var(--ol-ink-3)',
color: 'var(--ol-style-card-ink-3)',
lineHeight: 1.6,
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
@@ -665,9 +680,11 @@ export function Style() {
style={{
width: 36, height: 36, borderRadius: 12,
display: 'grid', placeItems: 'center',
background: pack.active ? 'rgba(37,99,235,0.12)' : 'rgba(15,23,42,0.05)',
color: pack.active ? 'var(--ol-blue)' : 'var(--ol-ink-3)',
background: pack.active ? 'var(--ol-style-card-icon-bg-active)' : 'var(--ol-style-card-icon-bg)',
color: pack.active ? 'var(--ol-blue)' : 'var(--ol-style-card-ink-3)',
flexShrink: 0,
backdropFilter: 'blur(14px)',
WebkitBackdropFilter: 'blur(14px)',
}}
>
<Icon name="sparkle" size={16} />
@@ -683,8 +700,10 @@ export function Style() {
width: 36, height: 36, borderRadius: 12,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
border: '0.5px solid rgba(239,68,68,0.32)',
background: 'rgba(254,242,242,0.6)',
border: '1px solid var(--ol-style-delete-border)',
background: 'var(--ol-style-delete-bg)',
backdropFilter: 'blur(14px)',
WebkitBackdropFilter: 'blur(14px)',
color: 'var(--ol-red, #ef4444)',
cursor: busy === 'deleting' ? 'wait' : 'pointer',
opacity: busy === 'deleting' ? 0.55 : 1,
@@ -755,10 +774,12 @@ export function Style() {
justifyContent: 'center',
gap: 8,
textAlign: 'center',
border: '0.5px dashed var(--ol-line-strong)',
border: '1px dashed var(--ol-style-add-tile-border)',
borderRadius: 18,
padding: 16,
background: 'transparent',
background: 'var(--ol-style-add-tile-bg)',
backdropFilter: 'blur(24px) saturate(150%)',
WebkitBackdropFilter: 'blur(24px) saturate(150%)',
color: 'var(--ol-ink-3)',
cursor: busy === 'creating' ? 'wait' : 'pointer',
opacity: busy === 'creating' ? 0.55 : 1,
@@ -770,8 +791,10 @@ export function Style() {
style={{
width: 44, height: 44, borderRadius: 999,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(15,23,42,0.04)',
background: 'var(--ol-style-add-tile-icon-bg)',
color: 'var(--ol-ink-2)',
backdropFilter: 'blur(14px)',
WebkitBackdropFilter: 'blur(14px)',
}}
>
<Icon name="plus" size={22} />
@@ -827,7 +850,9 @@ export function Style() {
display: 'grid',
gridTemplateRows: 'auto minmax(0, 1fr)',
overflow: 'hidden',
boxShadow: '0 24px 80px rgba(15,23,42,0.22)',
background: 'var(--ol-style-editor-bg)',
border: '1px solid var(--ol-style-editor-border)',
boxShadow: 'var(--ol-style-editor-shadow)',
}}
>
<div style={{ padding: 18, borderBottom: '0.5px solid var(--ol-line)' }}>
@@ -990,8 +1015,11 @@ export function Style() {
<Card
padding={16}
style={{
background: 'linear-gradient(180deg, rgba(255,255,255,0.98), rgba(246,248,252,0.95))',
border: '0.5px solid rgba(148,163,184,0.24)',
background: 'var(--ol-style-subtle-bg)',
border: '1px solid var(--ol-style-subtle-border)',
boxShadow: 'var(--ol-style-subtle-shadow)',
backdropFilter: 'blur(20px) saturate(150%)',
WebkitBackdropFilter: 'blur(20px) saturate(150%)',
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 12 }}>
@@ -1057,9 +1085,12 @@ export function Style() {
<div
style={{
padding: 14,
borderRadius: 14,
background: 'linear-gradient(180deg, rgba(248,250,252,0.98), rgba(241,245,249,0.95))',
border: '0.5px solid var(--ol-line)',
borderRadius: 16,
background: 'var(--ol-style-subtle-bg)',
border: '1px solid var(--ol-style-subtle-border)',
boxShadow: 'var(--ol-style-subtle-shadow)',
backdropFilter: 'blur(18px) saturate(145%)',
WebkitBackdropFilter: 'blur(18px) saturate(145%)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
@@ -1095,7 +1126,11 @@ export function Style() {
key={`${draft.id}-example-${index}`}
padding={16}
style={{
background: 'linear-gradient(180deg, rgba(255,255,255,0.98), rgba(248,250,252,0.98))',
background: 'var(--ol-style-subtle-bg)',
border: '1px solid var(--ol-style-subtle-border)',
boxShadow: 'var(--ol-style-subtle-shadow)',
backdropFilter: 'blur(18px) saturate(145%)',
WebkitBackdropFilter: 'blur(18px) saturate(145%)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
@@ -1128,10 +1163,13 @@ export function Style() {
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 12 }}>
<div
style={{
borderRadius: 14,
border: '0.5px solid rgba(148,163,184,0.22)',
background: 'rgba(248,250,252,0.9)',
borderRadius: 16,
border: '1px solid var(--ol-style-subtle-border)',
background: 'var(--ol-style-subtle-bg)',
padding: 14,
boxShadow: 'var(--ol-style-subtle-shadow)',
backdropFilter: 'blur(16px) saturate(145%)',
WebkitBackdropFilter: 'blur(16px) saturate(145%)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
@@ -1140,16 +1178,19 @@ export function Style() {
<textarea
value={example.input}
onChange={event => patchExample(index, { input: event.target.value })}
style={{ ...textareaStyle, minHeight: 120, background: '#fff' }}
style={{ ...textareaStyle, minHeight: 120 }}
/>
</div>
<div
style={{
borderRadius: 14,
border: '0.5px solid rgba(37,99,235,0.16)',
background: 'rgba(239,246,255,0.86)',
borderRadius: 16,
border: '1px solid rgba(96,165,250,0.24)',
background: 'color-mix(in srgb, var(--ol-style-subtle-bg) 82%, rgba(96,165,250,0.16) 18%)',
padding: 14,
boxShadow: 'var(--ol-style-subtle-shadow)',
backdropFilter: 'blur(16px) saturate(145%)',
WebkitBackdropFilter: 'blur(16px) saturate(145%)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
@@ -1158,7 +1199,7 @@ export function Style() {
<textarea
value={example.output}
onChange={event => patchExample(index, { output: event.target.value })}
style={{ ...textareaStyle, minHeight: 120, background: '#fff' }}
style={{ ...textareaStyle, minHeight: 120 }}
/>
</div>
</div>
@@ -1180,10 +1221,13 @@ function MetaItem({ label, value }: { label: string; value: string }) {
return (
<div
style={{
borderRadius: 12,
border: '0.5px solid rgba(148,163,184,0.2)',
background: 'rgba(255,255,255,0.92)',
borderRadius: 14,
border: '1px solid var(--ol-style-subtle-border)',
background: 'var(--ol-style-subtle-bg)',
padding: '10px 12px',
boxShadow: 'var(--ol-style-subtle-shadow)',
backdropFilter: 'blur(16px) saturate(145%)',
WebkitBackdropFilter: 'blur(16px) saturate(145%)',
}}
>
<div style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: '.08em', color: 'var(--ol-ink-4)', marginBottom: 6 }}>
@@ -1217,9 +1261,12 @@ function DirectiveRow({
justifyContent: 'space-between',
gap: 12,
padding: '10px 12px',
borderRadius: 12,
border: '0.5px solid rgba(148,163,184,0.2)',
background: 'rgba(255,255,255,0.92)',
borderRadius: 14,
border: '1px solid var(--ol-style-subtle-border)',
background: 'var(--ol-style-subtle-bg)',
boxShadow: 'var(--ol-style-subtle-shadow)',
backdropFilter: 'blur(16px) saturate(145%)',
WebkitBackdropFilter: 'blur(16px) saturate(145%)',
}}
>
<div style={{ minWidth: 0 }}>
@@ -1238,24 +1285,30 @@ const inputStyle: CSSProperties = {
boxSizing: 'border-box',
minHeight: 38,
padding: '9px 11px',
borderRadius: 10,
border: '0.5px solid var(--ol-line-strong)',
background: '#fff',
borderRadius: 12,
border: '1px solid var(--ol-style-input-border)',
background: 'var(--ol-style-input-bg)',
color: 'var(--ol-ink)',
font: 'inherit',
fontSize: 12.5,
boxShadow: 'var(--ol-style-input-shadow)',
backdropFilter: 'blur(16px) saturate(145%)',
WebkitBackdropFilter: 'blur(16px) saturate(145%)',
};
const textareaStyle: CSSProperties = {
width: '100%',
boxSizing: 'border-box',
padding: '11px 12px',
borderRadius: 12,
border: '0.5px solid var(--ol-line-strong)',
background: '#fff',
borderRadius: 14,
border: '1px solid var(--ol-style-input-border)',
background: 'var(--ol-style-input-bg)',
color: 'var(--ol-ink)',
font: 'inherit',
fontSize: 12.5,
lineHeight: 1.65,
resize: 'vertical',
boxShadow: 'var(--ol-style-input-shadow)',
backdropFilter: 'blur(16px) saturate(145%)',
WebkitBackdropFilter: 'blur(16px) saturate(145%)',
};
+53 -25
View File
@@ -15,16 +15,39 @@ interface PageHeaderProps {
export function PageHeader({ kicker, title, desc, right, titleRight }: PageHeaderProps) {
return (
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 24, marginBottom: 24 }}>
<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: '.08em', textTransform: 'uppercase', color: 'var(--ol-ink-4)', marginBottom: 8 }}>{kicker}</div>
<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: 26, fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--ol-ink)' }}>{title}</h1>
<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: '8px 0 0', fontSize: 13, color: 'var(--ol-ink-3)', maxWidth: 640, lineHeight: 1.55 }}>{desc}</p>}
{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>
@@ -42,15 +65,15 @@ interface CardProps {
export function Card({ children, style, padding = 18, glassy = false, className }: CardProps) {
return (
<div
className={className}
className={['ol-aura-card', className].filter(Boolean).join(' ')}
style={{
background: glassy ? 'rgba(255,255,255,0.55)' : 'var(--ol-surface)',
backdropFilter: glassy ? 'blur(20px) saturate(160%)' : undefined,
WebkitBackdropFilter: glassy ? 'blur(20px) saturate(160%)' : undefined,
border: '0.5px solid var(--ol-line)',
borderRadius: 'var(--ol-r-lg)',
background: glassy ? 'var(--ol-panel-bg)' : 'var(--ol-card-bg)',
backdropFilter: glassy ? 'blur(var(--ol-aura-glass-blur)) saturate(150%)' : undefined,
WebkitBackdropFilter: glassy ? 'blur(var(--ol-aura-glass-blur)) saturate(150%)' : undefined,
border: `1px solid ${glassy ? 'var(--ol-panel-border)' : 'var(--ol-card-border)'}`,
borderRadius: 'var(--ol-card-radius)',
padding,
boxShadow: 'var(--ol-shadow-sm)',
boxShadow: glassy ? 'var(--ol-panel-shadow)' : 'var(--ol-card-shadow)',
...style,
}}
>
@@ -71,16 +94,16 @@ interface PillProps {
export function Pill({ children, tone = 'default', size = 'md', style }: PillProps) {
const tones: Record<PillTone, { bg: string; color: string; bd: string }> = {
default: { bg: 'rgba(0,0,0,0.05)', color: 'var(--ol-ink-2)', bd: 'transparent' },
blue: { bg: 'var(--ol-blue-soft)',color: 'var(--ol-blue)', bd: 'transparent' },
ok: { bg: 'var(--ol-ok-soft)', color: 'var(--ol-ok)', bd: 'transparent' },
outline: { bg: 'transparent', color: 'var(--ol-ink-3)', bd: 'var(--ol-line-strong)' },
dark: { bg: 'var(--ol-ink)', color: '#fff', bd: 'transparent' },
default: { bg: 'var(--ol-pill-bg)', color: 'var(--ol-ink-2)', bd: 'var(--ol-pill-border)' },
blue: { bg: 'var(--ol-pill-blue-bg)', color: 'var(--ol-blue)', bd: 'var(--ol-pill-blue-border)' },
ok: { bg: 'var(--ol-pill-ok-bg)', color: 'var(--ol-ok)', bd: 'var(--ol-pill-ok-border)' },
outline: { bg: 'var(--ol-pill-bg)', color: 'var(--ol-ink-3)', bd: 'var(--ol-pill-border)' },
dark: { bg: 'var(--ol-pill-dark-bg)', color: 'var(--ol-on-accent)', bd: 'var(--ol-pill-dark-border)' },
};
const t = tones[tone];
const sz = size === 'sm'
? { padding: '2px 8px', fontSize: 10.5 }
: { padding: '4px 10px', fontSize: 11.5 };
? { padding: '3px 10px', fontSize: 11 }
: { padding: '5px 12px', fontSize: 11.5 };
return (
<span
style={{
@@ -88,8 +111,13 @@ export function Pill({ children, tone = 'default', size = 'md', style }: PillPro
borderRadius: 999,
background: t.bg,
color: t.color,
border: t.bd === 'transparent' ? '0.5px solid transparent' : `0.5px solid ${t.bd}`,
fontWeight: 500,
border: `1px solid ${t.bd}`,
boxShadow: tone === 'dark' ? 'var(--ol-pill-dark-shadow)' : 'var(--ol-pill-shadow)',
backdropFilter: 'blur(16px) saturate(145%)',
WebkitBackdropFilter: 'blur(16px) saturate(145%)',
fontWeight: 600,
letterSpacing: '.01em',
lineHeight: 1.2,
whiteSpace: 'nowrap',
flexShrink: 0,
...sz,
@@ -116,10 +144,10 @@ interface BtnProps {
export function Btn({ children, variant = 'ghost', size = 'md', icon, style, onClick, disabled = false }: BtnProps) {
const variants: Record<BtnVariant, { bg: string; color: string; bd: string; sh: string }> = {
primary: { bg: 'var(--ol-ink)', color: '#fff', bd: 'transparent', sh: '0 1px 2px rgba(0,0,0,.08)' },
blue: { bg: 'var(--ol-blue)', color: '#fff', bd: 'transparent', sh: '0 1px 2px rgba(37,99,235,.18)' },
ghost: { bg: 'transparent', color: 'var(--ol-ink-2)', bd: 'var(--ol-line-strong)', sh: 'none' },
soft: { bg: 'rgba(0,0,0,0.04)', color: 'var(--ol-ink-2)', bd: 'transparent', sh: 'none' },
primary: { bg: 'linear-gradient(180deg, #141821, #0d1016)', color: 'var(--ol-on-accent)', bd: 'transparent', sh: '0 14px 32px -22px rgba(15,23,42,0.55)' },
blue: { bg: 'linear-gradient(180deg, var(--ol-blue), var(--ol-blue-hover))', color: 'var(--ol-on-accent)', bd: 'transparent', sh: '0 14px 32px -22px rgba(37,99,235,.45)' },
ghost: { bg: 'var(--ol-control-muted)', color: 'var(--ol-ink-2)', bd: 'var(--ol-control-border)', sh: 'var(--ol-control-shadow)' },
soft: { bg: 'var(--ol-control-muted-strong)', color: 'var(--ol-ink-2)', bd: 'transparent', sh: 'var(--ol-control-shadow)' },
};
const v = variants[variant];
const sizes: Record<BtnSize, { padding: string; fontSize: number }> = {
@@ -134,7 +162,7 @@ export function Btn({ children, variant = 'ghost', size = 'md', icon, style, onC
display: 'inline-flex', alignItems: 'center', gap: 6,
background: v.bg, color: v.color,
border: v.bd === 'transparent' ? '0.5px solid transparent' : `0.5px solid ${v.bd}`,
borderRadius: 8,
borderRadius: 999,
boxShadow: v.sh,
fontFamily: 'inherit', fontWeight: 500,
cursor: disabled ? 'not-allowed' : 'pointer',
@@ -9,6 +9,7 @@ import { Icon } from '../../components/Icon';
import { Row } from '../../components/ui/Row';
import { openExternal } from '../../lib/ipc';
import { APP_VERSION_LABEL } from '../../lib/appVersion';
import { readAppTheme, setAppTheme, type AppThemeId } from '../../lib/appTheme';
import { readFontScale, setFontScale, type FontScaleId } from '../../lib/fontScale';
import { Card } from '../_atoms';
import { SectionTitle } from './shared';
@@ -54,6 +55,7 @@ export function AboutSection() {
{/* ─── 个性化(字体大小)—— 原 personalize tab 并入此处 ──────────── */}
<Card>
<SectionTitle>{t('modal.sections.personalize')}</SectionTitle>
<ThemeRow />
<FontSizeRow />
</Card>
@@ -95,6 +97,51 @@ export function AboutSection() {
);
}
function ThemeRow() {
const { t } = useTranslation();
const [theme, setThemeState] = useState<AppThemeId>(() => readAppTheme());
const themeOptions: Array<[AppThemeId, string]> = [
['light', t('modal.personalize.themeLight')],
['dark', t('modal.personalize.themeDark')],
];
return (
<Row label={t('modal.personalize.theme')} desc={t('modal.personalize.themeDesc')}>
<div style={{ display: 'flex', gap: 4, padding: 2, background: 'var(--ol-segmented-bg)', borderRadius: 10 }}>
{themeOptions.map(([id, label]) => {
const selected = theme === id;
return (
<button
key={id}
onClick={() => {
setThemeState(id);
setAppTheme(id);
}}
style={{
minWidth: 74,
height: 30,
border: 0,
borderRadius: 8,
background: selected ? 'var(--ol-segmented-active-bg)' : 'transparent',
color: selected ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
fontFamily: 'inherit',
fontSize: 12,
fontWeight: selected ? 600 : 500,
cursor: 'default',
boxShadow: selected ? 'var(--ol-segmented-active-shadow)' : 'none',
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), box-shadow 0.18s var(--ol-motion-soft)',
padding: '0 12px',
}}
>
{label}
</button>
);
})}
</div>
</Row>
);
}
// 字体大小 —— 整体缩放界面字号,立即生效(fontScale.ts 走 html.style.zoom)。
function FontSizeRow() {
const { t } = useTranslation();
@@ -109,8 +156,8 @@ function FontSizeRow() {
['large', t('modal.personalize.fontLarge')],
];
return (
<Row label={t('modal.personalize.font')}>
<div style={{ display: 'flex', gap: 4, padding: 2, background: 'rgba(0,0,0,0.04)', borderRadius: 8 }}>
<Row label={t('modal.personalize.font')} desc={t('modal.personalize.fontDesc')}>
<div style={{ display: 'flex', gap: 4, padding: 2, background: 'var(--ol-segmented-bg)', borderRadius: 10 }}>
{fontOptions.map(([id, label]) => {
const selected = fontScale === id;
return (
@@ -121,14 +168,14 @@ function FontSizeRow() {
minWidth: 64,
height: 28,
border: 0,
borderRadius: 6,
background: selected ? '#fff' : 'transparent',
borderRadius: 8,
background: selected ? 'var(--ol-segmented-active-bg)' : 'transparent',
color: selected ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
fontFamily: 'inherit',
fontSize: 12,
fontWeight: selected ? 600 : 500,
cursor: 'default',
boxShadow: selected ? '0 1px 2px rgba(0,0,0,.06), 0 0 0 0.5px rgba(0,0,0,.06)' : 'none',
boxShadow: selected ? 'var(--ol-segmented-active-shadow)' : 'none',
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), box-shadow 0.18s var(--ol-motion-soft)',
padding: '0 12px',
}}
@@ -145,7 +192,7 @@ function FontSizeRow() {
const btnGhost: CSSProperties = {
padding: '5px 10px', fontSize: 12, borderRadius: 6,
border: '0.5px solid var(--ol-line-strong)',
background: '#fff', color: 'var(--ol-ink-2)',
background: 'var(--ol-control-solid)', color: 'var(--ol-ink-2)',
cursor: 'default', fontFamily: 'inherit',
transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)',
};
@@ -72,7 +72,7 @@ export function CheckUpdateButton({ channel }: { channel: UpdateChannel }) {
const checkBtnStyle: CSSProperties = {
padding: '5px 10px', fontSize: 12, borderRadius: 6,
border: '0.5px solid var(--ol-line-strong)',
background: '#fff',
background: 'var(--ol-control-solid)',
cursor: 'default', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6,
minWidth: 84,
@@ -0,0 +1,292 @@
// 高级 → Claude 控制台:检测 claude 安装 / MCP(computer use)状态,
// 并护栏化地无头跑一次 claude、流式查看输出与用量。这是「快速 Agent」引擎的
// 最小可用垂直切片,不依赖录音 / coordinator。
import { useEffect, useRef, useState, type CSSProperties } from 'react'
import { useTranslation } from 'react-i18next'
import {
codingAgentCancelTest,
codingAgentCommandRisk,
codingAgentDetect,
codingAgentRunTest,
isTauri,
type ClaudeDetection,
type CodingAgentEvent,
type CodingAgentPermissionMode,
} from '../../lib/ipc'
import { Btn, Card } from '../_atoms'
import { SectionDesc, SectionTitle, SettingRow, inputStyle } from './shared'
const PERMISSION_MODES: CodingAgentPermissionMode[] = [
'acceptEdits',
'plan',
'default',
'bypassPermissions',
]
const consoleStyle: CSSProperties = {
margin: 0,
marginTop: 10,
padding: '12px 14px',
minHeight: 96,
maxHeight: 260,
overflow: 'auto',
borderRadius: 10,
background: '#0f1117',
color: '#d7dbe0',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
fontSize: 12,
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}
export function ClaudeConsoleSection() {
const { t } = useTranslation()
const [detection, setDetection] = useState<ClaudeDetection | null>(null)
const [detecting, setDetecting] = useState(false)
const [exe, setExe] = useState('claude')
const [workdir, setWorkdir] = useState('')
const [permMode, setPermMode] = useState<CodingAgentPermissionMode>('acceptEdits')
const [prompt, setPrompt] = useState('')
const [running, setRunning] = useState(false)
const [output, setOutput] = useState('')
const [summary, setSummary] = useState<string | null>(null)
const [risk, setRisk] = useState<string | null>(null)
// 控制台默认折叠:测试用的重型 UI(检测/输入/输出)平时不展开,保持设置页清爽。
const [expanded, setExpanded] = useState(false)
const outRef = useRef<HTMLPreElement | null>(null)
async function runDetect() {
setDetecting(true)
try {
setDetection(await codingAgentDetect(exe.trim() || undefined))
} finally {
setDetecting(false)
}
}
// 订阅后端流式事件。
useEffect(() => {
if (!isTauri) return
let unlisten: (() => void) | undefined
let alive = true
void (async () => {
const { listen } = await import('@tauri-apps/api/event')
const un = await listen<CodingAgentEvent>('coding-agent:test', e => {
const ev = e.payload
switch (ev.kind) {
case 'started':
setOutput('')
setSummary(null)
break
case 'delta':
setOutput(prev => prev + ev.text)
break
case 'tool_use':
setOutput(prev => `${prev}\n· ${t('settings.codingConsole.toolUse', { name: ev.name })}\n`)
break
case 'completed':
setRunning(false)
setSummary(
ev.cost_usd != null
? t('settings.codingConsole.doneCost', { cost: ev.cost_usd.toFixed(4) })
: t('settings.codingConsole.done'),
)
if (ev.text.trim()) setOutput(prev => (prev.trim() ? prev : ev.text))
break
case 'cancelled':
setRunning(false)
setSummary(t('settings.codingConsole.cancelled'))
break
case 'error':
setRunning(false)
setSummary(`✗ ${ev.message}`)
break
}
})
if (alive) unlisten = un
else un()
})()
return () => {
alive = false
unlisten?.()
}
}, [t])
// 首次自动检测。
useEffect(() => {
void runDetect()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 输出自动滚到底。
useEffect(() => {
if (outRef.current) outRef.current.scrollTop = outRef.current.scrollHeight
}, [output])
const onRun = async () => {
const p = prompt.trim()
if (!p || running) return
setRisk(await codingAgentCommandRisk(p))
setRunning(true)
setOutput('')
setSummary(null)
try {
await codingAgentRunTest({
prompt: p,
exe: exe.trim() || undefined,
permissionMode: permMode,
workdir: workdir.trim() || undefined,
maxBudgetUsd: 0.5,
})
} catch (err) {
setRunning(false)
setSummary(`✗ ${err instanceof Error ? err.message : String(err)}`)
}
}
const installed = detection?.installed === true
return (
<Card>
<button
onClick={() => setExpanded(v => !v)}
style={{ all: 'unset', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}
>
<SectionTitle>{t('settings.codingConsole.title')}</SectionTitle>
<span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--ol-ink-4)' }}>
{expanded ? `${t('common.hide')} ▴` : `${t('common.show')} ▾`}
</span>
</button>
<SectionDesc>{t('settings.codingConsole.desc')}</SectionDesc>
{expanded && (
<>
<SettingRow label={t('settings.codingConsole.status')} desc={t('settings.codingConsole.guardNote')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<Btn variant="ghost" size="sm" disabled={detecting} onClick={() => void runDetect()}>
{detecting ? t('settings.codingConsole.detecting') : t('settings.codingConsole.detect')}
</Btn>
{detection && (
<span style={{ fontSize: 12, color: installed ? 'var(--ol-ok)' : 'var(--ol-err)' }}>
{installed
? `${t('settings.codingConsole.installed')} · v${detection.version ?? '?'}`
: t('settings.codingConsole.notInstalled')}
</span>
)}
</div>
{detection && !installed && (
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.5 }}>
{t('settings.codingConsole.notInstalledHint')}
</span>
)}
{detection && installed && (
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.5 }}>
{t('settings.codingConsole.mcpServers', { count: detection.mcpServers.length })}
{' · '}
{detection.hasComputerUse
? t('settings.codingConsole.computerUsePresent')
: t('settings.codingConsole.computerUseAbsent')}
</span>
)}
</div>
</SettingRow>
<SettingRow label={t('settings.codingConsole.exePath')}>
<input
type="text"
value={exe}
placeholder="claude"
spellCheck={false}
onChange={e => setExe(e.target.value)}
style={inputStyle}
/>
</SettingRow>
<SettingRow label={t('settings.codingConsole.workdir')} desc={t('settings.codingConsole.workdirDesc')}>
<input
type="text"
value={workdir}
placeholder={t('settings.codingConsole.workdirPlaceholder')}
spellCheck={false}
onChange={e => setWorkdir(e.target.value)}
style={inputStyle}
/>
</SettingRow>
<SettingRow label={t('settings.codingConsole.permissionMode')}>
<select
value={permMode}
onChange={e => setPermMode(e.target.value as CodingAgentPermissionMode)}
style={{ ...inputStyle, maxWidth: 220, cursor: 'pointer' }}
>
{PERMISSION_MODES.map(m => (
<option key={m} value={m}>
{t(`settings.codingConsole.mode.${m}`)}
</option>
))}
</select>
</SettingRow>
<div style={{ paddingTop: 14, borderTop: '0.5px solid var(--ol-line-soft)' }}>
<textarea
value={prompt}
placeholder={t('settings.codingConsole.promptPlaceholder')}
rows={3}
spellCheck={false}
onChange={e => setPrompt(e.target.value)}
style={{
...inputStyle,
maxWidth: '100%',
height: 'auto',
padding: '10px 12px',
resize: 'vertical',
lineHeight: 1.5,
}}
/>
{risk && (
<div style={{ marginTop: 8, fontSize: 11.5, color: 'var(--ol-err)', lineHeight: 1.5 }}>
⚠ {t('settings.codingConsole.riskWarn', { reason: risk })}
</div>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 10, alignItems: 'center' }}>
<Btn
variant="primary"
size="sm"
disabled={!installed || running || prompt.trim() === ''}
onClick={() => void onRun()}
>
{running ? t('settings.codingConsole.running') : t('settings.codingConsole.run')}
</Btn>
{running && (
<Btn variant="ghost" size="sm" onClick={() => void codingAgentCancelTest()}>
{t('settings.codingConsole.cancel')}
</Btn>
)}
{!running && output !== '' && (
<Btn
variant="ghost"
size="sm"
onClick={() => {
setOutput('')
setSummary(null)
}}
>
{t('settings.codingConsole.clear')}
</Btn>
)}
{summary && (
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginLeft: 'auto' }}>{summary}</span>
)}
</div>
<pre ref={outRef} style={consoleStyle}>
{output || t('settings.codingConsole.outputPlaceholder')}
</pre>
</div>
</>
)}
</Card>
)
}
@@ -0,0 +1,119 @@
// 高级 → Less Computer 配置:启用开关、后端(Claude / OpenCode)、模型 / 权限模式 / 工作目录。
// 「按住说话键」在 通用 → 快捷键 里配置(见 ShortcutsSection),这里不再重复。
// 配置经 UserPreferences 持久化;启用后 coordinator 才注册热键。
import { useTranslation } from 'react-i18next'
import { detectOS } from '../../components/WindowChrome'
import type { CodingAgentPermissionMode, CodingAgentProviderId } from '../../lib/types'
import { useHotkeySettings } from '../../state/HotkeySettingsContext'
import { Card } from '../_atoms'
import { SectionDesc, SectionTitle, SettingRow, Toggle, inputStyle } from './shared'
const PERMISSION_MODES: CodingAgentPermissionMode[] = [
'acceptEdits',
'plan',
'default',
'bypassPermissions',
]
export function CodingAgentSection() {
const { t } = useTranslation()
const { prefs, updatePrefs: savePrefs } = useHotkeySettings()
const os = detectOS()
if (os === 'win') return null
if (!prefs) {
return (
<Card>
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>
</Card>
)
}
const enabled = prefs.codingAgentEnabled
return (
<Card>
<SectionTitle>{t('settings.codingAgent.title')}</SectionTitle>
<SectionDesc>{t('settings.codingAgent.desc')}</SectionDesc>
<SettingRow label={t('settings.codingAgent.enable')} desc={t('settings.codingAgent.hotkeyHint')}>
<Toggle
on={enabled}
onToggle={next => void savePrefs({ ...prefs, codingAgentEnabled: next })}
/>
</SettingRow>
{enabled && (
<>
{/* 「按住说话键」配置已挪到 通用 → 快捷键,避免和这里重复。本区只留后端/模型等高级项。 */}
<SettingRow label={t('settings.codingAgent.provider')}>
<select
value={prefs.codingAgentProvider}
onChange={e =>
void savePrefs({
...prefs,
codingAgentProvider: e.target.value as CodingAgentProviderId,
})
}
style={{ ...inputStyle, maxWidth: 240, cursor: 'pointer' }}
>
<option value="claude-code-cli">Claude Code</option>
<option value="opencode-cli">{t('settings.codingAgent.providerOpenCodeSoon')}</option>
</select>
</SettingRow>
<SettingRow label={t('settings.codingConsole.permissionMode')}>
<select
value={prefs.codingAgentPermissionMode}
onChange={e =>
void savePrefs({
...prefs,
codingAgentPermissionMode: e.target.value as CodingAgentPermissionMode,
})
}
style={{ ...inputStyle, maxWidth: 240, cursor: 'pointer' }}
>
{PERMISSION_MODES.map(m => (
<option key={m} value={m}>
{t(`settings.codingConsole.mode.${m}`)}
</option>
))}
</select>
</SettingRow>
<SettingRow label={t('settings.codingAgent.model')} desc={t('settings.codingAgent.modelHint')}>
<select
value={prefs.codingAgentModel ?? ''}
onChange={e => {
const v = e.target.value
void savePrefs({ ...prefs, codingAgentModel: v === '' ? null : v })
}}
style={{ ...inputStyle, maxWidth: 240, cursor: 'pointer' }}
>
<option value="">{t('settings.codingAgent.modelDefault')}</option>
<option value="haiku">Haiku</option>
<option value="sonnet">Sonnet</option>
<option value="opus">Opus</option>
</select>
</SettingRow>
<SettingRow label={t('settings.codingConsole.workdir')} desc={t('settings.codingConsole.workdirDesc')}>
<input
type="text"
value={prefs.codingAgentWorkdir ?? ''}
placeholder={t('settings.codingConsole.workdirPlaceholder')}
spellCheck={false}
onChange={e => {
const v = e.target.value.trim()
void savePrefs({ ...prefs, codingAgentWorkdir: v === '' ? null : v })
}}
style={inputStyle}
/>
</SettingRow>
</>
)}
</Card>
)
}
@@ -116,6 +116,18 @@ const LLM_PRESETS = [
baseUrl: 'https://api.codingplanx.ai/v1',
modelPlaceholder: 'gpt-5-mini',
},
{
// MiniMax 国内开放平台(minimaxi.com),OpenAI 兼容 /v1/chat/completions。
// M3 默认开启 thinking,可通过 `thinking.type = disabled` 关闭。
// provider_id 在后端 polish.rs::openai_compatible_thinking_control 命中
// "minimax" → MiniMaxThinking 分支,关闭时下发 disabled、开启时发 adaptive。
// 走"自定义"preset 接入时由 base_url 含 "minimax" 兜底识别,见 polish.rs。
// 文档: https://platform.minimaxi.com/docs/api-reference/text-chat-openai#thinking-控制
id: 'minimax',
nameKey: 'minimax',
baseUrl: 'https://api.minimaxi.com/v1',
modelPlaceholder: 'MiniMax-M3',
},
{
id: 'custom',
nameKey: 'custom',
@@ -132,7 +144,8 @@ const ASR_DEFAULT_RESOURCE_ID = 'volc.seedasr.sauc.duration';
// `/audio/transcriptions`(`coordinator.rs::is_whisper_compatible_provider`)。
// 新增兼容厂商:
// 1. 在这里加一项 `{ id, nameKey, baseUrl, model }`;
// 2. `coordinator.rs::is_whisper_compatible_provider` 加同名 id;
// 2. 若走 Whisper 协议,`coordinator.rs::is_whisper_compatible_provider` 加同名 id;
// 若是专有协议,新增独立 ASR client 与 provider kind;
// 3. 在 i18n 的 `settings.providers.presets.<nameKey>` 加文案。
// `AsrPresetId` 定义在 settings/shared.tsx,LocalModelSection / ProvidersSection 共用同一份。
const ASR_PRESETS: ReadonlyArray<{ id: AsrPresetId; nameKey: string; baseUrl: string; model: string }> = [
@@ -145,6 +158,9 @@ const ASR_PRESETS: ReadonlyArray<{ id: AsrPresetId; nameKey: string; baseUrl: st
// OpenRouter 的 /audio/transcriptions 走 application/json + base64(issue #582),
// 后端 coordinator.rs::whisper_request_format 对该 id 切换到 OpenRouterJson 编码。
{ id: 'openrouter', nameKey: 'asrOpenrouter', baseUrl: 'https://openrouter.ai/api/v1', model: 'openai/whisper-large-v3-turbo' },
// 小米 MiMo ASR 按官方文档走 /chat/completions + input_audio,不是
// Whisper /audio/transcriptions;后端由 asr/mimo.rs 专用 client 处理。
{ id: 'xiaomi-mimo-asr', nameKey: 'asrXiaomiMimo', baseUrl: 'https://api.xiaomimimo.com/v1', model: 'mimo-v2.5-asr' },
{ id: 'foundry-local-whisper', nameKey: 'asrFoundryLocalWhisper', baseUrl: '', model: '' },
// 本地引擎(Foundry / sherpa-onnx / Qwen3):无 baseUrl/model 配置,
// 模型在「高级 → 本地模型」里下载与切换。
@@ -3,7 +3,7 @@
import type { CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { ShortcutRecorder } from '../../components/ShortcutRecorder';
import { defaultOpenAppShortcut, defaultQaShortcut, defaultSwitchStyleShortcut } from '../../lib/hotkey';
import { defaultLessComputerShortcut, defaultOpenAppShortcut, defaultQaShortcut, defaultSwitchStyleShortcut } from '../../lib/hotkey';
import {
setDictationHotkey,
setOpenAppHotkey,
@@ -29,18 +29,6 @@ const enableBtnStyle: CSSProperties = {
cursor: 'pointer',
};
const disableBtnStyle: CSSProperties = {
alignSelf: 'flex-start',
fontSize: 11,
padding: '3px 10px',
background: 'transparent',
color: 'var(--ol-ink-4)',
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 6,
fontFamily: 'inherit',
cursor: 'pointer',
};
export function ShortcutsSection() {
const { t } = useTranslation();
const os = detectOS();
@@ -95,6 +83,10 @@ export function ShortcutsSection() {
await setQaHotkey(binding);
await savePrefs({ ...prefs, qaHotkey: binding });
}}
onDisable={async () => {
await setQaHotkey(null);
await savePrefs({ ...prefs, qaHotkey: null });
}}
/>
) : (
<button
@@ -111,25 +103,18 @@ export function ShortcutsSection() {
</SettingRow>
<SettingRow label={t('settings.shortcuts.switchStyle')}>
{prefs.switchStyleHotkey ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, width: '100%' }}>
<ShortcutRecorder
value={prefs.switchStyleHotkey}
alignRecordButton
onSave={async binding => {
await setSwitchStyleHotkey(binding);
await savePrefs({ ...prefs, switchStyleHotkey: binding });
}}
/>
<button
onClick={async () => {
await setSwitchStyleHotkey(null);
await savePrefs({ ...prefs, switchStyleHotkey: null });
}}
style={disableBtnStyle}
>
{t('settings.shortcuts.disable', 'Disable')}
</button>
</div>
<ShortcutRecorder
value={prefs.switchStyleHotkey}
alignRecordButton
onSave={async binding => {
await setSwitchStyleHotkey(binding);
await savePrefs({ ...prefs, switchStyleHotkey: binding });
}}
onDisable={async () => {
await setSwitchStyleHotkey(null);
await savePrefs({ ...prefs, switchStyleHotkey: null });
}}
/>
) : (
<button
onClick={async () => {
@@ -145,25 +130,18 @@ export function ShortcutsSection() {
</SettingRow>
<SettingRow label={t('settings.shortcuts.openApp')}>
{prefs.openAppHotkey ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, width: '100%' }}>
<ShortcutRecorder
value={prefs.openAppHotkey}
alignRecordButton
onSave={async binding => {
await setOpenAppHotkey(binding);
await savePrefs({ ...prefs, openAppHotkey: binding });
}}
/>
<button
onClick={async () => {
await setOpenAppHotkey(null);
await savePrefs({ ...prefs, openAppHotkey: null });
}}
style={disableBtnStyle}
>
{t('settings.shortcuts.disable', 'Disable')}
</button>
</div>
<ShortcutRecorder
value={prefs.openAppHotkey}
alignRecordButton
onSave={async binding => {
await setOpenAppHotkey(binding);
await savePrefs({ ...prefs, openAppHotkey: binding });
}}
onDisable={async () => {
await setOpenAppHotkey(null);
await savePrefs({ ...prefs, openAppHotkey: null });
}}
/>
) : (
<button
onClick={async () => {
@@ -177,6 +155,35 @@ export function ShortcutsSection() {
</button>
)}
</SettingRow>
{os === 'mac' && (
<SettingRow label={t('settings.codingAgent.title')} desc={t('settings.codingAgent.voiceHotkeyDesc')}>
{prefs.codingAgentEnabled && prefs.codingAgentVoiceHotkey ? (
<ShortcutRecorder
value={prefs.codingAgentVoiceHotkey}
alignRecordButton
onSave={async binding => {
await savePrefs({ ...prefs, codingAgentVoiceHotkey: binding });
}}
onDisable={async () => {
await savePrefs({ ...prefs, codingAgentVoiceHotkey: null });
}}
/>
) : (
<button
onClick={() =>
void savePrefs({
...prefs,
codingAgentEnabled: true,
codingAgentVoiceHotkey: prefs.codingAgentVoiceHotkey ?? defaultLessComputerShortcut(),
})
}
style={enableBtnStyle}
>
{t('settings.shortcuts.enable', 'Enable')}
</button>
)}
</SettingRow>
)}
{readonlyRows.map(([k, v]) => (
<SettingRow key={k} label={k}>
<kbd style={{
@@ -130,7 +130,7 @@ export function Toggle({
height: 20,
borderRadius: 999,
border: 0,
background: on ? "var(--ol-blue)" : "rgba(0,0,0,0.15)",
background: on ? "var(--ol-blue)" : "var(--ol-toggle-off-bg)",
boxShadow: "inset 0 1px 2px rgba(0,0,0,0.06)",
cursor: "default",
transition: "background 0.16s var(--ol-motion-quick)",
@@ -144,7 +144,7 @@ export function Toggle({
width: 16,
height: 16,
borderRadius: 999,
background: "#fff",
background: "var(--ol-toggle-knob)",
boxShadow:
"0 1px 2px rgba(0,0,0,.25), 0 0 0 0.5px rgba(0,0,0,.04)",
transition: "left .16s var(--ol-motion-spring)",
@@ -163,7 +163,7 @@ export const inputStyle: CSSProperties = {
fontSize: 12.5,
fontFamily: "inherit",
outline: "none",
background: "var(--ol-surface-2)",
background: "var(--ol-control-elevated)",
width: "100%",
maxWidth: 360,
transition:
@@ -180,6 +180,7 @@ export type AsrPresetId =
| "groq"
| "whisper"
| "openrouter"
| "xiaomi-mimo-asr"
| "foundry-local-whisper"
| "sherpa-onnx-local"
| "local-qwen3"
@@ -12,7 +12,10 @@ import { PermissionsSection } from './PermissionsSection';
import { DataStorageSection } from './DataStorageSection';
import { LocalModelSection } from './LocalModelSection';
import { DebugToolsSection } from './DebugToolsSection';
import { CodingAgentSection } from './CodingAgentSection';
import { ClaudeConsoleSection } from './ClaudeConsoleSection';
import { BetaChannelSection } from './BetaChannelSection';
import { detectOS } from '../../components/WindowChrome';
// 通用:录音与输入 · 快捷键 · 语言。
export function GeneralTab() {
@@ -70,10 +73,13 @@ export function PrivacyTab() {
// 高级:本地模型 · 调试工具 · 加入 Beta 渠道(固定在最下面)。
export function AdvancedTab() {
const os = detectOS();
return (
<>
<LocalModelSection />
<DebugToolsSection />
{os !== 'win' && <CodingAgentSection />}
{os !== 'win' && <ClaudeConsoleSection />}
<BetaChannelSection />
</>
);
+30 -48
View File
@@ -7,10 +7,16 @@ html, body, #root {
overflow: hidden;
}
html[data-ol-platform="linux"],
html[data-ol-platform="linux"] body,
html[data-ol-platform="linux"] #root {
background: var(--ol-surface);
}
body {
background: transparent;
user-select: none;
-webkit-user-select: none;
background: var(--ol-bg-base);
}
#root {
@@ -64,6 +70,24 @@ input, textarea {
a { color: inherit; text-decoration: none; }
.ol-app-shell-bg {
background: var(--ol-app-shell-bg);
}
.ol-aura-panel {
background: var(--ol-panel-bg);
backdrop-filter: blur(var(--ol-aura-glass-blur)) saturate(150%);
-webkit-backdrop-filter: blur(var(--ol-aura-glass-blur)) saturate(150%);
border: 1px solid var(--ol-panel-border);
box-shadow: var(--ol-panel-shadow);
}
.ol-aura-card {
background: var(--ol-card-bg);
border: 1px solid var(--ol-card-border);
box-shadow: var(--ol-card-shadow);
}
@keyframes ol-window-enter {
from {
opacity: 0;
@@ -133,7 +157,7 @@ a { color: inherit; text-decoration: none; }
是 Tauri 已知的上游限制(issue #2827 / #10064 / #6876)。所以这里**不做
真实高斯模糊**:宿主元素保留半透明白底,这层 ::before 叠一张细颗粒噪点纹理
(磨砂质感来源);内描边高光与柔和阴影由宿主自带的 box-shadow 提供。
纯静态 CSS,零运行时 / 动画开销,macOS / Windows / Linux 像素一致。
纯静态 CSS,零运行时 / 动画开销。Linux 主动禁用透明窗口后走纯不透明面。
宿主元素自带 border-radius 即可,::before 噪点层 inherit 圆角;宿主不要再设
backdrop-filter(死代码)。 */
.ol-frost {
@@ -164,52 +188,10 @@ a { color: inherit; text-decoration: none; }
pointer-events: none;
}
/* ── Linux no-compositing fallback ──────────────────────────────────────
WEBKIT_DISABLE_COMPOSITING_MODE=1 时 backdrop-filter 不可用,用噪点纹理
替代磨砂效果。data-ol-no-compositing 由 App.tsx 在初始化时设置。
!important 是因为三处目标的 backdrop-filter / background 均为 inline style
(WindowChrome.tsx / FloatingShell.tsx 的 style={...}),CSS class 必须
用 !important 才能覆盖。 */
[data-ol-no-compositing] .ol-winchrome {
position: relative;
isolation: isolate;
/* !important 覆盖 WindowChrome.tsx inline backdropFilter */
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
/* 提高透明梯度不透明度,补偿缺少模糊的视觉深度
注意:此三层的渐变参数与 WindowChrome.tsx 的 inline background 同步,
改动那边时请同步更新这里。 */
background:
radial-gradient(120% 80% at 0% 0%, rgba(255,255,255,0.55) 0%, rgba(255,255,255,0) 60%),
radial-gradient(100% 70% at 100% 100%, rgba(37,99,235,0.07) 0%, rgba(37,99,235,0) 55%),
linear-gradient(180deg, rgba(245,245,247,0.96) 0%, rgba(232,232,236,0.96) 100%) !important;
html[data-ol-platform="linux"] .ol-frost {
background: var(--ol-surface);
}
[data-ol-no-compositing] .ol-winchrome::after {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
/* 灰阶噪点颗粒,低 opacity 模拟磨砂质感 */
background-image: var(--ol-frost-grain);
background-size: 100px 100px;
opacity: 0.20;
pointer-events: none;
}
[data-ol-no-compositing] .ol-console-main {
/* !important 覆盖 FloatingShell.tsx inline backdropFilter */
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
background: rgba(255, 255, 255, 0.80) !important;
}
[data-ol-no-compositing] .ol-linux-titlebar {
/* !important 覆盖 WindowChrome.tsx LinuxTitlebar inline backdropFilter */
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
background: rgba(245, 245, 247, 0.95) !important;
html[data-ol-platform="linux"] .ol-frost::before {
content: none;
}
+263 -13
View File
@@ -1,15 +1,26 @@
/* OpenLess design tokens — black + white + electric blue accent, glassy */
/* OpenLess design tokens with Aura-specific extensions. */
:root {
/* Palette — neutrals */
/* Shared palette */
--ol-white: #ffffff;
--ol-canvas: #f7f7f8; /* outer chrome wash */
--ol-canvas: #f7f7f8;
--ol-surface: #ffffff;
--ol-surface-2: #fafafa;
--ol-surface-solid: #ffffff;
--ol-line: rgba(0, 0, 0, 0.08);
--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;
@@ -22,19 +33,20 @@
--ol-blue-hover: #1d4ed8;
--ol-blue-soft: #eff4ff;
--ol-blue-ring: rgba(37, 99, 235, 0.22);
--ol-on-accent: #ffffff;
/* Glass */
/* Shared glass */
--ol-glass-bg: rgba(255, 255, 255, 0.62);
--ol-glass-bg-strong: rgba(255, 255, 255, 0.78);
--ol-glass-border: rgba(255, 255, 255, 0.7);
--ol-glass-blur: 20px;
--ol-glass-blur-strong: 36px;
/* 假毛玻璃噪点 —— qa 追问框 / capsule 语音胶囊等透明浮窗用。详见 global.css .ol-frost。
关键点:feColorMatrix 用 matrix 把分形噪点**去色 + 强制 alpha=1**(不透明灰度噪点)。
早期版本用 saturate=0 保留了 feTurbulence 的随机 alpha,噪点本身就半透明,再叠
::before 的低 opacity,等于洗没了 —— 看不出磨砂颗粒。现在用不透明灰度噪点,由
::before 的 opacity 单独、可控地决定颗粒强度。 */
/* 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");
/* Motion */
@@ -47,25 +59,263 @@
--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;
--ol-panel-radius: 28px;
--ol-card-radius: 22px;
--ol-pill-radius: 999px;
--ol-r-sm: 6px;
--ol-r-md: 10px;
--ol-r-lg: 14px;
--ol-r-xl: 18px;
--ol-r-2xl: 22px;
--ol-r-pill: 999px;
--ol-r-2xl: var(--ol-card-radius);
--ol-r-pill: var(--ol-pill-radius);
/* Typography */
--ol-font-display: "Aptos", "Segoe UI Variable Display", "PingFang SC", "Microsoft YaHei", sans-serif;
--ol-font-sans: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
--ol-font-mono: "SF Mono", "Cascadia Code", Consolas, monospace;
/* Status colors (sparse use only) */
/* Status colors */
--ol-ok: #16a34a;
--ol-ok-soft: #ecfdf5;
--ol-warn: #d97706;
--ol-warn-soft: #fff7ed;
--ol-err: #dc2626;
/* Theme-aware shell and control tokens */
--ol-app-shell-bg:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0) 38%),
radial-gradient(circle at 86% 18%, rgba(47, 109, 246, 0.08), rgba(47, 109, 246, 0) 30%),
linear-gradient(180deg, var(--ol-bg-elevated) 0%, var(--ol-bg-base) 100%);
--ol-window-bg:
radial-gradient(120% 80% at 0% 0%, rgba(255,255,255,0.55) 0%, rgba(255,255,255,0) 60%),
radial-gradient(100% 70% at 100% 100%, rgba(37,99,235,0.07) 0%, rgba(37,99,235,0) 55%),
linear-gradient(180deg, rgba(245,245,247,0.92) 0%, rgba(232,232,236,0.92) 100%);
--ol-window-border: rgba(0, 0, 0, 0.10);
--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-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-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);
--ol-sidebar-brand-bg: linear-gradient(180deg, rgba(255,255,255,0.72), rgba(255,255,255,0.36));
--ol-sidebar-brand-border: rgba(255,255,255,0.50);
--ol-sidebar-pill-bg: linear-gradient(180deg, rgba(255,255,255,0.84), rgba(255,255,255,0.58));
--ol-sidebar-pill-border: rgba(255,255,255,0.62);
--ol-sidebar-footer-border: rgba(255,255,255,0.36);
--ol-sidebar-version-bg: linear-gradient(180deg, rgba(255,255,255,0.68), rgba(255,255,255,0.34));
--ol-sidebar-version-border: rgba(255,255,255,0.56);
--ol-sidebar-settings-bg: linear-gradient(180deg, rgba(255,255,255,0.48), rgba(255,255,255,0.24));
--ol-sidebar-settings-border: rgba(255,255,255,0.50);
--ol-sidebar-settings-active-bg: linear-gradient(180deg, rgba(255,255,255,0.82), rgba(255,255,255,0.48));
--ol-nav-hover-bg: rgba(0, 0, 0, 0.04);
--ol-settings-rail-bg: linear-gradient(180deg, rgba(255,255,255,0.58), rgba(246,248,252,0.72));
--ol-settings-rail-border: rgba(255,255,255,0.52);
--ol-settings-links-border: rgba(255,255,255,0.46);
--ol-settings-content-bg: linear-gradient(180deg, rgba(255,255,255,0.56), rgba(249,250,252,0.74));
--ol-settings-close-bg: rgba(255,255,255,0.38);
--ol-settings-close-hover-bg: rgba(255,255,255,0.72);
--ol-control-muted: rgba(255,255,255,0.42);
--ol-control-muted-strong: rgba(255,255,255,0.58);
--ol-control-border: rgba(255,255,255,0.60);
--ol-control-solid: var(--ol-surface);
--ol-control-elevated: var(--ol-surface-2);
--ol-control-shadow: 0 10px 20px -18px rgba(15,23,42,0.2);
--ol-pill-bg: linear-gradient(180deg, rgba(255,255,255,0.84), rgba(245,247,250,0.68));
--ol-pill-border: rgba(148,163,184,0.22);
--ol-pill-shadow: inset 0 1px 0 0 rgba(255,255,255,0.72), 0 10px 20px -18px rgba(15,23,42,0.18);
--ol-pill-blue-bg: rgba(96,165,250,0.14);
--ol-pill-blue-border: rgba(96,165,250,0.24);
--ol-pill-ok-bg: rgba(74,222,128,0.14);
--ol-pill-ok-border: rgba(74,222,128,0.22);
--ol-pill-dark-bg: linear-gradient(180deg, rgba(28,34,45,0.92), rgba(18,23,31,0.88));
--ol-pill-dark-border: rgba(15,23,42,0.10);
--ol-pill-dark-shadow: 0 12px 20px -18px rgba(15,23,42,0.22);
--ol-segmented-bg: rgba(0,0,0,0.04);
--ol-segmented-active-bg: #ffffff;
--ol-segmented-active-shadow: 0 1px 2px rgba(0,0,0,.06), 0 0 0 0.5px rgba(0,0,0,.06);
--ol-toggle-off-bg: rgba(0,0,0,0.15);
--ol-toggle-knob: #ffffff;
--ol-overlay-bg: rgba(15,17,22,0.32);
--ol-style-shell-bg: linear-gradient(180deg, rgba(255,255,255,0.84), rgba(249,250,252,0.72));
--ol-style-shell-border: rgba(255,255,255,0.72);
--ol-style-shell-shadow: 0 32px 80px -40px rgba(15, 23, 42, 0.26);
--ol-style-card-bg: linear-gradient(180deg, rgba(255,255,255,0.96), rgba(247,249,252,0.90));
--ol-style-card-bg-active: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,255,0.94));
--ol-style-card-border: rgba(255,255,255,0.82);
--ol-style-card-border-active: rgba(96,165,250,0.82);
--ol-style-card-shadow: 0 24px 48px -34px rgba(15, 23, 42, 0.18), inset 0 1px 0 0 rgba(255,255,255,0.65);
--ol-style-card-shadow-active: 0 24px 56px -28px rgba(37,99,235,0.28), 0 0 0 1px rgba(96,165,250,0.78), 0 0 0 4px rgba(96,165,250,0.16), inset 0 1px 0 0 rgba(255,255,255,0.72);
--ol-style-card-ink: #0f172a;
--ol-style-card-ink-2: rgba(15, 23, 42, 0.82);
--ol-style-card-ink-3: rgba(15, 23, 42, 0.56);
--ol-style-card-icon-bg: rgba(226,232,240,0.62);
--ol-style-card-icon-bg-active: rgba(96,165,250,0.14);
--ol-style-delete-bg: rgba(255,255,255,0.56);
--ol-style-delete-border: rgba(248,113,113,0.36);
--ol-style-add-tile-bg: rgba(255,255,255,0.12);
--ol-style-add-tile-icon-bg: rgba(255,255,255,0.48);
--ol-style-add-tile-border: rgba(255,255,255,0.32);
--ol-style-input-bg: rgba(255,255,255,0.86);
--ol-style-input-border: rgba(255,255,255,0.74);
--ol-style-input-shadow: inset 0 1px 0 0 rgba(255,255,255,0.84), 0 10px 24px -22px rgba(15,23,42,0.16);
--ol-style-subtle-bg: rgba(255,255,255,0.74);
--ol-style-subtle-border: rgba(255,255,255,0.62);
--ol-style-subtle-shadow: inset 0 1px 0 0 rgba(255,255,255,0.74);
--ol-style-editor-bg: linear-gradient(180deg, rgba(255,255,255,0.92), rgba(247,249,252,0.82));
--ol-style-editor-border: rgba(255,255,255,0.74);
--ol-style-editor-shadow: 0 32px 96px -32px rgba(15,23,42,0.34);
}
[data-ol-theme='dark'] {
--ol-white: #11141a;
--ol-canvas: #0b0e13;
--ol-surface: #141922;
--ol-surface-2: #1a202b;
--ol-surface-solid: #171d27;
--ol-line: rgba(255, 255, 255, 0.09);
--ol-line-strong: rgba(255, 255, 255, 0.16);
--ol-line-soft: rgba(255, 255, 255, 0.07);
--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-ink: #f4f7fb;
--ol-ink-2: #d8dfeb;
--ol-ink-3: rgba(244, 247, 251, 0.74);
--ol-ink-4: rgba(244, 247, 251, 0.50);
--ol-ink-5: rgba(244, 247, 251, 0.26);
--ol-blue: #60a5fa;
--ol-blue-hover: #3b82f6;
--ol-blue-soft: rgba(96, 165, 250, 0.14);
--ol-blue-ring: rgba(96, 165, 250, 0.30);
--ol-on-accent: #f8fbff;
--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);
--ol-warn: #f59e0b;
--ol-warn-soft: rgba(245, 158, 11, 0.14);
--ol-err: #f87171;
--ol-app-shell-bg: linear-gradient(180deg, #050607 0%, #090a0c 100%);
--ol-window-bg:
radial-gradient(120% 80% at 0% 0%, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0) 42%),
linear-gradient(180deg, rgba(31,31,34,0.98) 0%, rgba(23,23,25,0.98) 100%);
--ol-window-border: rgba(255,255,255,0.08);
--ol-linux-titlebar-bg: rgba(15, 20, 28, 0.90);
--ol-linux-titlebar-border: rgba(255,255,255,0.08);
--ol-panel-bg: linear-gradient(180deg, rgba(33,33,36,0.96), rgba(28,28,31,0.94));
--ol-panel-border: rgba(255,255,255,0.07);
--ol-panel-shadow: 0 18px 44px -34px rgba(0,0,0,0.62);
--ol-card-bg: linear-gradient(180deg, rgba(34,34,37,0.96), rgba(29,29,32,0.94));
--ol-card-border: rgba(255,255,255,0.06);
--ol-card-shadow: none;
--ol-sidebar-bg: transparent;
--ol-sidebar-border: rgba(255,255,255,0.06);
--ol-sidebar-brand-bg: transparent;
--ol-sidebar-brand-border: transparent;
--ol-sidebar-pill-bg: rgba(255,255,255,0.06);
--ol-sidebar-pill-border: rgba(255,255,255,0.07);
--ol-sidebar-footer-border: rgba(255,255,255,0.07);
--ol-sidebar-version-bg: transparent;
--ol-sidebar-version-border: transparent;
--ol-sidebar-settings-bg: transparent;
--ol-sidebar-settings-border: transparent;
--ol-sidebar-settings-active-bg: rgba(255,255,255,0.08);
--ol-nav-hover-bg: rgba(255,255,255,0.05);
--ol-settings-rail-bg: rgba(255,255,255,0.02);
--ol-settings-rail-border: rgba(255,255,255,0.06);
--ol-settings-links-border: rgba(255,255,255,0.06);
--ol-settings-content-bg: linear-gradient(180deg, rgba(29,29,32,0.96), rgba(24,24,27,0.94));
--ol-settings-close-bg: rgba(255,255,255,0.06);
--ol-settings-close-hover-bg: rgba(255,255,255,0.14);
--ol-control-muted: rgba(255,255,255,0.05);
--ol-control-muted-strong: rgba(255,255,255,0.08);
--ol-control-border: rgba(255,255,255,0.08);
--ol-control-solid: rgba(24,31,41,0.96);
--ol-control-elevated: rgba(21,27,36,0.98);
--ol-control-shadow: none;
--ol-pill-bg: rgba(255,255,255,0.05);
--ol-pill-border: rgba(255,255,255,0.08);
--ol-pill-shadow: none;
--ol-pill-blue-bg: rgba(59,130,246,0.14);
--ol-pill-blue-border: rgba(96,165,250,0.28);
--ol-pill-ok-bg: rgba(74,222,128,0.16);
--ol-pill-ok-border: rgba(74,222,128,0.24);
--ol-pill-dark-bg: rgba(255,255,255,0.08);
--ol-pill-dark-border: rgba(255,255,255,0.12);
--ol-pill-dark-shadow: none;
--ol-segmented-bg: rgba(255,255,255,0.06);
--ol-segmented-active-bg: rgba(255,255,255,0.12);
--ol-segmented-active-shadow: none;
--ol-toggle-off-bg: rgba(255,255,255,0.16);
--ol-toggle-knob: #f8fbff;
--ol-overlay-bg: rgba(4, 8, 14, 0.58);
--ol-style-shell-bg: linear-gradient(180deg, rgba(29,29,32,0.94), rgba(24,24,27,0.92));
--ol-style-shell-border: rgba(255,255,255,0.07);
--ol-style-shell-shadow: none;
--ol-style-card-bg: rgba(34,34,37,0.94);
--ol-style-card-bg-active: rgba(39,39,43,0.96);
--ol-style-card-border: rgba(255,255,255,0.07);
--ol-style-card-border-active: rgba(96,165,250,0.72);
--ol-style-card-shadow: none;
--ol-style-card-shadow-active: 0 0 0 1px rgba(96,165,250,0.72);
--ol-style-card-ink: #f4f7fb;
--ol-style-card-ink-2: rgba(244,247,251,0.86);
--ol-style-card-ink-3: rgba(244,247,251,0.64);
--ol-style-card-icon-bg: rgba(255,255,255,0.06);
--ol-style-card-icon-bg-active: rgba(96,165,250,0.16);
--ol-style-delete-bg: rgba(255,255,255,0.05);
--ol-style-delete-border: rgba(248,113,113,0.34);
--ol-style-add-tile-bg: rgba(255,255,255,0.02);
--ol-style-add-tile-icon-bg: rgba(255,255,255,0.10);
--ol-style-add-tile-border: rgba(255,255,255,0.10);
--ol-style-input-bg: rgba(255,255,255,0.04);
--ol-style-input-border: rgba(255,255,255,0.08);
--ol-style-input-shadow: none;
--ol-style-subtle-bg: rgba(255,255,255,0.03);
--ol-style-subtle-border: rgba(255,255,255,0.06);
--ol-style-subtle-shadow: none;
--ol-style-editor-bg: linear-gradient(180deg, rgba(27,27,30,0.96), rgba(23,23,26,0.94));
--ol-style-editor-border: rgba(255,255,255,0.07);
--ol-style-editor-shadow: none;
}
* { box-sizing: border-box; }
@@ -83,7 +333,7 @@ body {
.ol-noscrollbar::-webkit-scrollbar { display: none; }
.ol-noscrollbar { scrollbar-width: none; }
/* Thin scrollbar — 细而可见,保留"可以滚"的可见性,但不抢眼。issue #243 */
/* Thin scrollbar */
.ol-thinscroll::-webkit-scrollbar { width: 6px; height: 6px; }
.ol-thinscroll::-webkit-scrollbar-track { background: transparent; }
.ol-thinscroll::-webkit-scrollbar-thumb {
@@ -96,30 +96,29 @@ public:
<< "DBus module not available";
}
// 3. 注册快捷键事件监听
// 3. 快捷键事件监听。
// PreInputMethod 在引擎 InputMethod 阶段之前运行,
// filterAndAccept() 设 filtered+accepted → 引擎跳过 commit → 字符不泄漏。
eventHandlers_.push_back(
instance_->watchEvent(
EventType::InputContextKeyEvent,
EventWatcherPhase::PreInputMethod,
[this](Event &event) {
auto &keyEvent = static_cast<KeyEvent &>(event);
// 保存当前输入上下文:快捷键按下时用户在目标 app 中,
// 此后胶囊窗口可能抢走焦点,但 commitText 仍能用此 IC 提交文字。
if (!keyEvent.isRelease()) {
savedIc_ = keyEvent.inputContext();
}
auto sym = static_cast<uint32_t>(keyEvent.key().sym());
auto states = static_cast<uint32_t>(keyEvent.key().states());
bool isPress = !keyEvent.isRelease();
// 检查自定义组合键(优先级最高)
if (hasCustomDictationKey_ &&
keyEvent.key().check(customDictationKey_)) {
// 自定义组合键:Alt 状态下字母 sym 可能大写(A vs a),归一化比较
if (hasCustomDictationKey_ && states == static_cast<uint32_t>(customDictationKey_.states()) &&
(sym == static_cast<uint32_t>(customDictationKey_.sym()) ||
(sym >= 65 && sym <= 90 && sym + 32 == static_cast<uint32_t>(customDictationKey_.sym())) ||
(sym >= 97 && sym <= 122 && sym - 32 == static_cast<uint32_t>(customDictationKey_.sym())))) {
FCITX_LOGC(openless, Debug)
<< "Custom dictation combo: sym=" << sym
<< " states=" << states
<< " isPress=" << isPress;
<< "Custom dictation: sym=" << sym << " states=" << states;
dictationKeyEvent(
static_cast<uint32_t>(customDictationKey_.sym()),
static_cast<uint32_t>(customDictationKey_.states()),
@@ -127,8 +126,6 @@ public:
keyEvent.filterAndAccept();
return;
}
// 检查听写触发键(raw + keylist 双路径)
if ((triggerRawSym_ != 0 &&
keyEvent.key().check(Key(static_cast<KeySym>(triggerRawSym_),
static_cast<KeyStates>(triggerRawStates_)))) ||
@@ -140,55 +137,35 @@ public:
}
return false;
}())) {
auto dsym = triggerRawSym_ != 0
? triggerRawSym_
auto dsym = triggerRawSym_ != 0 ? triggerRawSym_
: static_cast<uint32_t>(triggerKeyList_[0].sym());
auto dstates = triggerRawStates_ != 0
? triggerRawStates_
auto dstates = triggerRawStates_ != 0 ? triggerRawStates_
: static_cast<uint32_t>(triggerKeyList_[0].states());
FCITX_LOGC(openless, Debug)
<< "Dictation hotkey: sym=" << dsym
<< " states=" << dstates
<< " isPress=" << isPress;
<< "Dictation hotkey sym=" << dsym;
dictationKeyEvent(dsym, dstates, isPress);
keyEvent.filterAndAccept();
return;
}
// 检查 QA 快捷键
if (qaRawSym_ != 0 &&
sym == qaRawSym_ &&
if (qaRawSym_ != 0 && sym == qaRawSym_ &&
states == qaRawStates_) {
FCITX_LOGC(openless, Debug)
<< "QA shortcut: sym=" << qaRawSym_
<< " states=" << qaRawStates_
<< " isPress=" << isPress;
<< "QA shortcut";
qaShortcutEvent(qaRawSym_, qaRawStates_, isPress);
keyEvent.filterAndAccept();
return;
}
// 检查翻译模式修饰键(自定义 + 内置 Shift)
bool translationMatched = false;
if (translationRawSym_ != 0 &&
sym == translationRawSym_ &&
states == translationRawStates_) {
if (translationRawSym_ != 0 && sym == translationRawSym_ &&
states == translationRawStates_)
translationMatched = true;
}
// 内置 Shift 修饰键(仅在配置了自定义翻译键时生效,
// 避免无翻译配置时每次按 Shift 都触发信号)。
if (translationRawSym_ != 0 &&
(sym == 0xffe1 || sym == 0xffe2)) {
(sym == 0xffe1 || sym == 0xffe2))
translationMatched = true;
}
if (translationMatched) {
FCITX_LOGC(openless, Debug)
<< "Translation modifier: sym=" << sym
<< " states=" << states
<< " isPress=" << isPress;
<< "Translation modifier: sym=" << sym;
translationModifierEvent(sym, states, isPress);
// 不 filterAndAccept:修改键只需通知 OpenLess 翻译状态变更,
// 不应阻塞输入法引擎处理 Shift 事件(如中英文切换)。
}
}));
@@ -222,8 +199,7 @@ public:
instance_->flushUI();
}));
// 6. PostInputMethod 阶段恢复 lastAuxText_:fcitx5 在处理按键后可能
// 清掉 auxDown(如 enter/backspace 触发内联模式),此钩子自动补回。
// 6. PostInputMethod:恢复 auxDown(fcitx5 内联模式/方向键后可能清掉)
eventHandlers_.push_back(
instance_->watchEvent(
EventType::InputContextKeyEvent,