Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
333 changes: 176 additions & 157 deletions openless-all/app/src-tauri/src/commands.rs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions openless-all/app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ mod insertion;
#[cfg(target_os = "linux")]
mod linux_fcitx;
mod llm_gemini;
mod net;
mod permissions;
mod persistence;
mod polish;
Expand Down
72 changes: 72 additions & 0 deletions openless-all/app/src-tauri/src/net.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
//! 共享 HTTP 客户端 + 带重试的请求发送。
//!
//! 背景:原先每个网络命令各自 `reqwest::Client::new()`,连接池互不复用 —— 一次
//! 成功的 TLS 连接用完即弃,下一个命令又得重新握手。在握手不稳定的网络下(代理
//! 分流等)首次握手经常被重置,用户得反复重试才能用。
//!
//! 这里提供两件东西:
//! - `http()`:进程级共享客户端。一次握手成功后的连接进连接池,后续命令直接复用,
//! 不再付握手成本。
//! - `send_with_retry`:只对**连接层失败**(`is_connect()` —— 握手重置 / 连接被拒
//! 等)做指数退避重试。这类失败发生在请求送达服务端之前、且通常是瞬时的(代理
//! 分流抖动等),重试既幂等安全又有意义。**不重试超时与其他请求层错误**:超时
//! 可能发生在服务端已收到之后(重试 POST / DELETE 会重复执行);`is_request()`
//! 类错误多为确定性失败(如 endpoint 配置错误),重试只是徒增数秒延迟。HTTP
//! 4xx/5xx 同样不重试 —— 服务端已应答,状态码交给调用方判断。

use std::time::Duration;

use once_cell::sync::Lazy;

static HTTP: Lazy<reqwest::Client> = Lazy::new(|| {
reqwest::Client::builder()
// 握手单独限时:卡在握手上要尽快失败,好让 send_with_retry 立即重试。
.connect_timeout(Duration::from_secs(8))
// 连接池:一条握手成功的连接保留 90s 供后续命令复用。
.pool_idle_timeout(Duration::from_secs(90))
.pool_max_idle_per_host(8)
.tcp_keepalive(Duration::from_secs(30))
.user_agent(concat!("OpenLess/", env!("CARGO_PKG_VERSION")))
.build()
.unwrap_or_else(|_| reqwest::Client::new())
});

/// 进程级共享 HTTP 客户端。带连接池 —— 一次握手成功后的连接被后续请求复用。
pub fn http() -> &'static reqwest::Client {
&HTTP
}

/// 单次请求最多尝试的次数。失败本身很快(握手重置 ~0.5s),10 次总耗时仍可控。
const MAX_ATTEMPTS: u32 = 10;

/// 发送请求,只对连接层失败(`is_connect()`:握手重置 / 连接被拒等)做指数退避重试。
///
/// `make` 每次尝试都重新构造 `RequestBuilder`(`send()` 会消耗它)。只重试
/// `is_connect()` —— 连接尚未建立、请求未送达服务端,且这类失败通常是瞬时的,
/// 重试幂等安全且有价值。超时(可能服务端已在处理)与其他 `is_request()` 类错误
/// (多为 endpoint 配置错误等确定性失败)都不重试。拿到任意 HTTP 响应(含
/// 4xx/5xx)即返回,状态码由调用方自行判断。
pub async fn send_with_retry<F>(make: F) -> reqwest::Result<reqwest::Response>
where
F: Fn() -> reqwest::RequestBuilder,
{
let mut attempt: u32 = 0;
loop {
attempt += 1;
match make().send().await {
Ok(resp) => return Ok(resp),
Err(err) => {
let retryable = err.is_connect();
if !retryable || attempt >= MAX_ATTEMPTS {
return Err(err);
}
// 150 / 300 / 600 / 900 / 900 … ms 退避。
let backoff = (150u64 * 2u64.pow((attempt - 1).min(3))).min(900);
log::warn!(
"[net] transient failure (attempt {attempt}/{MAX_ATTEMPTS}), retry in {backoff}ms: {err}"
);
tokio::time::sleep(Duration::from_millis(backoff)).await;
}
}
}
}
9 changes: 5 additions & 4 deletions openless-all/app/src-tauri/src/polish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2782,7 +2782,8 @@ mod tests {
assert!(prompt.contains("# 三、双层格式"));
assert!(prompt.contains("第一层(主题)"));
assert!(prompt.contains("第二层(子项)"));
assert!(prompt.contains("事项 ≤ 2 条"));
assert!(prompt.contains("事项仅 1 条"));
assert!(prompt.contains("事项 = 2 条"));
assert!(prompt.contains("事项 ≥ 3 条"));

// 防回归:模型名、字段名、布尔值和版本号必须被显式保护。
Expand All @@ -2808,14 +2809,14 @@ mod tests {
fn structured_prompt_keeps_regrouping_and_no_loss_guards() {
let prompt = prompts::system_prompt(PolishMode::Structured);

// v1.3.0 回归的关键规则:已编号 ≠ 不用改、≥3 必须重组、≤2 不硬塞层级
// v1.3.0 回归的关键规则:已编号 ≠ 不用改、≥3 必须重组、仅 1 条事项输出连贯段落
assert!(
prompt.contains("照抄原结构 = 失败"),
"Structured prompt 必须把照抄原结构判为失败"
);
assert!(
prompt.contains("不硬塞层级"),
"Structured prompt 必须避免短输入过度结构化"
prompt.contains("输出连贯段落"),
"Structured prompt 必须避免短输入过度结构化(仅 1 条事项 → 连贯段落)"
);
assert!(
prompt.contains("不丢失任何一件事"),
Expand Down
5 changes: 4 additions & 1 deletion openless-all/app/src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1107,11 +1107,14 @@ const STRUCTURED_BUILTIN_PROMPT: &str = r#"# 角色

按可识别的事项数决定输出形态:

- **事项 ≤ 2 条** → 输出连贯段落,不硬塞层级。
- **事项仅 1 条** → 输出连贯段落。
- **事项 = 2 条** → **必须**用 1./2. 编号平列输出,每条一句完整陈述。不强制分主题子项,但仍需整理表达。
- **事项 ≥ 3 条** → **必须**按语义归类为 2–4 个主题,使用下文双层格式。**照抄原结构 = 失败。**

即使原文已经写成「1. 做 X 2. 做 Y 3. 做 Z」,也要按主题重新归类,把同主题事项收到同一组下做 (a)(b) 子项。

**重要:只要存在 2 条及以上可区分事项,就必须编号。不编号 = 失败。**

常见主题组合(按内容自动选取):

- 工程类:「代码与功能 / 文档与配置 / 界面与交互 / 项目清理」「后端 / 前端 / 部署 / 提示词」
Expand Down
9 changes: 5 additions & 4 deletions openless-all/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { AutoUpdateGate } from './components/AutoUpdateGate';
import { Capsule } from './components/Capsule';
import { FloatingShell } from './components/FloatingShell';
import { Onboarding } from './components/Onboarding';
import { detectOS } from './components/WindowChrome';
import { detectOS, type OS } from './components/WindowChrome';
import {
checkAccessibilityPermission,
checkMicrophonePermission,
Expand All @@ -22,19 +22,20 @@ import { HotkeySettingsProvider } from './state/HotkeySettingsContext';
interface AppProps {
isCapsule: boolean;
isQa: boolean;
forcedOs?: OS | null;
}

type Gate = 'checking' | 'onboarding' | 'ready';

export function App({ isCapsule, isQa }: AppProps) {
export function App({ isCapsule, isQa, forcedOs }: AppProps) {
if (isCapsule) {
return <Capsule />;
}
if (isQa) {
return <QaPanel />;
}

const os = detectOS();
const os = forcedOs ?? detectOS();
// Windows 启动不应被权限探测阻塞首屏。
const [gate, setGate] = useState<Gate>(isTauri ? 'checking' : 'ready');

Expand Down Expand Up @@ -173,7 +174,7 @@ export function App({ isCapsule, isQa }: AppProps) {
}
return (
<HotkeySettingsProvider>
{gate === 'onboarding' ? <Onboarding onComplete={() => setGate('ready')} /> : <FloatingShell />}
{gate === 'onboarding' ? <Onboarding onComplete={() => setGate('ready')} /> : <FloatingShell os={os} />}
{gate === 'ready' && <AutoUpdateGate />}
</HotkeySettingsProvider>
);
Expand Down
10 changes: 6 additions & 4 deletions openless-all/app/src/components/AutoUpdate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { invoke } from '@tauri-apps/api/core';
import type { DownloadEvent } from '@tauri-apps/plugin-updater';
import { Update } from '@tauri-apps/plugin-updater';
import { useTranslation } from 'react-i18next';
import { isTauri, restartApp } from '../lib/ipc';
import { isTauri, restartApp, type UpdateChannel } from '../lib/ipc';
import { Btn } from '../pages/_atoms';

const UPDATE_CHECK_TIMEOUT_MS = 15_000;
Expand Down Expand Up @@ -45,8 +45,9 @@ export interface UseAutoUpdate {
checking: boolean;
busy: boolean;
errorMessage: string | null;
/** 触发"检查更新"。如果发现新版本,状态变为 'available',需要 caller 渲染对话框让用户确认下载。 */
checkForUpdates: () => Promise<void>;
/** 触发"检查更新"。如果发现新版本,状态变为 'available',需要 caller 渲染对话框让用户确认下载。
* `channel` 显式指定查哪个渠道;省略时由 Rust 端回落到 prefs.update_channel。 */
checkForUpdates: (channel?: UpdateChannel) => Promise<void>;
/** 用户在对话框里确认 → 下载 + 安装。完成后状态变为 'downloaded',等用户点重启。 */
installUpdate: () => Promise<void>;
/** 关闭对话框(仅在非 busy 状态可用)。 */
Expand Down Expand Up @@ -88,7 +89,7 @@ export function useAutoUpdate(): UseAutoUpdate {
setContentLength(null);
};

const checkForUpdates = async () => {
const checkForUpdates = async (channel?: UpdateChannel) => {
setStatus('checking');
setVersion('');
setErrorMessage(null);
Expand All @@ -103,6 +104,7 @@ export function useAutoUpdate(): UseAutoUpdate {
// Beta → fetch_latest_beta_release 拼出 -beta manifest URL 后再 check。
const metadata = await invoke<AppUpdateMetadata | null>('app_check_update_with_channel', {
timeoutMs: UPDATE_CHECK_TIMEOUT_MS,
channel: channel ?? null,
});
if (!metadata) {
setStatus('none');
Expand Down
6 changes: 3 additions & 3 deletions openless-all/app/src/components/FloatingShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
PROVIDER_SETUP_PROMPT_DEFERRED_KEY,
shouldShowProviderSetupPrompt,
} from '../lib/providerSetup';
import { type SettingsSectionId } from '../pages/Settings';
import { type SettingsSectionId } from './SettingsModal';
import { useAppState, type AppTab } from '../state/useAppState';

interface NavItem {
Expand Down Expand Up @@ -170,13 +170,13 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia

const openProviderSettings = () => {
rememberProviderPrompt();
openSettings('providers');
openSettings('services');
};

const openHotkeyRecordingSettings = () => {
window.localStorage.setItem(HOTKEY_MODE_MIGRATION_ACK_KEY, '1');
setHotkeyModePromptOpen(false);
openSettings('recording');
openSettings('general');
};

return (
Expand Down
Loading
Loading