diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 00000000..cc1eff5a --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,68 @@ +name: Claude Code + +# 触发策略: +# - 默认不主动 review;任何 PR 打开 / 同步都不会自动调用模型,避免烧 OAuth token 额度。 +# - 评论 / review / issue 中出现 `@claude` 才触发,且发起者必须是仓库 OWNER(appergb)。 +# - 模型默认 claude-sonnet-4-6(量大、成本低);评论里写 `--opus` 或包含 `claude-opus` +# 字样会切到 claude-opus-4-7(用于需要更深推理的任务)。 + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + # 双重门禁:(1) 内容含 @claude (2) 触发者是 OWNER。 + # author_association == 'OWNER' 是 GitHub 内建字段,免一次 API 调用。 + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') && github.event.comment.author_association == 'OWNER') || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') && github.event.comment.author_association == 'OWNER') || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude') && github.event.review.author_association == 'OWNER') || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) && github.event.issue.author_association == 'OWNER') + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Pick model (default sonnet, opt-in opus) + id: pick_model + env: + COMMENT_BODY: ${{ github.event.comment.body }} + REVIEW_BODY: ${{ github.event.review.body }} + ISSUE_BODY: ${{ github.event.issue.body }} + ISSUE_TITLE: ${{ github.event.issue.title }} + run: | + set -eu + body="${COMMENT_BODY}${REVIEW_BODY}${ISSUE_BODY}${ISSUE_TITLE}" + if [[ "$body" == *"--opus"* ]] || [[ "$body" == *"claude-opus"* ]]; then + echo "model=claude-opus-4-7" >> "$GITHUB_OUTPUT" + else + echo "model=claude-sonnet-4-6" >> "$GITHUB_OUTPUT" + fi + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # 让 Claude 能看 CI 结果 + additional_permissions: | + actions: read + + # 模型策略:默认 sonnet,评论里 --opus 才升级到 opus + claude_args: '--model ${{ steps.pick_model.outputs.model }}' diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index dd0dbaf1..4850e62e 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -38,6 +38,8 @@ use std::sync::Arc; #[cfg(target_os = "macos")] use std::time::Duration; +const LOG_ROTATE_LIMIT_BYTES: u64 = 10 * 1024 * 1024; + /// 第一次 show 时把 QA 浮窗摆到屏幕底部居中;之后的 show 不再 reposition, /// 让用户拖动后的位置在 hide → show 之间得以保持。详见 issue #118 v2。 static QA_WINDOW_POSITIONED: AtomicBool = AtomicBool::new(false); @@ -47,14 +49,11 @@ use tauri::{AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, RunEvent, #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - init_file_logger(); - log::info!("=== OpenLess 启动 ==="); - let foundry_local_runtime = Arc::new(asr::local::FoundryLocalRuntime::new()); #[cfg(target_os = "windows")] - let coordinator = Arc::new(coordinator::Coordinator::new_with_foundry_runtime(Arc::clone( - &foundry_local_runtime, - ))); + let coordinator = Arc::new(coordinator::Coordinator::new_with_foundry_runtime( + Arc::clone(&foundry_local_runtime), + )); #[cfg(not(target_os = "windows"))] let coordinator = Arc::new(coordinator::Coordinator::new()); let local_asr_download_manager = Arc::new(asr::local::DownloadManager::new()); @@ -83,6 +82,9 @@ pub fn run() { .manage(local_asr_download_manager.clone()) .manage(foundry_local_runtime.clone()) .setup(move |app| { + init_file_logger(); + log::info!("=== OpenLess 启动 ==="); + // Capsule 启动时定位到屏幕底部居中并隐藏;coordinator 按需显示。 // 与 Swift `CapsuleWindowController.repositionToBottomCenter` 同语义。 if let Some(capsule) = app.get_webview_window("capsule") { @@ -444,6 +446,9 @@ fn init_file_logger() { let log_dir = log_dir_path(); let _ = std::fs::create_dir_all(&log_dir); let log_file = log_dir.join("openless.log"); + if let Err(e) = rotate_log_if_too_large(&log_file) { + eprintln!("[logger] WARN 日志轮转失败: {e}"); + } let config = ConfigBuilder::new().set_time_format_rfc3339().build(); let mut loggers: Vec> = vec![TermLogger::new( LevelFilter::Info, @@ -461,6 +466,23 @@ fn init_file_logger() { let _ = CombinedLogger::init(loggers); } +fn rotate_log_if_too_large(path: &std::path::Path) -> std::io::Result<()> { + let Ok(metadata) = std::fs::metadata(path) else { + return Ok(()); + }; + if metadata.len() <= LOG_ROTATE_LIMIT_BYTES { + return Ok(()); + } + + let archive = path.with_file_name("openless.log.1"); + match std::fs::remove_file(&archive) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + std::fs::rename(path, archive) +} + pub fn log_dir_path() -> std::path::PathBuf { #[cfg(target_os = "macos")] { @@ -851,7 +873,11 @@ fn capsule_height_for_qa() -> f64 { #[cfg(test)] mod tests { - use super::{capsule_height_for_qa, capsule_visual_height, capsule_window_bounds}; + use super::{ + capsule_height_for_qa, capsule_visual_height, capsule_window_bounds, + rotate_log_if_too_large, LOG_ROTATE_LIMIT_BYTES, + }; + use std::io::Write; #[test] fn capsule_window_bounds_leave_room_for_windows_shadow() { @@ -902,4 +928,62 @@ mod tests { #[cfg(not(target_os = "windows"))] assert_eq!(capsule_height_for_qa(), 96.0); } + + #[test] + fn oversized_log_rotates_to_single_archive() { + let dir = std::env::temp_dir().join(format!("openless-log-rotate-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let log = dir.join("openless.log"); + let archive = dir.join("openless.log.1"); + + { + let mut file = std::fs::File::create(&log).unwrap(); + file.set_len(LOG_ROTATE_LIMIT_BYTES + 1).unwrap(); + file.write_all(b"x").unwrap(); + } + std::fs::write(&archive, b"old").unwrap(); + + rotate_log_if_too_large(&log).unwrap(); + + assert!(!log.exists()); + assert!(archive.exists()); + assert!(std::fs::metadata(&archive).unwrap().len() > LOG_ROTATE_LIMIT_BYTES); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn small_log_does_not_rotate() { + let dir = std::env::temp_dir().join(format!("openless-log-small-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let log = dir.join("openless.log"); + let archive = dir.join("openless.log.1"); + std::fs::write(&log, b"small").unwrap(); + + rotate_log_if_too_large(&log).unwrap(); + + assert!(log.exists()); + assert!(!archive.exists()); + assert_eq!(std::fs::read(&log).unwrap(), b"small"); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn missing_log_does_not_rotate() { + let dir = std::env::temp_dir().join(format!("openless-log-missing-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let log = dir.join("openless.log"); + let archive = dir.join("openless.log.1"); + + rotate_log_if_too_large(&log).unwrap(); + + assert!(!log.exists()); + assert!(!archive.exists()); + + let _ = std::fs::remove_dir_all(&dir); + } }