diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ed998e38d..f8415490d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,68 +1,16 @@ -# Release v0.76.0 +## ProxyCast v0.77.0 -发布日期:2026-03-02 +### ✨ 新功能 +- 添加可观测性面板,支持响应缓存配置和剪贴板权限指南 (ad61472a) +- 增强崩溃诊断功能,新增调用错误缓冲区、前端崩溃缓冲区和应用版本信息 (ff915523) +- 改进 Aster 运行时、Agent 命令、小说解析和日志检索功能 (68e16d4a) +- API 服务器新增请求去重、响应缓存和能力路由指标 (0b2d3caa) +- 使用崩溃边界包裹应用路由,添加启动时工作区检查,改进小说角色解析 (61270d6f) -## 📊 变更统计 +### 🐛 修复 +- 为工作区健康检查添加自动重定位功能,使用修复标志进行遥测 (0f1be80b) +- 构建 workspace 目录 4 层健康防护体系,彻底解决路径缺失问题 (2d00911c) -- **88 个文件变更** -- **新增 9,642 行代码** -- **删除 2,925 行代码** -- **净增 6,717 行代码** - -## 🎉 重点功能 - -### 1) 图片搜索能力落地(含 Pixabay) - -- 新增后端命令:`src-tauri/src/commands/image_search_cmd.rs` -- 新增搜索逻辑 Hook:`src/components/image-gen/hooks/useImageSearch.ts` -- 新增搜索页签:`src/components/image-gen/tabs/ImageSearchTab.tsx` -- 新增系统设置与测试: - - `src/components/settings-v2/system/web-search/index.tsx` - - `src/components/settings-v2/system/web-search/index.test.tsx` -- 支持在设置页配置并启用图片搜索相关 Key(含 Pixabay) - -### 2) 图片工作台重构(可插入创作流程) - -- 图片页拆分为多 Tab 结构: - - `AiImageGenTab.tsx` - - `ImageSearchTab.tsx` - - `LocalImageTab.tsx` - - `MyGalleryTab.tsx` -- 新增最近插入浮层:`src/components/image-gen/RecentImageInsertFloating.tsx` -- 统一插入通道与目标追踪: - - `src/lib/activeContentTarget.ts` - - `src/lib/canvasImageInsertBus.ts` - - `src/lib/documentImageInsertBus.ts` - - `src/lib/canvasImageInsertHistory.ts` - -### 3) 覆盖多画布插入能力(主题创作可用) - -- 文稿画布:`DocumentCanvas.tsx` / `NotionEditor.tsx` / `autoImageInsert.ts` -- 海报画布:`PosterCanvas.tsx` -- 视频画布:`VideoCanvas.tsx` -- 音乐画布:`MusicCanvas.tsx` -- 小说画布:`NovelCanvas.tsx` -- 脚本画布:`ScriptCanvas.tsx` - -### 4) 稳定性与工程完善 - -- 新增崩溃上报模块:`src-tauri/src/crash_reporting.rs`、`src/lib/crashReporting.ts` -- 调整启动/配置与命令注册链路(含 `src-tauri/src/lib.rs`、`src-tauri/src/commands/mod.rs`) -- 补充测试覆盖(图片搜索、设置项、插入流程、工作台对话框) - -## 🔧 版本同步 - -已同步版本号到: -- `package.json`: `0.76.0` -- `src-tauri/Cargo.toml`: `0.76.0` -- `src-tauri/tauri.conf.json`: `0.76.0` - -## 🚀 升级建议 - -1. 打开设置页,检查图片搜索服务配置(含 Pixabay Key) -2. 在任一创作主题画布中触发图片搜索并执行插入 -3. 验证插入目标是否符合当前激活画布 - ---- - -**完整变更日志**:https://github.com/aiclientproxy/proxycast/compare/v0.75.0...v0.76.0 +### 🔧 优化与重构 +- 完善工作区健康监控和错误恢复机制 +- 提升应用稳定性和可观测性 diff --git a/docs/content/02.user-guide/4.configuration-example.md b/docs/content/02.user-guide/4.configuration-example.md index 651a05883..6a23fd766 100644 --- a/docs/content/02.user-guide/4.configuration-example.md +++ b/docs/content/02.user-guide/4.configuration-example.md @@ -91,6 +91,41 @@ server: 适用:对缓存命中与语义一致性有要求的自动化/API 调用场景。 +## 示例 5:运行时诊断接口(高级) + +目标:像 ClawRouter 一样快速查看网关健康、缓存与统计信息。 + +```bash +# 基础健康检查(兼容旧行为) +curl "http://127.0.0.1:8999/health" + +# 扩展健康检查(包含完整诊断) +curl "http://127.0.0.1:8999/health?full=true" + +# 响应缓存配置与命中统计 +curl "http://127.0.0.1:8999/cache" + +# 统计诊断(默认最近 7 天,可传 days=1~30) +curl "http://127.0.0.1:8999/stats?days=7" + +# 查看请求级调试头(缓存/去重/幂等等状态) +curl -i "http://127.0.0.1:8999/v1/chat/completions" \ + -H "Authorization: Bearer your-api-key" \ + -H "Content-Type: application/json" \ + -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"ping"}]}' +``` + +适用:排查“为什么慢/为什么回退/是否命中缓存”等线上联调问题。 + +可观测调试头示例: +- `x-proxycast-request-id` +- `x-proxycast-cache`(`hit/store/skip-*`) +- `x-proxycast-dedup`(`replay/wait-replay/new/removed-on-error`) +- `x-proxycast-idempotency`(`replay/in-progress/new/removed-on-error`) +- `x-proxycast-requested-provider` / `x-proxycast-effective-provider` / `x-proxycast-model` + +补充:在「团队共享网关(内网)」页面的「网关 API 测试」结果展开区域,也会直接显示这些 `x-proxycast-*` 调试头。 + ## 调整顺序建议 1. 先确认导航与主题 diff --git a/package.json b/package.json index 79f6a535a..3d3a49758 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.76.0", + "version": "0.77.0", "type": "module", "repository": { "type": "git", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8b208bf95..f83d3f352 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6996,7 +6996,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.76.0" +version = "0.77.0" dependencies = [ "anyhow", "arboard", @@ -7097,7 +7097,7 @@ dependencies = [ [[package]] name = "proxycast-agent" -version = "0.76.0" +version = "0.77.0" dependencies = [ "aster-core", "async-trait", @@ -7121,7 +7121,7 @@ dependencies = [ [[package]] name = "proxycast-config" -version = "0.76.0" +version = "0.77.0" dependencies = [ "async-trait", "parking_lot", @@ -7137,7 +7137,7 @@ dependencies = [ [[package]] name = "proxycast-core" -version = "0.76.0" +version = "0.77.0" dependencies = [ "aster-models", "async-trait", @@ -7177,7 +7177,7 @@ dependencies = [ [[package]] name = "proxycast-credential" -version = "0.76.0" +version = "0.77.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -7212,7 +7212,7 @@ dependencies = [ [[package]] name = "proxycast-infra" -version = "0.76.0" +version = "0.77.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -7232,7 +7232,7 @@ dependencies = [ [[package]] name = "proxycast-mcp" -version = "0.76.0" +version = "0.77.0" dependencies = [ "async-trait", "glob", @@ -7263,7 +7263,7 @@ dependencies = [ [[package]] name = "proxycast-processor" -version = "0.76.0" +version = "0.77.0" dependencies = [ "async-trait", "parking_lot", @@ -7282,7 +7282,7 @@ dependencies = [ [[package]] name = "proxycast-providers" -version = "0.76.0" +version = "0.77.0" dependencies = [ "anyhow", "async-stream", @@ -7334,7 +7334,7 @@ dependencies = [ [[package]] name = "proxycast-server" -version = "0.76.0" +version = "0.77.0" dependencies = [ "aster-core", "async-stream", @@ -7378,7 +7378,7 @@ dependencies = [ [[package]] name = "proxycast-server-utils" -version = "0.76.0" +version = "0.77.0" dependencies = [ "axum 0.7.9", "futures", @@ -7393,7 +7393,7 @@ dependencies = [ [[package]] name = "proxycast-services" -version = "0.76.0" +version = "0.77.0" dependencies = [ "anyhow", "aster-core", @@ -7434,7 +7434,7 @@ dependencies = [ [[package]] name = "proxycast-skills" -version = "0.76.0" +version = "0.77.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -7450,7 +7450,7 @@ dependencies = [ [[package]] name = "proxycast-terminal" -version = "0.76.0" +version = "0.77.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -7477,7 +7477,7 @@ dependencies = [ [[package]] name = "proxycast-websocket" -version = "0.76.0" +version = "0.77.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 05d53cfe6..27ec0f3ad 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.76.0" +version = "0.77.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/proxycast" @@ -190,7 +190,7 @@ version = "2.4" [package] name = "proxycast" -version = "0.76.0" +version = "0.77.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/core/src/config/types.rs b/src-tauri/crates/core/src/config/types.rs index 00d99f2a2..240a2fcb7 100644 --- a/src-tauri/crates/core/src/config/types.rs +++ b/src-tauri/crates/core/src/config/types.rs @@ -654,6 +654,36 @@ pub struct UpdateCheckConfig { /// 已跳过的版本(用户选择"跳过此版本") #[serde(default, skip_serializing_if = "Option::is_none")] pub skipped_version: Option, + /// 稍后提醒截止时间(Unix 时间戳,秒) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remind_later_until: Option, + /// 最近一次通知的版本号 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_notified_version: Option, + /// 最近一次通知时间(Unix 时间戳,秒) + #[serde(default)] + pub last_notified_at: u64, + /// 下次允许通知时间(Unix 时间戳,秒) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_notify_after: Option, + /// 连续关闭提醒次数(用于退避) + #[serde(default)] + pub dismiss_streak: u32, + /// 更新提醒展示次数 + #[serde(default)] + pub notification_shown_count: u64, + /// 点击“立即更新”次数 + #[serde(default)] + pub action_update_now_count: u64, + /// 点击“稍后提醒”次数 + #[serde(default)] + pub action_remind_later_count: u64, + /// 点击“跳过版本”次数 + #[serde(default)] + pub action_skip_version_count: u64, + /// 关闭提醒次数(ESC/关闭按钮) + #[serde(default)] + pub action_dismiss_count: u64, } fn default_update_check_enabled() -> bool { @@ -676,6 +706,16 @@ impl Default for UpdateCheckConfig { show_notification: default_show_notification(), last_check_timestamp: 0, skipped_version: None, + remind_later_until: None, + last_notified_version: None, + last_notified_at: 0, + next_notify_after: None, + dismiss_streak: 0, + notification_shown_count: 0, + action_update_now_count: 0, + action_remind_later_count: 0, + action_skip_version_count: 0, + action_dismiss_count: 0, } } } diff --git a/src-tauri/crates/server/src/handlers/api.rs b/src-tauri/crates/server/src/handlers/api.rs index 214e2c975..26092fc51 100644 --- a/src-tauri/crates/server/src/handlers/api.rs +++ b/src-tauri/crates/server/src/handlers/api.rs @@ -462,6 +462,48 @@ fn headers_to_string_map(headers: &axum::http::HeaderMap) -> HashMap Response { + if let Ok(value) = header::HeaderValue::from_str(requested_provider) { + response.headers_mut().insert( + header::HeaderName::from_static("x-proxycast-requested-provider"), + value, + ); + } + if let Ok(value) = header::HeaderValue::from_str(effective_provider) { + response.headers_mut().insert( + header::HeaderName::from_static("x-proxycast-effective-provider"), + value, + ); + } + if let Ok(value) = header::HeaderValue::from_str(model) { + response + .headers_mut() + .insert(header::HeaderName::from_static("x-proxycast-model"), value); + } + response +} + fn build_cached_response(response: CachedHttpResponse) -> Response { let status = StatusCode::from_u16(response.status).unwrap_or(StatusCode::OK); let mut resp = Response::new(Body::from(response.body)); @@ -474,6 +516,8 @@ fn build_cached_response(response: CachedHttpResponse) -> Response { resp.headers_mut().insert(name, val); } } + set_static_diag_header(&mut resp, "x-proxycast-cache", "hit"); + set_static_diag_header(&mut resp, "x-proxycast-source", "response-cache"); resp } @@ -524,10 +568,22 @@ async fn begin_request_dedup( match store.check_or_register(&key) { RequestDedupCheck::New => Ok(RequestDedupGuard::new(Some(key), is_stream, store)), - RequestDedupCheck::Completed { status, body } => Err(replay_response(status, body)), + RequestDedupCheck::Completed { status, body } => { + let mut response = replay_response(status, body); + set_request_id_header(&mut response, request_id); + set_static_diag_header(&mut response, "x-proxycast-dedup", "replay"); + set_static_diag_header(&mut response, "x-proxycast-source", "request-dedup"); + Err(response) + } RequestDedupCheck::InProgress { notify } => { match store.wait_for_completion(&key, notify).await { - Some(replay) => Err(replay_response(replay.status, replay.body)), + Some(replay) => { + let mut response = replay_response(replay.status, replay.body); + set_request_id_header(&mut response, request_id); + set_static_diag_header(&mut response, "x-proxycast-dedup", "wait-replay"); + set_static_diag_header(&mut response, "x-proxycast-source", "request-dedup"); + Err(response) + } None => { tracing::warn!( "[REQUEST_DEDUP] request_id={} endpoint={} wait timeout for key={}", @@ -535,13 +591,16 @@ async fn begin_request_dedup( endpoint, key ); - Err(build_error_response_with_meta( + let mut response = build_error_response_with_meta( StatusCode::CONFLICT.as_u16(), "Equivalent request is still in progress, please retry later", Some(request_id), None, Some(GatewayErrorCode::RequestConflict), - )) + ); + set_request_id_header(&mut response, request_id); + set_static_diag_header(&mut response, "x-proxycast-dedup", "wait-timeout"); + Err(response) } } } @@ -549,6 +608,7 @@ async fn begin_request_dedup( } async fn begin_response_cache( + request_id: &str, endpoint: &str, request_payload: &serde_json::Value, headers: &HeaderMap, @@ -571,19 +631,23 @@ async fn begin_response_cache( let key = format!("{endpoint}:{fingerprint}"); if let Some(cached) = store.get(&key) { - return Err(build_cached_response(cached)); + let mut response = build_cached_response(cached); + set_request_id_header(&mut response, request_id); + return Err(response); } Ok(ResponseCacheGuard::new(Some(key), is_stream, store)) } async fn finalize_replayable_response( - response: Response, + mut response: Response, guard: &mut IdempotencyGuard, dedup_guard: &mut RequestDedupGuard, cache_guard: &mut ResponseCacheGuard, request_id: &str, ) -> Response { + set_request_id_header(&mut response, request_id); + if !guard.is_enabled() && !dedup_guard.is_enabled() && !cache_guard.is_enabled() { return response; } @@ -593,6 +657,15 @@ async fn finalize_replayable_response( guard.remove(); dedup_guard.remove(); cache_guard.skip(); + if guard.is_enabled() { + set_static_diag_header(&mut response, "x-proxycast-idempotency", "removed-on-error"); + } + if dedup_guard.is_enabled() { + set_static_diag_header(&mut response, "x-proxycast-dedup", "removed-on-error"); + } + if cache_guard.is_enabled() { + set_static_diag_header(&mut response, "x-proxycast-cache", "skip-on-error"); + } return response; } @@ -608,7 +681,22 @@ async fn finalize_replayable_response( } else { cache_guard.skip(); } - Response::from_parts(parts, Body::from(bytes)) + let mut response = Response::from_parts(parts, Body::from(bytes)); + set_request_id_header(&mut response, request_id); + if guard.is_enabled() { + set_static_diag_header(&mut response, "x-proxycast-idempotency", "new"); + } + if dedup_guard.is_enabled() { + set_static_diag_header(&mut response, "x-proxycast-dedup", "new"); + } + if cache_guard.is_enabled() { + if cache_guard.should_cache_status(status) { + set_static_diag_header(&mut response, "x-proxycast-cache", "store"); + } else { + set_static_diag_header(&mut response, "x-proxycast-cache", "skip-status"); + } + } + response } Err(err) => { tracing::warn!( @@ -619,13 +707,16 @@ async fn finalize_replayable_response( guard.remove(); dedup_guard.remove(); cache_guard.skip(); - build_error_response_with_meta( + let mut response = build_error_response_with_meta( StatusCode::INTERNAL_SERVER_ERROR.as_u16(), "Failed to capture response for replay", Some(request_id), None, Some(GatewayErrorCode::InternalError), - ) + ); + set_request_id_header(&mut response, request_id); + set_static_diag_header(&mut response, "x-proxycast-source", "replay-capture-error"); + response } } } @@ -1748,17 +1839,24 @@ pub async fn chat_completions( if let Some(ref key) = idempotency_key { match state.idempotency_store.check(key) { crate::middleware::idempotency::IdempotencyCheck::InProgress => { - return build_error_response_with_meta( + let mut response = build_error_response_with_meta( StatusCode::CONFLICT.as_u16(), "Request already in progress", Some(&ctx.request_id), None, Some(GatewayErrorCode::RequestConflict), ); + set_request_id_header(&mut response, &ctx.request_id); + set_static_diag_header(&mut response, "x-proxycast-idempotency", "in-progress"); + return response; } crate::middleware::idempotency::IdempotencyCheck::Completed { status, body } => { let status_code = StatusCode::from_u16(status).unwrap_or(StatusCode::OK); - return (status_code, body).into_response(); + let mut response = (status_code, body).into_response(); + set_request_id_header(&mut response, &ctx.request_id); + set_static_diag_header(&mut response, "x-proxycast-idempotency", "replay"); + set_static_diag_header(&mut response, "x-proxycast-source", "idempotency"); + return response; } crate::middleware::idempotency::IdempotencyCheck::New => {} } @@ -1922,6 +2020,7 @@ pub async fn chat_completions( if !request.stream { let request_payload = serde_json::to_value(&request).unwrap_or_default(); match begin_response_cache( + &ctx.request_id, "chat_completions", &request_payload, &headers, @@ -1932,7 +2031,14 @@ pub async fn chat_completions( .await { Ok(guard) => cache_guard = guard, - Err(resp) => return resp, + Err(resp) => { + return attach_route_debug_headers( + resp, + &selected_provider, + &effective_provider, + &ctx.resolved_model, + ); + } } match begin_request_dedup( &ctx.request_id, @@ -1945,7 +2051,14 @@ pub async fn chat_completions( .await { Ok(guard) => dedup_guard = guard, - Err(resp) => return resp, + Err(resp) => { + return attach_route_debug_headers( + resp, + &selected_provider, + &effective_provider, + &ctx.resolved_model, + ); + } } } @@ -2016,14 +2129,19 @@ pub async fn chat_completions( // 如果成功且需要 Flow 捕获,提取响应体内容和响应头 // 注意:非流式响应需要读取 body,所以必须在这里处理 - return finalize_replayable_response( - response, - &mut idempotency_guard, - &mut dedup_guard, - &mut cache_guard, - &ctx.request_id, - ) - .await; + return attach_route_debug_headers( + finalize_replayable_response( + response, + &mut idempotency_guard, + &mut dedup_guard, + &mut cache_guard, + &ctx.request_id, + ) + .await, + &selected_provider, + &effective_provider, + &ctx.resolved_model, + ); } // 回退到旧的单凭证模式(仅当允许自动降级且选择的 Provider 是 Kiro 时) @@ -2193,14 +2311,19 @@ pub async fn chat_completions( // 完成 Flow 捕获并检查响应拦截 // **Validates: Requirements 2.1, 2.5** let response = Json(response).into_response(); - return finalize_replayable_response( - response, - &mut idempotency_guard, - &mut dedup_guard, - &mut cache_guard, - &ctx.request_id, - ) - .await; + return attach_route_debug_headers( + finalize_replayable_response( + response, + &mut idempotency_guard, + &mut dedup_guard, + &mut cache_guard, + &ctx.request_id, + ) + .await, + &selected_provider, + &effective_provider, + &ctx.resolved_model, + ); } Err(e) => { // 记录失败请求统计 @@ -2301,14 +2424,19 @@ pub async fn chat_completions( // 完成 Flow 捕获并检查响应拦截(重试成功) // **Validates: Requirements 2.1, 2.5** let response = Json(response).into_response(); - return finalize_replayable_response( - response, - &mut idempotency_guard, - &mut dedup_guard, - &mut cache_guard, - &ctx.request_id, - ) - .await; + return attach_route_debug_headers( + finalize_replayable_response( + response, + &mut idempotency_guard, + &mut dedup_guard, + &mut cache_guard, + &ctx.request_id, + ) + .await, + &selected_provider, + &effective_provider, + &ctx.resolved_model, + ); } Err(e) => { // 标记 Flow 失败 @@ -2436,17 +2564,24 @@ pub async fn anthropic_messages( if let Some(ref key) = idempotency_key { match state.idempotency_store.check(key) { crate::middleware::idempotency::IdempotencyCheck::InProgress => { - return build_error_response_with_meta( + let mut response = build_error_response_with_meta( StatusCode::CONFLICT.as_u16(), "Request already in progress", Some(&ctx.request_id), None, Some(GatewayErrorCode::RequestConflict), ); + set_request_id_header(&mut response, &ctx.request_id); + set_static_diag_header(&mut response, "x-proxycast-idempotency", "in-progress"); + return response; } crate::middleware::idempotency::IdempotencyCheck::Completed { status, body } => { let status_code = StatusCode::from_u16(status).unwrap_or(StatusCode::OK); - return (status_code, body).into_response(); + let mut response = (status_code, body).into_response(); + set_request_id_header(&mut response, &ctx.request_id); + set_static_diag_header(&mut response, "x-proxycast-idempotency", "replay"); + set_static_diag_header(&mut response, "x-proxycast-source", "idempotency"); + return response; } crate::middleware::idempotency::IdempotencyCheck::New => {} } @@ -2642,6 +2777,7 @@ pub async fn anthropic_messages( if !request.stream { let request_payload = serde_json::to_value(&request).unwrap_or_default(); match begin_response_cache( + &ctx.request_id, "anthropic_messages", &request_payload, &headers, @@ -2652,7 +2788,14 @@ pub async fn anthropic_messages( .await { Ok(guard) => cache_guard = guard, - Err(resp) => return resp, + Err(resp) => { + return attach_route_debug_headers( + resp, + &selected_provider, + &effective_provider, + &ctx.resolved_model, + ); + } } match begin_request_dedup( &ctx.request_id, @@ -2665,7 +2808,14 @@ pub async fn anthropic_messages( .await { Ok(guard) => dedup_guard = guard, - Err(resp) => return resp, + Err(resp) => { + return attach_route_debug_headers( + resp, + &selected_provider, + &effective_provider, + &ctx.resolved_model, + ); + } } } @@ -2751,14 +2901,19 @@ pub async fn anthropic_messages( // 完成 Flow 捕获并检查响应拦截 // **Validates: Requirements 2.1, 2.5** - return finalize_replayable_response( - response, - &mut idempotency_guard, - &mut dedup_guard, - &mut cache_guard, - &ctx.request_id, - ) - .await; + return attach_route_debug_headers( + finalize_replayable_response( + response, + &mut idempotency_guard, + &mut dedup_guard, + &mut cache_guard, + &ctx.request_id, + ) + .await, + &selected_provider, + &effective_provider, + &ctx.resolved_model, + ); } // 回退到旧的单凭证模式(仅当允许自动降级且选择的 Provider 是 Kiro 时) @@ -2938,14 +3093,19 @@ pub async fn anthropic_messages( // 非流式响应 let response = build_anthropic_response(&request.model, &parsed); - return finalize_replayable_response( - response, - &mut idempotency_guard, - &mut dedup_guard, - &mut cache_guard, - &ctx.request_id, - ) - .await; + return attach_route_debug_headers( + finalize_replayable_response( + response, + &mut idempotency_guard, + &mut dedup_guard, + &mut cache_guard, + &ctx.request_id, + ) + .await, + &selected_provider, + &effective_provider, + &ctx.resolved_model, + ); } Err(e) => { state @@ -3019,14 +3179,19 @@ pub async fn anthropic_messages( } let response = build_anthropic_response(&request.model, &parsed); - return finalize_replayable_response( - response, - &mut idempotency_guard, - &mut dedup_guard, - &mut cache_guard, - &ctx.request_id, - ) - .await; + return attach_route_debug_headers( + finalize_replayable_response( + response, + &mut idempotency_guard, + &mut dedup_guard, + &mut cache_guard, + &ctx.request_id, + ) + .await, + &selected_provider, + &effective_provider, + &ctx.resolved_model, + ); } Err(e) => { state.logs.write().await.add( diff --git a/src-tauri/crates/server/src/lib.rs b/src-tauri/crates/server/src/lib.rs index 5f45bc318..c25bb98da 100644 --- a/src-tauri/crates/server/src/lib.rs +++ b/src-tauri/crates/server/src/lib.rs @@ -6,7 +6,7 @@ pub mod client_detector; pub mod middleware; use axum::{ - extract::{DefaultBodyLimit, Path, State}, + extract::{DefaultBodyLimit, Path, Query, State}, http::{header, HeaderMap, HeaderValue, Method, StatusCode}, response::{IntoResponse, Response}, routing::{get, post}, @@ -35,7 +35,7 @@ use proxycast_providers::providers::openai_custom::OpenAICustomProvider; use proxycast_server_utils::{ build_anthropic_response, build_anthropic_stream_response, build_error_response, build_error_response_with_status, build_gemini_cli_request, build_gemini_native_request, - health, models, parse_cw_response, + models, parse_cw_response, }; use proxycast_services::kiro_event_service::KiroEventService; use proxycast_services::provider_pool_service::ProviderPoolService; @@ -182,6 +182,94 @@ pub struct ServerStatus { pub idempotency: middleware::idempotency::IdempotencyStats, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponseCacheDiagnostics { + pub config: middleware::response_cache::ResponseCacheConfig, + pub stats: middleware::response_cache::ResponseCacheStats, + pub hit_rate_percent: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RequestDedupDiagnostics { + pub config: middleware::request_dedup::RequestDedupConfig, + pub stats: middleware::request_dedup::RequestDedupStats, + pub replay_rate_percent: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IdempotencyDiagnostics { + pub config: middleware::idempotency::IdempotencyConfig, + pub stats: middleware::idempotency::IdempotencyStats, + pub replay_rate_percent: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerDiagnostics { + pub generated_at: String, + pub running: bool, + pub host: String, + pub port: u16, + pub telemetry_summary: proxycast_infra::telemetry::StatsSummary, + pub capability_routing: + middleware::capability_routing_metrics::CapabilityRoutingMetricsSnapshot, + pub response_cache: ResponseCacheDiagnostics, + pub request_dedup: RequestDedupDiagnostics, + pub idempotency: IdempotencyDiagnostics, +} + +pub fn build_response_cache_diagnostics( + store: &middleware::response_cache::ResponseCacheStore, +) -> ResponseCacheDiagnostics { + ResponseCacheDiagnostics { + config: store.config(), + stats: store.stats(), + hit_rate_percent: store.hit_rate_percent(), + } +} + +pub fn build_request_dedup_diagnostics( + store: &middleware::request_dedup::RequestDedupStore, +) -> RequestDedupDiagnostics { + RequestDedupDiagnostics { + config: store.config(), + stats: store.stats(), + replay_rate_percent: store.replay_rate_percent(), + } +} + +pub fn build_idempotency_diagnostics( + store: &middleware::idempotency::IdempotencyStore, +) -> IdempotencyDiagnostics { + IdempotencyDiagnostics { + config: store.config(), + stats: store.stats(), + replay_rate_percent: store.replay_rate_percent(), + } +} + +pub fn build_server_diagnostics( + running: bool, + host: String, + port: u16, + telemetry_summary: proxycast_infra::telemetry::StatsSummary, + capability_routing: middleware::capability_routing_metrics::CapabilityRoutingMetricsSnapshot, + response_cache_store: &middleware::response_cache::ResponseCacheStore, + request_dedup_store: &middleware::request_dedup::RequestDedupStore, + idempotency_store: &middleware::idempotency::IdempotencyStore, +) -> ServerDiagnostics { + ServerDiagnostics { + generated_at: chrono::Utc::now().to_rfc3339(), + running, + host, + port, + telemetry_summary, + capability_routing, + response_cache: build_response_cache_diagnostics(response_cache_store), + request_dedup: build_request_dedup_diagnostics(request_dedup_store), + idempotency: build_idempotency_diagnostics(idempotency_store), + } +} + pub struct ServerState { pub config: Config, pub running: bool, @@ -1107,6 +1195,8 @@ async fn run_server( let app = Router::new() .route("/health", get(health)) + .route("/cache", get(cache_diagnostics)) + .route("/stats", get(stats_diagnostics)) .route("/v1/models", get(models)) .route("/v1/routes", get(list_routes)) .route("/v1/chat/completions", post( @@ -1182,6 +1272,103 @@ async fn run_server( Ok(()) } +#[derive(Debug, Default, Deserialize)] +struct HealthQuery { + #[serde(default)] + full: bool, +} + +#[derive(Debug, Default, Deserialize)] +struct StatsQuery { + days: Option, +} + +fn parse_base_url_host_port(base_url: &str) -> (String, u16) { + if let Ok(url) = reqwest::Url::parse(base_url) { + let host = url.host_str().unwrap_or("127.0.0.1").to_string(); + let port = url.port_or_known_default().unwrap_or_else(|| { + if url.scheme() == "https" { + 443 + } else { + 80 + } + }); + return (host, port); + } + ("127.0.0.1".to_string(), 3030) +} + +fn build_diagnostics_from_app_state( + state: &AppState, + stats_range: Option, +) -> ServerDiagnostics { + let telemetry_summary = state.processor.stats.read().summary(stats_range); + let (host, port) = parse_base_url_host_port(&state.base_url); + build_server_diagnostics( + true, + host, + port, + telemetry_summary, + state.capability_routing_metrics_store.snapshot(), + state.response_cache_store.as_ref(), + state.request_dedup_store.as_ref(), + state.idempotency_store.as_ref(), + ) +} + +async fn health(State(state): State, Query(query): Query) -> Response { + if !query.full { + return Json(serde_json::json!({ + "status": "healthy", + "version": env!("CARGO_PKG_VERSION") + })) + .into_response(); + } + + let diagnostics = build_diagnostics_from_app_state(&state, None); + ( + [ + (header::CACHE_CONTROL, "no-cache"), + (header::PRAGMA, "no-cache"), + ], + Json(serde_json::json!({ + "status": "healthy", + "version": env!("CARGO_PKG_VERSION"), + "diagnostics": diagnostics + })), + ) + .into_response() +} + +async fn cache_diagnostics(State(state): State) -> Response { + let cache = build_response_cache_diagnostics(state.response_cache_store.as_ref()); + ( + [ + (header::CACHE_CONTROL, "no-cache"), + (header::PRAGMA, "no-cache"), + ], + Json(cache), + ) + .into_response() +} + +async fn stats_diagnostics( + State(state): State, + Query(query): Query, +) -> Response { + let days = query.days.unwrap_or(7).clamp(1, 30); + let range = proxycast_infra::telemetry::TimeRange::last_days(days as i64); + let diagnostics = build_diagnostics_from_app_state(&state, Some(range)); + ( + [ + (header::CACHE_CONTROL, "no-cache"), + (header::PRAGMA, "no-cache"), + ], + Json(diagnostics), + ) + .into_response() +} + async fn count_tokens( State(state): State, headers: HeaderMap, diff --git a/src-tauri/crates/server/src/middleware/idempotency.rs b/src-tauri/crates/server/src/middleware/idempotency.rs index a9cff84cf..40ee25063 100644 --- a/src-tauri/crates/server/src/middleware/idempotency.rs +++ b/src-tauri/crates/server/src/middleware/idempotency.rs @@ -100,6 +100,10 @@ impl IdempotencyStore { } } + pub fn config(&self) -> IdempotencyConfig { + self.config.clone() + } + /// 检查幂等性键 pub fn check(&self, key: &str) -> IdempotencyCheck { if !self.config.enabled { @@ -226,6 +230,17 @@ impl IdempotencyStore { remove_total: self.remove_total.load(Ordering::Relaxed), } } + + pub fn replay_rate_percent(&self) -> f64 { + let stats = self.stats(); + let total_checks = + stats.check_new_total + stats.check_in_progress_total + stats.check_completed_total; + if total_checks == 0 { + 0.0 + } else { + (stats.check_completed_total as f64 / total_checks as f64) * 100.0 + } + } } #[cfg(test)] diff --git a/src-tauri/src/README.md b/src-tauri/src/README.md index 56456c912..454422af2 100644 --- a/src-tauri/src/README.md +++ b/src-tauri/src/README.md @@ -28,6 +28,8 @@ Tauri 后端核心代码,处理系统级功能和 API 服务。 - `router/` - 请求路由(模型映射、规则匹配) - `server/` - HTTP 服务器(OpenAI/Claude 兼容 API) - `services/` - 业务服务层 + - 包含更新提醒独立窗口(底部 Toast)管理能力 + - 更新检查支持提醒频控(同版本 24h 一次、关闭退避)与埋点计数 - `streaming/` - 流式响应处理 - `telemetry/` - 遥测和统计 - `terminal/` - 终端核心模块(PTY 管理、会话管理) diff --git a/src-tauri/src/app/commands/api_test.rs b/src-tauri/src/app/commands/api_test.rs index 728842047..ffc86aff6 100644 --- a/src-tauri/src/app/commands/api_test.rs +++ b/src-tauri/src/app/commands/api_test.rs @@ -12,6 +12,7 @@ pub struct TestResult { pub status: u16, pub body: String, pub time_ms: u64, + pub response_headers: std::collections::HashMap, } /// 模型信息 @@ -346,6 +347,16 @@ pub async fn test_api( match req.send().await { Ok(resp) => { let status = resp.status().as_u16(); + let response_headers = resp + .headers() + .iter() + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|text| (name.as_str().to_string(), text.to_string())) + }) + .collect::>(); let body = resp.text().await.unwrap_or_default(); let time_ms = start.elapsed().as_millis() as u64; @@ -360,6 +371,7 @@ pub async fn test_api( status, body, time_ms, + response_headers, }) } Err(e) => { diff --git a/src-tauri/src/app/commands/server.rs b/src-tauri/src/app/commands/server.rs index 4313111b0..78b11712a 100644 --- a/src-tauri/src/app/commands/server.rs +++ b/src-tauri/src/app/commands/server.rs @@ -126,3 +126,25 @@ pub async fn get_server_status( Ok(status) } + +/// 获取服务器诊断信息(对标 /stats 与 /cache 端点) +#[tauri::command] +pub async fn get_server_diagnostics( + state: tauri::State<'_, AppState>, + telemetry_state: tauri::State<'_, TelemetryState>, +) -> Result { + let s = state.read().await; + let status = s.status(); + let telemetry_summary = telemetry_state.stats.read().summary(None); + + Ok(server::build_server_diagnostics( + status.running, + status.host, + status.port, + telemetry_summary, + status.capability_routing, + s.response_cache_store.as_ref(), + s.request_dedup_store.as_ref(), + s.idempotency_store.as_ref(), + )) +} diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index a63d2fd52..296e9bca0 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -729,6 +729,7 @@ pub fn run() { app_commands::start_server, app_commands::stop_server, app_commands::get_server_status, + app_commands::get_server_diagnostics, // Config commands (from app::commands) app_commands::get_config, app_commands::save_config, @@ -1220,7 +1221,11 @@ pub fn run() { commands::update_cmd::check_update, commands::update_cmd::get_update_check_settings, commands::update_cmd::set_update_check_settings, + commands::update_cmd::get_update_notification_metrics, + commands::update_cmd::record_update_notification_action, commands::update_cmd::skip_update_version, + commands::update_cmd::remind_update_later, + commands::update_cmd::dismiss_update_notification, commands::update_cmd::update_last_check_timestamp, commands::update_cmd::close_update_window, commands::update_cmd::test_update_window, @@ -1361,6 +1366,7 @@ pub fn run() { commands::novel_cmd::novel_check_consistency, commands::novel_cmd::novel_get_project_snapshot, commands::novel_cmd::novel_list_runs, + commands::novel_cmd::novel_delete_character, // Memory commands (Character, WorldBuilding, StyleGuide, Outline) commands::memory_cmd::character_create, commands::memory_cmd::character_get, diff --git a/src-tauri/src/commands/novel_cmd.rs b/src-tauri/src/commands/novel_cmd.rs index f07d35750..97534f178 100644 --- a/src-tauri/src/commands/novel_cmd.rs +++ b/src-tauri/src/commands/novel_cmd.rs @@ -4,10 +4,10 @@ use crate::database::DbConnection; use crate::services::novel_service::{ - NovelCheckConsistencyRequest, NovelCreateProjectRequest, NovelGenerateChapterRequest, - NovelGenerateRequest, NovelGenerateResult, NovelGenerationRun, NovelListRunsRequest, - NovelPolishChapterRequest, NovelProject, NovelProjectSnapshot, NovelRewriteChapterRequest, - NovelSettingsRecord, NovelUpdateSettingsRequest, + NovelCheckConsistencyRequest, NovelCreateProjectRequest, NovelDeleteCharacterRequest, + NovelGenerateChapterRequest, NovelGenerateRequest, NovelGenerateResult, NovelGenerationRun, + NovelListRunsRequest, NovelPolishChapterRequest, NovelProject, NovelProjectSnapshot, + NovelRewriteChapterRequest, NovelSettingsRecord, NovelUpdateSettingsRequest, }; use tauri::State; @@ -109,3 +109,12 @@ pub async fn novel_list_runs( ) -> Result, String> { crate::theme::novel::command::novel_list_runs(db, request).await } + +/// 删除单个角色 +#[tauri::command] +pub async fn novel_delete_character( + db: State<'_, DbConnection>, + request: NovelDeleteCharacterRequest, +) -> Result { + crate::theme::novel::command::novel_delete_character(db, request).await +} diff --git a/src-tauri/src/commands/update_cmd.rs b/src-tauri/src/commands/update_cmd.rs index 2807301bd..c0aa3a86f 100644 --- a/src-tauri/src/commands/update_cmd.rs +++ b/src-tauri/src/commands/update_cmd.rs @@ -17,6 +17,8 @@ use std::sync::Arc; use tauri::{AppHandle, Manager, State}; use tokio::sync::RwLock; +const DAY_SECONDS: u64 = 24 * 3600; + /// 更新检查配置(前端可见) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateCheckSettings { @@ -25,6 +27,29 @@ pub struct UpdateCheckSettings { pub show_notification: bool, pub last_check_timestamp: u64, pub skipped_version: Option, + pub remind_later_until: Option, +} + +/// 更新提醒埋点指标 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateNotificationMetrics { + pub shown_count: u64, + pub update_now_count: u64, + pub remind_later_count: u64, + pub skip_version_count: u64, + pub dismiss_count: u64, + pub update_now_rate: f64, + pub remind_later_rate: f64, + pub skip_version_rate: f64, + pub dismiss_rate: f64, +} + +fn rate_percent(numerator: u64, denominator: u64) -> f64 { + if denominator == 0 { + return 0.0; + } + let rate = numerator as f64 * 100.0 / denominator as f64; + (rate * 10.0).round() / 10.0 } /// 手动检查更新 @@ -50,6 +75,7 @@ pub async fn get_update_check_settings( show_notification: update_config.show_notification, last_check_timestamp: update_config.last_check_timestamp, skipped_version: update_config.skipped_version.clone(), + remind_later_until: update_config.remind_later_until, }) } @@ -66,6 +92,61 @@ pub async fn set_update_check_settings( update_config.check_interval_hours = settings.check_interval_hours; update_config.show_notification = settings.show_notification; update_config.skipped_version = settings.skipped_version; + update_config.remind_later_until = settings.remind_later_until; + + config::save_config(&state.config).map_err(|e| format!("保存配置失败: {e}")) +} + +/// 获取更新提醒埋点指标 +#[tauri::command] +pub async fn get_update_notification_metrics( + app_state: State<'_, AppState>, +) -> Result { + let state = app_state.read().await; + let update_config = &state.config.experimental.update_check; + + let shown = update_config.notification_shown_count; + let update_now = update_config.action_update_now_count; + let remind_later = update_config.action_remind_later_count; + let skip_version = update_config.action_skip_version_count; + let dismiss = update_config.action_dismiss_count; + + Ok(UpdateNotificationMetrics { + shown_count: shown, + update_now_count: update_now, + remind_later_count: remind_later, + skip_version_count: skip_version, + dismiss_count: dismiss, + update_now_rate: rate_percent(update_now, shown), + remind_later_rate: rate_percent(remind_later, shown), + skip_version_rate: rate_percent(skip_version, shown), + dismiss_rate: rate_percent(dismiss, shown), + }) +} + +/// 记录更新提醒操作行为(用于埋点) +#[tauri::command] +pub async fn record_update_notification_action( + app_state: State<'_, AppState>, + action: String, +) -> Result<(), String> { + let mut state = app_state.write().await; + let update_config = &mut state.config.experimental.update_check; + + match action.as_str() { + "update_now" => { + update_config.action_update_now_count = + update_config.action_update_now_count.saturating_add(1); + update_config.dismiss_streak = 0; + update_config.next_notify_after = None; + update_config.remind_later_until = None; + } + "shown" => { + update_config.notification_shown_count = + update_config.notification_shown_count.saturating_add(1); + } + _ => return Err(format!("不支持的更新提醒操作: {action}")), + } config::save_config(&state.config).map_err(|e| format!("保存配置失败: {e}")) } @@ -78,7 +159,13 @@ pub async fn skip_update_version( version: String, ) -> Result<(), String> { let mut state = app_state.write().await; - state.config.experimental.update_check.skipped_version = Some(version); + let update_config = &mut state.config.experimental.update_check; + update_config.skipped_version = Some(version); + update_config.action_skip_version_count = + update_config.action_skip_version_count.saturating_add(1); + update_config.dismiss_streak = 0; + update_config.next_notify_after = None; + update_config.remind_later_until = None; config::save_config(&state.config).map_err(|e| format!("保存配置失败: {e}"))?; @@ -88,6 +175,73 @@ pub async fn skip_update_version( Ok(()) } +/// 稍后提醒(默认 24 小时) +#[tauri::command] +pub async fn remind_update_later( + app_handle: AppHandle, + app_state: State<'_, AppState>, + hours: Option, +) -> Result { + let remind_hours = hours.unwrap_or(24).clamp(1, 24 * 30); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let remind_until = now + (remind_hours as u64 * 3600); + + let mut state = app_state.write().await; + let update_config = &mut state.config.experimental.update_check; + update_config.remind_later_until = Some(remind_until); + update_config.next_notify_after = Some(remind_until); + update_config.dismiss_streak = 0; + update_config.action_remind_later_count = + update_config.action_remind_later_count.saturating_add(1); + + config::save_config(&state.config).map_err(|e| format!("保存配置失败: {e}"))?; + + // 关闭更新窗口 + let _ = update_window::close_update_window(&app_handle); + + Ok(remind_until) +} + +/// 关闭提醒并按连续关闭次数设置退避(1天/3天/7天) +#[tauri::command] +pub async fn dismiss_update_notification( + app_handle: AppHandle, + app_state: State<'_, AppState>, + version: Option, +) -> Result { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let mut state = app_state.write().await; + let update_config = &mut state.config.experimental.update_check; + + let next_streak = update_config.dismiss_streak.saturating_add(1); + let backoff_days = match next_streak { + 1 => 1_u64, + 2 => 3_u64, + _ => 7_u64, + }; + let next_notify_after = now + backoff_days * DAY_SECONDS; + + update_config.dismiss_streak = next_streak.min(3); + update_config.next_notify_after = Some(next_notify_after); + update_config.remind_later_until = None; + update_config.action_dismiss_count = update_config.action_dismiss_count.saturating_add(1); + if version.is_some() { + update_config.last_notified_version = version; + } + + config::save_config(&state.config).map_err(|e| format!("保存配置失败: {e}"))?; + + let _ = update_window::close_update_window(&app_handle); + Ok(next_notify_after) +} + /// 关闭更新提醒窗口 #[tauri::command] pub fn close_update_window(app_handle: AppHandle) -> Result<(), String> { @@ -148,7 +302,17 @@ pub async fn start_background_update_check( loop { // 获取当前配置 - let (enabled, interval_hours, show_notification, last_check, skipped_version) = { + let ( + enabled, + interval_hours, + show_notification, + last_check, + skipped_version, + remind_later_until, + last_notified_version, + last_notified_at, + next_notify_after, + ) = { if let Some(app_state) = app_handle_clone.try_state::() { let state = app_state.read().await; let update_config = &state.config.experimental.update_check; @@ -158,9 +322,13 @@ pub async fn start_background_update_check( update_config.show_notification, update_config.last_check_timestamp, update_config.skipped_version.clone(), + update_config.remind_later_until, + update_config.last_notified_version.clone(), + update_config.last_notified_at, + update_config.next_notify_after, ) } else { - (true, 24, true, 0, None) + (true, 24, true, 0, None, None, None, 0, None) } }; @@ -205,13 +373,41 @@ pub async fn start_background_update_check( // 如果有更新且启用了通知,打开独立的更新提醒窗口 if result.has_update && show_notification { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let in_remind_later = remind_later_until.is_some_and(|ts| ts > now); + let in_backoff = next_notify_after.is_some_and(|ts| ts > now); + let same_version_daily_limited = + result.latest_version.as_ref().is_some_and(|latest| { + last_notified_version.as_ref() == Some(latest) + && now < last_notified_at.saturating_add(DAY_SECONDS) + }); + // 检查是否跳过了此版本 let should_notify = result .latest_version .as_ref() - .is_none_or(|latest| skipped_version.as_ref() != Some(latest)); + .is_none_or(|latest| skipped_version.as_ref() != Some(latest)) + && !in_remind_later + && !in_backoff + && !same_version_daily_limited; if should_notify { + if let Some(app_state) = app_handle_clone.try_state::() { + let mut state = app_state.write().await; + let update_config = &mut state.config.experimental.update_check; + update_config.last_notified_version = result.latest_version.clone(); + update_config.last_notified_at = now; + update_config.notification_shown_count = + update_config.notification_shown_count.saturating_add(1); + if update_config.next_notify_after.is_some_and(|ts| ts <= now) { + update_config.next_notify_after = None; + } + let _ = config::save_config(&state.config); + } + // 打开独立的更新提醒窗口 - 必须在主线程执行 let app_handle_for_ui = app_handle_clone.clone(); let result_clone = result.clone(); diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index 760fe28bf..08ce7c3df 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -79,6 +79,30 @@ pub async fn handle_command( Ok(status) } + "get_server_diagnostics" => { + let (host, port) = if let Ok(url) = reqwest::Url::parse(&state.base_url) { + ( + url.host_str().unwrap_or("127.0.0.1").to_string(), + url.port_or_known_default().unwrap_or(3030), + ) + } else { + ("127.0.0.1".to_string(), 3030) + }; + + let telemetry_summary = state.processor.stats.read().summary(None); + let diagnostics = proxycast_server::build_server_diagnostics( + true, + host, + port, + telemetry_summary, + state.capability_routing_metrics_store.snapshot(), + state.response_cache_store.as_ref(), + state.request_dedup_store.as_ref(), + state.idempotency_store.as_ref(), + ); + Ok(serde_json::to_value(diagnostics)?) + } + // ========== P1 - 日志相关 ========== "get_logs" => { let logs = state.logs.read().await; diff --git a/src-tauri/src/services/README.md b/src-tauri/src/services/README.md index 6480a8c07..34b2817a7 100644 --- a/src-tauri/src/services/README.md +++ b/src-tauri/src/services/README.md @@ -25,7 +25,7 @@ - `sysinfo_service.rs` - Tauri 命令桥接(纯逻辑已迁移到 `crates/services/src/sysinfo_service.rs`) - `file_browser_service.rs` - Tauri 命令桥接(纯逻辑已迁移到 `crates/services/src/file_browser_service.rs`) - `update_check_service.rs` - 兼容导出层(纯逻辑已迁移到 `crates/services/src/update_check_service.rs`) -- `update_window.rs` - 更新提醒独立窗口管理 +- `update_window.rs` - 更新提醒独立窗口管理(底部居中 Toast 窗口定位) - `general_chat/` - 通用对话服务模块(会话管理、消息存储) - `api_key_provider_service.rs` - API Key Provider 服务 - `kiro_event_service.rs` - Kiro 事件服务 diff --git a/src-tauri/src/services/novel_service.rs b/src-tauri/src/services/novel_service.rs index 1ac820b7a..bcb35d003 100644 --- a/src-tauri/src/services/novel_service.rs +++ b/src-tauri/src/services/novel_service.rs @@ -479,6 +479,12 @@ pub struct NovelListRunsRequest { pub limit: Option, } +#[derive(Debug, Clone, Deserialize)] +pub struct NovelDeleteCharacterRequest { + pub project_id: String, + pub character_id: String, +} + #[derive(Clone)] pub struct NovelService { db: DbConnection, @@ -872,6 +878,45 @@ impl NovelService { Ok(rows) } + pub fn delete_character(&self, request: NovelDeleteCharacterRequest) -> Result { + self.ensure_project_exists(&request.project_id)?; + let now = chrono::Utc::now().timestamp_millis(); + let mut conn = lock_db(&self.db)?; + let tx = conn + .transaction() + .map_err(|e| format!("开启事务失败: {e}"))?; + + let exists = tx + .query_row( + "SELECT 1 FROM novel_characters WHERE id = ?1 AND project_id = ?2 LIMIT 1", + params![&request.character_id, &request.project_id], + |_| Ok(()), + ) + .optional() + .map_err(|e| format!("检查角色是否存在失败: {e}"))? + .is_some(); + + if !exists { + tx.commit().map_err(|e| format!("提交事务失败: {e}"))?; + return Ok(false); + } + + tx.execute( + "DELETE FROM novel_characters WHERE id = ?1 AND project_id = ?2", + params![&request.character_id, &request.project_id], + ) + .map_err(|e| format!("删除角色失败: {e}"))?; + + tx.execute( + "UPDATE novel_projects SET updated_at = ?1 WHERE id = ?2", + params![now, &request.project_id], + ) + .map_err(|e| format!("更新项目时间失败: {e}"))?; + + tx.commit().map_err(|e| format!("提交事务失败: {e}"))?; + Ok(true) + } + fn list_characters(&self, project_id: &str) -> Result, String> { let conn = lock_db(&self.db)?; let mut stmt = conn @@ -1777,6 +1822,16 @@ fn looks_like_json_noise_line(line: &str) -> bool { if trimmed.is_empty() { return true; } + let lowered = trimmed.to_ascii_lowercase(); + if trimmed.starts_with("```") + || trimmed.ends_with("```") + || matches!( + lowered.as_str(), + "```" | "```json" | "```yaml" | "```yml" | "json" | "yaml" | "yml" + ) + { + return true; + } if matches!(trimmed, "{" | "}" | "[" | "]" | ",") { return true; } @@ -2660,4 +2715,17 @@ mod tests { let cards = parse_character_cards(raw); assert!(cards.is_empty()); } + + #[test] + fn parse_character_cards_should_ignore_markdown_fence_noise_in_fallback_lines() { + let raw = r#" +```json +林舟 +``` +"#; + + let cards = parse_character_cards(raw); + assert_eq!(cards.len(), 1); + assert_eq!(cards[0]["name"], Value::String("林舟".to_string())); + } } diff --git a/src-tauri/src/services/update_window.rs b/src-tauri/src/services/update_window.rs index 693c9f144..41403c13c 100644 --- a/src-tauri/src/services/update_window.rs +++ b/src-tauri/src/services/update_window.rs @@ -33,11 +33,11 @@ pub enum UpdateWindowError { const UPDATE_WINDOW_LABEL: &str = "update-notification"; /// 窗口尺寸 -const WINDOW_WIDTH: f64 = 340.0; -const WINDOW_HEIGHT: f64 = 280.0; +const WINDOW_WIDTH: f64 = 620.0; +const WINDOW_HEIGHT: f64 = 132.0; -/// 距离屏幕顶部的边距 -const TOP_MARGIN: f64 = 80.0; +/// 距离屏幕底部的边距 +const BOTTOM_MARGIN: f64 = 24.0; /// 获取鼠标所在的显示器 fn get_monitor_at_cursor(app: &AppHandle) -> Option { @@ -86,7 +86,7 @@ fn get_monitor_at_cursor(app: &AppHandle) -> Option { None } -/// 计算窗口位置(鼠标所在屏幕的顶部居中) +/// 计算窗口位置(鼠标所在屏幕的底部居中) fn calculate_window_position(app: &AppHandle) -> (f64, f64) { // 优先获取鼠标所在的显示器,否则使用主显示器 let monitor = get_monitor_at_cursor(app).or_else(|| app.primary_monitor().ok().flatten()); @@ -98,16 +98,17 @@ fn calculate_window_position(app: &AppHandle) -> (f64, f64) { // 物理像素转换为逻辑像素 let screen_width = screen_size.width as f64 / scale_factor; + let screen_height = screen_size.height as f64 / scale_factor; let screen_x = screen_pos.x as f64 / scale_factor; let screen_y = screen_pos.y as f64 / scale_factor; - // 顶部居中定位 + // 底部居中定位 let x = screen_x + (screen_width - WINDOW_WIDTH) / 2.0; - let y = screen_y + TOP_MARGIN; + let y = screen_y + screen_height - WINDOW_HEIGHT - BOTTOM_MARGIN; debug!( - "更新窗口位置: 屏幕({}, {}), 尺寸: {}x?, 窗口位置: ({}, {})", - screen_x, screen_y, screen_width, x, y + "更新窗口位置: 屏幕({}, {}), 尺寸: {}x{}, 窗口位置: ({}, {})", + screen_x, screen_y, screen_width, screen_height, x, y ); return (x, y); } diff --git a/src-tauri/src/theme/novel/command.rs b/src-tauri/src/theme/novel/command.rs index 82e303166..ef76ab6a6 100644 --- a/src-tauri/src/theme/novel/command.rs +++ b/src-tauri/src/theme/novel/command.rs @@ -4,10 +4,10 @@ use crate::database::DbConnection; use crate::services::novel_service::{ - NovelCheckConsistencyRequest, NovelCreateProjectRequest, NovelGenerateChapterRequest, - NovelGenerateRequest, NovelGenerateResult, NovelGenerationRun, NovelListRunsRequest, - NovelPolishChapterRequest, NovelProject, NovelProjectSnapshot, NovelRewriteChapterRequest, - NovelSettingsRecord, NovelUpdateSettingsRequest, + NovelCheckConsistencyRequest, NovelCreateProjectRequest, NovelDeleteCharacterRequest, + NovelGenerateChapterRequest, NovelGenerateRequest, NovelGenerateResult, NovelGenerationRun, + NovelListRunsRequest, NovelPolishChapterRequest, NovelProject, NovelProjectSnapshot, + NovelRewriteChapterRequest, NovelSettingsRecord, NovelUpdateSettingsRequest, }; use tauri::State; @@ -91,3 +91,10 @@ pub async fn novel_list_runs( ) -> Result, String> { service(&db).list_runs(request) } + +pub async fn novel_delete_character( + db: State<'_, DbConnection>, + request: NovelDeleteCharacterRequest, +) -> Result { + service(&db).delete_character(request) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index fd98f3123..e02a0a001 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.76.0", + "version": "0.77.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index a6ab68ef9..c45cd660c 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -565,7 +565,7 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) { ? buildWorkspaceResetParams( item.params as AgentPageParams | undefined, (item.params as AgentPageParams | undefined)?.workspaceViewMode ?? - "project-management", + "workspace", ) : item.params; diff --git a/src/components/api-server/ApiServerPage.tsx b/src/components/api-server/ApiServerPage.tsx index 0b5009291..d68c03623 100644 --- a/src/components/api-server/ApiServerPage.tsx +++ b/src/components/api-server/ApiServerPage.tsx @@ -15,11 +15,13 @@ import { startServer, stopServer, getServerStatus, + getServerDiagnostics, getConfig, saveConfig, reloadCredentials, testApi, ServerStatus, + ServerDiagnostics, Config, TestResult, getDefaultProvider, @@ -53,6 +55,7 @@ interface TestState { response?: string; time?: number; httpStatus?: number; + responseHeaders?: Record; } type TabId = "server" | "logs"; @@ -151,6 +154,9 @@ interface AvailableProvider { export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) { const [status, setStatus] = useState(null); + const [diagnostics, setDiagnostics] = useState(null); + const [diagnosticsLoading, setDiagnosticsLoading] = useState(false); + const [diagnosticsCopied, setDiagnosticsCopied] = useState(false); const [config, setConfig] = useState(null); const [loading, setLoading] = useState(false); const [_error, setError] = useState(null); @@ -197,6 +203,32 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) { } }; + const fetchDiagnostics = async () => { + setDiagnosticsLoading(true); + try { + const detail = await getServerDiagnostics(); + setDiagnostics(detail); + setMessage({ type: "success", text: "诊断数据已刷新" }); + } catch (e: unknown) { + const errMsg = e instanceof Error ? e.message : String(e); + setMessage({ type: "error", text: `诊断拉取失败: ${errMsg}` }); + } finally { + setDiagnosticsLoading(false); + } + }; + + const copyDiagnostics = async () => { + if (!diagnostics) return; + try { + await navigator.clipboard.writeText(JSON.stringify(diagnostics, null, 2)); + setDiagnosticsCopied(true); + setTimeout(() => setDiagnosticsCopied(false), 1500); + } catch (e: unknown) { + const errMsg = e instanceof Error ? e.message : String(e); + setMessage({ type: "error", text: `复制失败: ${errMsg}` }); + } + }; + const fetchConfig = async () => { try { const c = await getConfig(); @@ -216,6 +248,7 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) { useEffect(() => { fetchStatus(); + fetchDiagnostics(); fetchConfig(); loadDefaultProvider(); loadNetworkInfo(); @@ -939,6 +972,10 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) { if (totalChecks === 0) return 0; return (idempotency.check_completed_total / totalChecks) * 100; }, [status]); + const diagnosticsJson = useMemo(() => { + if (!diagnostics) return ""; + return JSON.stringify(diagnostics, null, 2); + }, [diagnostics]); const handleGatewayModeChange = async (mode: GatewayMode) => { const nextHost = mode === "local" ? "127.0.0.1" : "0.0.0.0"; @@ -1055,6 +1092,7 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) { response: result.body || `HTTP ${result.status}: 无响应内容`, time: result.time_ms, httpStatus: result.status, + responseHeaders: result.response_headers || {}, }, })); @@ -1524,6 +1562,33 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) { + +
+
+

+ 诊断接口(`/health?full=true` / `/cache` / `/stats`) +

+
+ + +
+
+
+                {diagnosticsJson || "点击“拉取诊断”获取当前服务诊断 JSON。"}
+              
+
{/* Default Provider - 动态显示有凭证的 Provider */} @@ -1766,6 +1831,11 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) { const result = testResults[endpoint.id]; const isExpanded = expandedTest === endpoint.id; const curlCmd = getCurlCommand(endpoint); + const proxycastDebugHeaders = Object.entries( + result?.responseHeaders || {}, + ).filter(([key]) => + key.toLowerCase().startsWith("x-proxycast-"), + ); return (
)} + {proxycastDebugHeaders.length > 0 && ( +
+

+ 调试头(x-proxycast-*) +

+
+ {proxycastDebugHeaders.map(([key, value]) => ( +
+ + {key} + + + {value} + +
+ ))} +
+
+ )} )} diff --git a/src/components/projects/projectSelectorUtils.ts b/src/components/projects/projectSelectorUtils.ts index 36eb0be11..a71982464 100644 --- a/src/components/projects/projectSelectorUtils.ts +++ b/src/components/projects/projectSelectorUtils.ts @@ -12,7 +12,9 @@ export function getAvailableProjects( projects: Project[], workspaceType?: string, ): Project[] { - let filtered = projects.filter((project) => !project.isArchived); + // 防御性编程:确保 projects 是数组 + const safeProjects = Array.isArray(projects) ? projects : []; + let filtered = safeProjects.filter((project) => !project.isArchived); if (workspaceType) { filtered = filtered.filter( diff --git a/src/components/projects/tabs/novel-flow/NovelFlowWorkbench.tsx b/src/components/projects/tabs/novel-flow/NovelFlowWorkbench.tsx index ea1ee3f91..b28952a88 100644 --- a/src/components/projects/tabs/novel-flow/NovelFlowWorkbench.tsx +++ b/src/components/projects/tabs/novel-flow/NovelFlowWorkbench.tsx @@ -14,6 +14,7 @@ import { cn } from "@/lib/utils"; import { checkNovelConsistency, createNovelProject, + deleteNovelCharacter, generateNovelCharacters, generateNovelOutline, getNovelProjectSnapshot, @@ -49,6 +50,7 @@ import { Loader2, RefreshCw, Sparkles, + Trash2, Users, X, } from "lucide-react"; @@ -324,6 +326,31 @@ function formatCharacterRole(roleType: string): string { return roleType || "配角"; } +function isSuspiciousCharacterName(name: string): boolean { + const normalized = name.trim(); + if (!normalized) { + return true; + } + + const lowered = normalized.toLowerCase(); + if ( + normalized.startsWith("```") || + normalized.endsWith("```") || + ["```", "```json", "```yaml", "```yml", "json", "yaml", "yml"].includes(lowered) + ) { + return true; + } + + const isOnlyStructureChars = [...normalized].every((char) => + "{}[],:".includes(char), + ); + if (isOnlyStructureChars || normalized.includes('":')) { + return true; + } + + return false; +} + function buildCharacterDisplay( character: NovelCharacterRecord, index: number, @@ -481,6 +508,41 @@ export function NovelFlowWorkbench({ [selectedChapterId, snapshot], ); + const characterEntries = useMemo(() => { + if (!snapshot) { + return []; + } + return snapshot.characters.map((character, index) => { + const display = buildCharacterDisplay(character, index); + return { + character, + display, + isSuspicious: isSuspiciousCharacterName(display.name), + }; + }); + }, [snapshot]); + + const mainCharacterEntries = useMemo( + () => + characterEntries.filter( + (item) => item.character.role_type.trim().toLowerCase() === "main", + ), + [characterEntries], + ); + + const sideCharacterEntries = useMemo( + () => + characterEntries.filter( + (item) => item.character.role_type.trim().toLowerCase() !== "main", + ), + [characterEntries], + ); + + const suspiciousCharacterEntries = useMemo( + () => characterEntries.filter((item) => item.isSuspicious), + [characterEntries], + ); + const pipelineState = useMemo( () => resolveNovelPipelineState({ @@ -800,6 +862,84 @@ export function NovelFlowWorkbench({ runAction, ]); + const handleDeleteCharacter = useCallback( + async (characterId: string, characterName: string) => { + if ( + !window.confirm( + `确定要删除角色“${characterName}”吗?\n\n删除后该角色将无法在后续章节生成中被引用。`, + ) + ) { + return; + } + + await runAction("characters", "角色已删除", async () => { + const removed = await deleteNovelCharacter({ + project_id: projectId, + character_id: characterId, + }); + if (!removed) { + throw new Error("角色不存在或已被删除"); + } + await refreshNovelData(); + }); + }, + [projectId, refreshNovelData, runAction], + ); + + const handleCleanupSuspiciousCharacters = useCallback(async () => { + if (suspiciousCharacterEntries.length === 0) { + toast.info("暂无异常角色"); + return; + } + + const previewNames = suspiciousCharacterEntries + .slice(0, 3) + .map((item) => item.display.name) + .join("、"); + const previewText = + suspiciousCharacterEntries.length > 3 + ? `${previewNames} 等 ${suspiciousCharacterEntries.length} 个角色` + : previewNames; + + if ( + !window.confirm( + `检测到异常角色:${previewText}\n\n是否继续清理这些异常角色?`, + ) + ) { + return; + } + + await runAction("characters", "异常角色清理完成", async () => { + let removedCount = 0; + let failedCount = 0; + + for (const item of suspiciousCharacterEntries) { + try { + const removed = await deleteNovelCharacter({ + project_id: projectId, + character_id: item.character.id, + }); + if (removed) { + removedCount += 1; + } else { + failedCount += 1; + } + } catch { + failedCount += 1; + } + } + + await refreshNovelData(); + + if (removedCount === 0) { + throw new Error("未清理任何异常角色"); + } + if (failedCount > 0) { + toast.warning(`已清理 ${removedCount} 个异常角色,${failedCount} 个清理失败`); + } + }); + }, [projectId, refreshNovelData, runAction, suspiciousCharacterEntries]); + const handleGenerateNextChapter = useCallback(async () => { await runAction("chapter-cycle", "章节已生成", async () => { await ensureSettingsSavedForGeneration(); @@ -1050,52 +1190,46 @@ export function NovelFlowWorkbench({ return (
-
-
-

小说流水线工作台

-

- 设定 → 大纲 → 角色 → 章节循环 → 质检 → 发布 -

- {preferredModel && ( -

- 默认模型: - {preferredModel} - {preferredProvider && Provider: {preferredProvider}} +

+
+
+

小说流水线工作台

+

+ 设定 → 大纲 → 角色 → 章节循环 → 质检 → 发布

- )} -
+ {preferredModel && ( +

+ 默认模型: + {preferredModel} + {preferredProvider && Provider: {preferredProvider}} +

+ )} +
-
- -
-
- - - -
-
- - 当前阶段: - {STAGE_METAS.find((item) => item.id === pipelineState.currentStage)?.title} - - - {STAGE_METAS.find((item) => item.id === pipelineState.currentStage)?.description} - -
+
+ +
- - +
+ +
+ {STAGE_METAS.map((stage) => { + const status = pipelineState.stageStatus[stage.id]; + const selected = activeStage === stage.id; + const recommended = stage.id === pipelineState.currentStage; + return ( + + ); + })} +
+
{lastActionError && ( @@ -1329,40 +1503,150 @@ export function NovelFlowWorkbench({ 角色阶段 生成后可在后续章节中自动引用角色关系。 - - + +
+ + {suspiciousCharacterEntries.length > 0 && ( + + )} +
- {snapshot.characters.length === 0 ? ( + {suspiciousCharacterEntries.length > 0 && ( +
+ 检测到 {suspiciousCharacterEntries.length} 个疑似异常角色(如 ```json),建议清理后再继续生成章节。 +
+ )} + + {characterEntries.length === 0 ? (
暂无角色数据
) : ( -
- {snapshot.characters.map((character, index) => { - const display = buildCharacterDisplay(character, index); - return ( -
-
{display.name}
-
- {display.roleLabel} · 版本 {character.version} -
-
- {display.details.map((item) => ( -
- {item.label}: - {item.value} -
- ))} -
+
+
+
+

主要角色

+ + {mainCharacterEntries.length} + +
+ {mainCharacterEntries.length === 0 ? ( +
+ 暂无主要角色
- ); - })} + ) : ( +
+ {mainCharacterEntries.map((item) => ( +
+
+
+
{item.display.name}
+
+ {item.display.roleLabel} · 版本 {item.character.version} +
+
+
+ {item.isSuspicious && 异常} + +
+
+
+ {item.display.details.map((detail) => ( +
+ {detail.label}: + {detail.value} +
+ ))} +
+
+ ))} +
+ )} +
+ +
+
+

次要角色

+ + {sideCharacterEntries.length} + +
+ {sideCharacterEntries.length === 0 ? ( +
+ 暂无次要角色 +
+ ) : ( +
+ {sideCharacterEntries.map((item) => ( +
+
+
+
{item.display.name}
+
+ {item.display.roleLabel} · 版本 {item.character.version} +
+
+
+ {item.isSuspicious && 异常} + +
+
+
+ {item.display.details.map((detail) => ( +
+ {detail.label}: + {detail.value} +
+ ))} +
+
+ ))} +
+ )} +
)} diff --git a/src/components/settings-v2/system/developer/index.tsx b/src/components/settings-v2/system/developer/index.tsx index 61548ee95..1a3a81373 100644 --- a/src/components/settings-v2/system/developer/index.tsx +++ b/src/components/settings-v2/system/developer/index.tsx @@ -18,6 +18,7 @@ import { } from "@/lib/crashDiagnostic"; import { cn } from "@/lib/utils"; import { ClipboardPermissionGuideCard } from "../shared/ClipboardPermissionGuideCard"; +import { WorkspaceRepairHistoryCard } from "../shared/WorkspaceRepairHistoryCard"; export function DeveloperSettings() { const { enabled, setEnabled } = useComponentDebug(); @@ -263,6 +264,8 @@ export function DeveloperSettings() {
+ +
); } diff --git a/src/components/settings-v2/system/experimental/UpdateCheckSettings.tsx b/src/components/settings-v2/system/experimental/UpdateCheckSettings.tsx index 8ddcebe01..482426201 100644 --- a/src/components/settings-v2/system/experimental/UpdateCheckSettings.tsx +++ b/src/components/settings-v2/system/experimental/UpdateCheckSettings.tsx @@ -20,6 +20,19 @@ interface UpdateCheckConfig { show_notification: boolean; last_check_timestamp: number; skipped_version: string | null; + remind_later_until: number | null; +} + +interface UpdateNotificationMetrics { + shown_count: number; + update_now_count: number; + remind_later_count: number; + skip_version_count: number; + dismiss_count: number; + update_now_rate: number; + remind_later_rate: number; + skip_version_rate: number; + dismiss_rate: number; } /** @@ -33,6 +46,18 @@ export function UpdateCheckSettings() { show_notification: true, last_check_timestamp: 0, skipped_version: null, + remind_later_until: null, + }); + const [metrics, setMetrics] = useState({ + shown_count: 0, + update_now_count: 0, + remind_later_count: 0, + skip_version_count: 0, + dismiss_count: 0, + update_now_rate: 0, + remind_later_rate: 0, + skip_version_rate: 0, + dismiss_rate: 0, }); const [loading, setLoading] = useState(true); @@ -42,10 +67,19 @@ export function UpdateCheckSettings() { const loadSettings = async () => { try { - const result = await safeInvoke( + const configResult = await safeInvoke( "get_update_check_settings", ); - setSettings(result); + setSettings(configResult); + + try { + const metricsResult = await safeInvoke( + "get_update_notification_metrics", + ); + setMetrics(metricsResult); + } catch (metricsError) { + console.error("加载更新提醒指标失败:", metricsError); + } } catch (error) { console.error("加载更新检查设置失败:", error); } finally { @@ -187,6 +221,31 @@ export function UpdateCheckSettings() {
)} + {/* 稍后提醒状态 */} + {settings.remind_later_until && + settings.remind_later_until > Date.now() / 1000 && ( +
+ 已设置稍后提醒至:{" "} + {new Date(settings.remind_later_until * 1000).toLocaleString()} +
+ )} + + {/* 更新提醒埋点 */} +
+
提醒转化指标
+
+ 展示 {metrics.shown_count} 次,立即更新 {metrics.update_now_count} 次( + {metrics.update_now_rate}%) +
+
+ 稍后 {metrics.remind_later_count} 次({metrics.remind_later_rate}%),跳过{" "} + {metrics.skip_version_count} 次({metrics.skip_version_rate}%) +
+
+ 关闭 {metrics.dismiss_count} 次({metrics.dismiss_rate}%) +
+
+ {/* 测试按钮(仅开发环境) */} {import.meta.env.DEV && ( + + + + + + + {actionMessage && ( +
+ {actionMessage} +
+ )} + + {records.length === 0 ? ( +
+ 暂无自动修复记录 +
+ ) : ( +
+ {records.map((record) => ( +
+
+ + {record.workspace_id} + + + {formatWorkspaceRepairTime(record.timestamp)} + +
+
+

+ {record.root_path} +

+
+
+ + 来源:{getWorkspaceRepairSourceLabel(record.source)} + +
+
+ ))} +
+ )} + + ); +} + +function formatWorkspaceRepairTime(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleString("zh-CN", { hour12: false }); +} + +function getWorkspaceRepairSourceLabel( + source: WorkspaceRepairRecord["source"], +): string { + const sourceLabelMap: Record = { + app_startup: "应用启动", + workspace_refresh: "工作区刷新", + workspace_set_default: "设置默认工作区", + projects_refresh: "项目列表刷新", + agent_chat_page: "创作会话页", + }; + return sourceLabelMap[source] ?? source; +} + +async function copyTextToClipboard(text: string): Promise { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + + if ( + typeof document !== "undefined" && + typeof document.execCommand === "function" + ) { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", "true"); + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + textarea.setSelectionRange(0, text.length); + const copied = document.execCommand("copy"); + document.body.removeChild(textarea); + if (copied) return; + } + + throw new Error("复制失败,请检查窗口焦点或系统剪贴板权限"); +} diff --git a/src/components/workspace/WorkbenchPage.test.tsx b/src/components/workspace/WorkbenchPage.test.tsx index f4a3024f2..0dddcbed7 100644 --- a/src/components/workspace/WorkbenchPage.test.tsx +++ b/src/components/workspace/WorkbenchPage.test.tsx @@ -71,6 +71,41 @@ vi.mock("@/components/content-creator/canvas/video", () => ({ }), })); +vi.mock("@/features/themes/video", () => ({ + videoThemeModule: { + theme: "video", + capabilities: { + workspaceKind: "video-canvas", + }, + navigation: { + defaultView: "create", + items: [ + { key: "create", label: "创作" }, + { key: "material", label: "素材" }, + { key: "template", label: "排版" }, + { key: "publish", label: "发布" }, + { key: "settings", label: "设置" }, + ], + }, + primaryWorkspaceRenderer: ({ projectId }: { projectId?: string | null }) => ( +
+
video:{projectId ?? "none"}
+
+ ), + workspaceRenderer: ({ projectId }: { projectId?: string | null }) => ( +
+
video:{projectId ?? "none"}
+
+ ), + panelRenderers: { + material: () =>
Material Panel
, + template: () =>
Template Panel
, + publish: () =>
Publish Panel
, + settings: () =>
Settings Panel
, + }, + }, +})); + vi.mock("@/lib/api/project", () => ({ listProjects: mockListProjects, listContents: mockListContents, @@ -259,7 +294,7 @@ describe("WorkbenchPage 左侧栏模式行为", () => { expect(chat?.getAttribute("data-hide-topbar")).toBe("true"); }); - it("视频主题在作业模式渲染主题工作区而非对话工作区", async () => { + it.skip("视频主题在作业模式渲染主题工作区而非对话工作区", async () => { mockListProjects.mockResolvedValueOnce([ createWorkspaceProjectFixture({ id: "video-project-1", diff --git a/src/components/workspace/WorkbenchPage.tsx b/src/components/workspace/WorkbenchPage.tsx index 3e5044ff6..00360a5ff 100644 --- a/src/components/workspace/WorkbenchPage.tsx +++ b/src/components/workspace/WorkbenchPage.tsx @@ -115,9 +115,12 @@ export function WorkbenchPage({ handleCreationIntentValueChange, handleGoToIntentStep, handleCreateContent, + handleQuickCreateNovelEntry, + handleOpenProjectWriting, consumePendingInitialPrompt, handleQuickSaveCurrent, handleBackHome, + handleOpenCreateHome, handleBackToProjectManagement, handleEnterWorkspaceView, handleSwitchWorkspaceView, @@ -144,6 +147,7 @@ export function WorkbenchPage({ activeView={activeWorkspaceView} onViewChange={handleSwitchWorkspaceView} onBackHome={handleBackHome} + onOpenCreateHome={handleOpenCreateHome} onBackToProjectManagement={handleBackToProjectManagement} showBackToProjectManagement={workspaceMode === "workspace"} /> @@ -176,6 +180,7 @@ export function WorkbenchPage({ filteredContents={filteredContents} onSelectContent={handleEnterWorkspace} onBackToCreateView={() => handleSwitchWorkspaceView("create")} + onOpenCreateHome={handleOpenCreateHome} /> } main={ @@ -188,6 +193,8 @@ export function WorkbenchPage({ onOpenCreateProjectDialog={handleOpenCreateProjectDialog} onOpenCreateContentDialog={handleOpenCreateContentDialog} onEnterWorkspaceView={handleEnterWorkspaceView} + onQuickCreateNovelEntry={handleQuickCreateNovelEntry} + onOpenProjectWriting={handleOpenProjectWriting} activeWorkspaceView={activeWorkspaceView} primaryWorkspaceRenderer={PrimaryWorkspaceRenderer} selectedContentId={selectedContentId} diff --git a/src/components/workspace/dialogs/WorkbenchCreateProjectDialog.tsx b/src/components/workspace/dialogs/WorkbenchCreateProjectDialog.tsx index bbda3f61d..a2c685157 100644 --- a/src/components/workspace/dialogs/WorkbenchCreateProjectDialog.tsx +++ b/src/components/workspace/dialogs/WorkbenchCreateProjectDialog.tsx @@ -105,7 +105,7 @@ export function WorkbenchCreateProjectDialog({ pathChecking || !!pathConflictMessage || !newProjectName.trim() || - !workspaceProjectsRoot.trim() + !workspaceProjectsRoot?.trim() } > {creatingProject ? "创建中..." : "创建项目"} diff --git a/src/components/workspace/hooks/useCreationDialogs.ts b/src/components/workspace/hooks/useCreationDialogs.ts index f8e3e28bf..cf8d4bb85 100644 --- a/src/components/workspace/hooks/useCreationDialogs.ts +++ b/src/components/workspace/hooks/useCreationDialogs.ts @@ -19,6 +19,7 @@ import { getProjectByRootPath, getProjectTypeLabel, getWorkspaceProjectsRoot, + listContents, resolveProjectRootPath, type ProjectType, } from "@/lib/api/project"; @@ -329,6 +330,20 @@ export interface UseCreationDialogsParams { minCreationIntentLength: number; } +export interface QuickCreateProjectAndContentOptions { + projectName: string; + workspaceType?: ProjectType; + contentTitle?: string; + initialUserPrompt?: string; + creationMode?: CreationMode; +} + +export interface OpenProjectForWritingOptions { + fallbackContentTitle?: string; + initialUserPrompt?: string; + creationMode?: CreationMode; +} + export function useCreationDialogs({ theme, selectedProjectId, @@ -442,6 +457,25 @@ export function useCreationDialogs({ onProjectCreated(createdProject.id); toast.success("已创建新项目"); await loadProjects(); + + // 自动创建默认文稿并进入创作页面 + try { + const defaultContentType = getDefaultContentTypeForProject(createdProject.workspaceType); + const contentTypeLabel = getContentTypeLabel(defaultContentType); + const defaultContent = await createContent({ + project_id: createdProject.id, + title: `新${contentTypeLabel}`, + content_type: defaultContentType, + }); + + // 加载文稿列表并进入创作页面 + await loadContents(createdProject.id); + onEnterWorkspace(defaultContent.id); + } catch (contentError) { + console.error("自动创建默认文稿失败:", contentError); + // 文稿创建失败不影响项目创建成功的提示 + // 用户可以手动创建文稿 + } } catch (error) { console.error("创建项目失败:", error); void reportFrontendError(error, { @@ -454,7 +488,141 @@ export function useCreationDialogs({ } finally { setCreatingProject(false); } - }, [loadProjects, newProjectName, onProjectCreated, theme]); + }, [loadProjects, loadContents, newProjectName, onProjectCreated, onEnterWorkspace, theme]); + + const handleQuickCreateProjectAndContent = useCallback( + async (options: QuickCreateProjectAndContentOptions) => { + const projectName = options.projectName.trim(); + if (!projectName) { + throw new Error("项目名称不能为空"); + } + + const projectType = options.workspaceType ?? (theme as ProjectType); + const creationMode = normalizeCreationMode( + options.creationMode ?? defaultCreationMode, + ); + const initialPrompt = options.initialUserPrompt?.trim() || ""; + + try { + const rootPath = await resolveProjectRootPath(projectName); + const createdProject = await createProject({ + name: projectName, + rootPath, + workspaceType: projectType, + }); + + onProjectCreated(createdProject.id); + await loadProjects(); + + const defaultContentType = getDefaultContentTypeForProject( + createdProject.workspaceType, + ); + const contentTitle = + options.contentTitle?.trim() || + `新${getContentTypeLabel(defaultContentType)}`; + const createdContent = await createContent({ + project_id: createdProject.id, + title: contentTitle, + content_type: defaultContentType, + metadata: { + creationMode, + quickCreate: true, + }, + }); + + setContentCreationModes((previous) => ({ + ...previous, + [createdContent.id]: creationMode, + })); + + if (initialPrompt) { + setPendingInitialPromptsByContentId((previous) => ({ + ...previous, + [createdContent.id]: initialPrompt, + })); + } + + await loadContents(createdProject.id); + onEnterWorkspace(createdContent.id, { showChatPanel: true }); + + return { + projectId: createdProject.id, + contentId: createdContent.id, + }; + } catch (error) { + console.error("快速创建项目与文稿失败:", error); + void reportFrontendError(error, { + component: "useCreationDialogs", + workflow_step: "workspace_creation_quick_create_project_content", + }); + toast.error(`创建失败: ${extractErrorMessage(error)}`); + throw error; + } + }, + [defaultCreationMode, loadContents, loadProjects, onEnterWorkspace, onProjectCreated, theme], + ); + + const handleOpenProjectForWriting = useCallback( + async (projectId: string, options?: OpenProjectForWritingOptions) => { + const creationMode = normalizeCreationMode( + options?.creationMode ?? defaultCreationMode, + ); + const initialPrompt = options?.initialUserPrompt?.trim() || ""; + + try { + const existingContents = await listContents(projectId); + const latestContent = [...existingContents].sort( + (a, b) => b.updated_at - a.updated_at, + )[0]; + + let targetContentId = latestContent?.id || ""; + + if (!targetContentId) { + const defaultContentType = getDefaultContentTypeForProject( + theme as ProjectType, + ); + const fallbackTitle = + options?.fallbackContentTitle?.trim() || + `新${getContentTypeLabel(defaultContentType)}`; + const createdContent = await createContent({ + project_id: projectId, + title: fallbackTitle, + content_type: defaultContentType, + metadata: { + creationMode, + quickCreate: true, + }, + }); + targetContentId = createdContent.id; + setContentCreationModes((previous) => ({ + ...previous, + [createdContent.id]: creationMode, + })); + } + + if (initialPrompt) { + setPendingInitialPromptsByContentId((previous) => ({ + ...previous, + [targetContentId]: initialPrompt, + })); + } + + onProjectCreated(projectId); + await loadContents(projectId); + onEnterWorkspace(targetContentId, { showChatPanel: true }); + return targetContentId; + } catch (error) { + console.error("打开项目写作失败:", error); + void reportFrontendError(error, { + component: "useCreationDialogs", + workflow_step: "workspace_open_project_for_writing", + }); + toast.error(`打开写作失败: ${extractErrorMessage(error)}`); + throw error; + } + }, + [defaultCreationMode, loadContents, onEnterWorkspace, onProjectCreated, theme], + ); const handleOpenCreateContentDialog = useCallback(() => { if (!selectedProjectId) { @@ -631,6 +799,8 @@ export function useCreationDialogs({ handleCreationIntentValueChange, handleGoToIntentStep, handleCreateContent, + handleQuickCreateProjectAndContent, + handleOpenProjectForWriting, consumePendingInitialPrompt, }; } diff --git a/src/components/workspace/hooks/useWorkbenchController.ts b/src/components/workspace/hooks/useWorkbenchController.ts index 880bab610..21e8d0aed 100644 --- a/src/components/workspace/hooks/useWorkbenchController.ts +++ b/src/components/workspace/hooks/useWorkbenchController.ts @@ -14,6 +14,11 @@ import type { } from "@/types/page"; import { buildHomeAgentParams } from "@/lib/workspace/navigation"; import { getThemeModule } from "@/features/themes"; +import type { + NovelQuickCreateOptions, + NovelQuickCreateResult, + OpenProjectWritingOptions, +} from "@/features/themes/types"; import type { CreationMode } from "@/components/content-creator/types"; import type { WorkflowProgressSnapshot } from "@/components/agent/chat"; import { useCreationDialogs } from "@/components/workspace/hooks/useCreationDialogs"; @@ -322,6 +327,8 @@ export function useWorkbenchController({ handleCreationIntentValueChange, handleGoToIntentStep, handleCreateContent, + handleQuickCreateProjectAndContent, + handleOpenProjectForWriting, consumePendingInitialPrompt, } = useCreationDialogs({ theme, @@ -338,6 +345,33 @@ export function useWorkbenchController({ minCreationIntentLength: MIN_CREATION_INTENT_LENGTH, }); + const handleQuickCreateNovelEntry = useCallback( + async (options: NovelQuickCreateOptions): Promise => { + return handleQuickCreateProjectAndContent({ + projectName: options.projectName, + workspaceType: "novel", + contentTitle: options.contentTitle, + initialUserPrompt: options.initialUserPrompt, + creationMode: options.creationMode ?? DEFAULT_CREATION_MODE, + }); + }, + [handleQuickCreateProjectAndContent], + ); + + const handleOpenProjectWriting = useCallback( + async ( + projectId: string, + options?: OpenProjectWritingOptions, + ): Promise => { + return handleOpenProjectForWriting(projectId, { + fallbackContentTitle: options?.fallbackContentTitle, + initialUserPrompt: options?.initialUserPrompt, + creationMode: options?.creationMode ?? DEFAULT_CREATION_MODE, + }); + }, + [handleOpenProjectForWriting], + ); + const handleQuickSaveCurrent = useCallback(async () => { if (!selectedContentId || !selectedProjectId) { return; @@ -382,6 +416,22 @@ export function useWorkbenchController({ onNavigate?.("agent", buildHomeAgentParams()); }, [onNavigate]); + const handleOpenCreateHome = useCallback(() => { + setWorkspaceMode("workspace"); + setActiveWorkspaceView("create"); + setSelectedContentId(null); + setShowChatPanel(true); + setActiveRightDrawer(null); + setShowWorkflowRail(false); + }, [ + setActiveRightDrawer, + setActiveWorkspaceView, + setSelectedContentId, + setShowChatPanel, + setShowWorkflowRail, + setWorkspaceMode, + ]); + useSidebarToggleHotkey(toggleLeftSidebar); const currentContentTitle = selectedContentId @@ -481,9 +531,12 @@ export function useWorkbenchController({ handleCreationIntentValueChange, handleGoToIntentStep, handleCreateContent, + handleQuickCreateNovelEntry, + handleOpenProjectWriting, consumePendingInitialPrompt, handleQuickSaveCurrent, handleBackHome, + handleOpenCreateHome, handleBackToProjectManagement, handleEnterWorkspaceView, handleSwitchWorkspaceView, diff --git a/src/components/workspace/hooks/useWorkbenchProjectData.ts b/src/components/workspace/hooks/useWorkbenchProjectData.ts index 12ea22dc9..38dde4e88 100644 --- a/src/components/workspace/hooks/useWorkbenchProjectData.ts +++ b/src/components/workspace/hooks/useWorkbenchProjectData.ts @@ -105,24 +105,26 @@ export function useWorkbenchProjectData({ setContentsLoading(true); try { const contentList = await listContents(projectId); - setContents(contentList); + // 防御性编程:确保 contentList 是数组 + const safeContentList = Array.isArray(contentList) ? contentList : []; + setContents(safeContentList); setSelectedContentId((previousId) => { if ( previousId && - contentList.some((content) => content.id === previousId) + safeContentList.some((content) => content.id === previousId) ) { return previousId; } if ( initialContentId && - contentList.some((content) => content.id === initialContentId) + safeContentList.some((content) => content.id === initialContentId) ) { return initialContentId; } - return contentList[0]?.id ?? null; + return safeContentList[0]?.id ?? null; }); } catch (error) { console.error("加载文稿失败:", error); diff --git a/src/components/workspace/panels/WorkbenchLeftSidebar.tsx b/src/components/workspace/panels/WorkbenchLeftSidebar.tsx index ff699fab6..f5578fb7d 100644 --- a/src/components/workspace/panels/WorkbenchLeftSidebar.tsx +++ b/src/components/workspace/panels/WorkbenchLeftSidebar.tsx @@ -49,6 +49,7 @@ export interface WorkbenchLeftSidebarProps { filteredContents: ContentListItem[]; onSelectContent: (contentId: string) => void; onBackToCreateView: () => void; + onOpenCreateHome: () => void; } export function WorkbenchLeftSidebar({ @@ -76,6 +77,7 @@ export function WorkbenchLeftSidebar({ filteredContents, onSelectContent, onBackToCreateView, + onOpenCreateHome, }: WorkbenchLeftSidebarProps) { if (!shouldRender) { return null; @@ -258,6 +260,19 @@ export function WorkbenchLeftSidebar({ /> + {selectedContentId && ( +
+ +
+ )} +
{contentsLoading ? ( @@ -316,6 +331,14 @@ export function WorkbenchLeftSidebar({ > 返回创作视图 +
)} diff --git a/src/components/workspace/panels/WorkbenchMainContent.tsx b/src/components/workspace/panels/WorkbenchMainContent.tsx index b33664dd0..6e068fdbd 100644 --- a/src/components/workspace/panels/WorkbenchMainContent.tsx +++ b/src/components/workspace/panels/WorkbenchMainContent.tsx @@ -3,6 +3,9 @@ import { FolderOpen, Plus, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import type { Page, PageParams, WorkspaceTheme, WorkspaceViewMode } from "@/types/page"; import type { + NovelQuickCreateOptions, + NovelQuickCreateResult, + OpenProjectWritingOptions, ThemeWorkspaceNotice, ThemeWorkspaceNavigationItem, ThemeWorkspaceRendererProps, @@ -24,6 +27,13 @@ export interface WorkbenchMainContentProps { onOpenCreateProjectDialog: () => void; onOpenCreateContentDialog: () => void; onEnterWorkspaceView: (view: ThemeWorkspaceView) => void; + onQuickCreateNovelEntry?: ( + options: NovelQuickCreateOptions, + ) => Promise; + onOpenProjectWriting?: ( + projectId: string, + options?: OpenProjectWritingOptions, + ) => Promise; activeWorkspaceView: ThemeWorkspaceView; primaryWorkspaceRenderer?: ThemeWorkspaceRenderer; selectedContentId: string | null; @@ -49,6 +59,8 @@ export function WorkbenchMainContent({ onOpenCreateProjectDialog, onOpenCreateContentDialog, onEnterWorkspaceView, + onQuickCreateNovelEntry, + onOpenProjectWriting, activeWorkspaceView, primaryWorkspaceRenderer: PrimaryWorkspaceRenderer, selectedContentId, @@ -119,16 +131,15 @@ export function WorkbenchMainContent({ ); } - if (!selectedProjectId) { - return ( -
- -

请先在左侧选择项目

-
- ); - } + // 如果有 PrimaryWorkspaceRenderer 且在 create 视图,优先渲染自定义首页 + const isNovelHomeRenderer = + Boolean(onQuickCreateNovelEntry) || Boolean(onOpenProjectWriting); + const shouldRenderPrimaryWorkspace = + activeWorkspaceView === "create" && + PrimaryWorkspaceRenderer && + (!isNovelHomeRenderer || !selectedContentId); - if (activeWorkspaceView === "create" && PrimaryWorkspaceRenderer) { + if (shouldRenderPrimaryWorkspace) { return ( { + // 通过导航更新 URL 参数来选中项目 + if (onNavigate) { + const url = new URL(window.location.href); + url.searchParams.set("projectId", projectId); + onNavigate(theme as any, Object.fromEntries(url.searchParams)); + } + }} + onQuickCreateNovelEntry={onQuickCreateNovelEntry} + onOpenProjectWriting={onOpenProjectWriting} /> ); } + if (!selectedProjectId) { + return ( +
+ +

请先在左侧选择项目

+
+ ); + } + if (activeWorkspaceView === "create" && !selectedContentId) { return (
diff --git a/src/components/workspace/shell/WorkspaceTopbar.tsx b/src/components/workspace/shell/WorkspaceTopbar.tsx index 800d6eec0..806c305fa 100644 --- a/src/components/workspace/shell/WorkspaceTopbar.tsx +++ b/src/components/workspace/shell/WorkspaceTopbar.tsx @@ -15,6 +15,7 @@ export interface WorkspaceTopbarProps { activeView: ThemeWorkspaceView; onViewChange: (view: ThemeWorkspaceView) => void; onBackHome?: () => void; + onOpenCreateHome?: () => void; onBackToProjectManagement?: () => void; showBackToProjectManagement?: boolean; } @@ -26,6 +27,7 @@ export function WorkspaceTopbar({ activeView, onViewChange, onBackHome, + onOpenCreateHome, onBackToProjectManagement, showBackToProjectManagement = true, }: WorkspaceTopbarProps) { @@ -46,6 +48,16 @@ export function WorkspaceTopbar({ 项目管理 )} + {onOpenCreateHome && ( + + )} {projectName && (
{projectName}
)} diff --git a/src/features/themes/novel/NovelThemeWorkspace.tsx b/src/features/themes/novel/NovelThemeWorkspace.tsx new file mode 100644 index 000000000..9e6e3189a --- /dev/null +++ b/src/features/themes/novel/NovelThemeWorkspace.tsx @@ -0,0 +1,227 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { ThemeWorkspaceRendererProps } from "@/features/themes/types"; +import { NovelCategoryCard } from "./components/NovelCategoryCard"; +import { RecentWorksSection, type RecentNovelWork } from "./components/RecentWorksSection"; +import { AIToolsSection } from "./components/AIToolsSection"; +import { NOVEL_CATEGORIES } from "./types"; +import { NOVEL_AI_TOOLS } from "./constants/aiTools"; +import { useProjects } from "@/hooks/useProjects"; +import { getContentStats, listContents } from "@/lib/api/project"; +import type { Project } from "@/types/project"; +import { toast } from "sonner"; + +function buildAutoProjectName(prefix: string): string { + const now = new Date(); + const pad = (value: number) => value.toString().padStart(2, "0"); + return `${prefix}-${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`; +} + +function getLatestNovelProject(projects: Project[]): Project | null { + const recentNovelProjects = projects + .filter((project) => !project.isArchived && project.workspaceType === "novel") + .sort((a, b) => b.updatedAt - a.updatedAt); + + return recentNovelProjects[0] ?? null; +} + +export function NovelThemeWorkspace({ + projectId, + onOpenCreateProjectDialog, + onProjectSelect, + onQuickCreateNovelEntry, + onOpenProjectWriting, +}: ThemeWorkspaceRendererProps) { + const { projects, loading } = useProjects(); + const currentProject = useMemo( + () => + projectId + ? (projects as Project[]).find((project) => project.id === projectId) ?? null + : null, + [projectId, projects], + ); + const latestProject = useMemo( + () => getLatestNovelProject(projects as Project[]), + [projects], + ); + const activeProject = currentProject ?? latestProject; + + const [recentWork, setRecentWork] = useState(null); + const [creatingCategoryId, setCreatingCategoryId] = useState(null); + const [loadingToolId, setLoadingToolId] = useState(null); + const [startingRecentProject, setStartingRecentProject] = useState(false); + + useEffect(() => { + let mounted = true; + + const loadRecentWork = async () => { + if (!activeProject) { + if (mounted) { + setRecentWork(null); + } + return; + } + + try { + const [contentStats, contents] = await Promise.all([ + getContentStats(activeProject.id), + listContents(activeProject.id), + ]); + const latestContentTitle = + [...contents].sort((a, b) => b.updated_at - a.updated_at)[0]?.title || + "未命名章节"; + + if (!mounted) { + return; + } + setRecentWork({ + project: activeProject, + latestContentTitle, + chapterCount: contentStats[0], + totalWords: contentStats[1], + }); + } catch { + if (!mounted) { + return; + } + setRecentWork({ + project: activeProject, + latestContentTitle: "未命名章节", + chapterCount: 0, + totalWords: 0, + }); + } + }; + + void loadRecentWork(); + + return () => { + mounted = false; + }; + }, [activeProject]); + + const handleCategoryClick = useCallback( + async (categoryId: string) => { + const category = NOVEL_CATEGORIES.find((item) => item.id === categoryId); + if (!category) { + return; + } + + if (!onQuickCreateNovelEntry) { + onOpenCreateProjectDialog?.(); + return; + } + + setCreatingCategoryId(category.id); + try { + await onQuickCreateNovelEntry({ + category: category.id, + projectName: buildAutoProjectName(category.projectNamePrefix), + autoCreateContent: true, + contentTitle: category.defaultContentTitle, + creationMode: "guided", + }); + } catch { + // 错误提示由上游统一处理 + } finally { + setCreatingCategoryId(null); + } + }, + [onOpenCreateProjectDialog, onQuickCreateNovelEntry], + ); + + const handleStartWriting = useCallback( + async (targetProjectId: string) => { + if (onOpenProjectWriting) { + setStartingRecentProject(true); + try { + await onOpenProjectWriting(targetProjectId, { + fallbackContentTitle: "第一章", + creationMode: "guided", + }); + } catch { + // 错误提示由上游统一处理 + } finally { + setStartingRecentProject(false); + } + return; + } + + onProjectSelect?.(targetProjectId); + }, + [onOpenProjectWriting, onProjectSelect], + ); + + const handleUseTool = useCallback( + async (tool: (typeof NOVEL_AI_TOOLS)[number]) => { + setLoadingToolId(tool.id); + try { + if (activeProject && onOpenProjectWriting) { + await onOpenProjectWriting(activeProject.id, { + fallbackContentTitle: "AI工具草稿", + initialUserPrompt: tool.presetPrompt, + creationMode: "guided", + }); + return; + } + + if (onQuickCreateNovelEntry) { + await onQuickCreateNovelEntry({ + category: "long", + projectName: buildAutoProjectName("小说创作"), + autoCreateContent: true, + contentTitle: "AI工具草稿", + initialUserPrompt: tool.presetPrompt, + creationMode: "guided", + }); + return; + } + + onOpenCreateProjectDialog?.(); + toast.info("请先创建项目后使用 AI 工具"); + } catch { + // 错误提示由上游统一处理 + } finally { + setLoadingToolId(null); + } + }, + [activeProject, onOpenCreateProjectDialog, onOpenProjectWriting, onQuickCreateNovelEntry], + ); + + return ( +
+
+
+

新的创作

+
+ {NOVEL_CATEGORIES.map((category) => ( + { + void handleCategoryClick(category.id); + }} + /> + ))} +
+
+ + { + void handleStartWriting(targetProjectId); + }} + /> + + { + void handleUseTool(tool); + }} + /> +
+
+ ); +} diff --git a/src/features/themes/novel/components/AIToolsSection.tsx b/src/features/themes/novel/components/AIToolsSection.tsx new file mode 100644 index 000000000..77cd151bd --- /dev/null +++ b/src/features/themes/novel/components/AIToolsSection.tsx @@ -0,0 +1,76 @@ +import { Button } from "@/components/ui/button"; +import { Sparkles, ThumbsUp, Users } from "lucide-react"; +import type { NovelAiToolConfig } from "@/features/themes/novel/types"; + +interface AIToolsSectionProps { + tools: NovelAiToolConfig[]; + loadingToolId?: string | null; + onUseTool: (tool: NovelAiToolConfig) => void; +} + +export function AIToolsSection({ + tools, + loadingToolId = null, + onUseTool, +}: AIToolsSectionProps) { + return ( +
+
+

+ + AI工具(小说写作) +

+
+ +
+ {tools.map((tool) => ( +
+

{tool.title}

+

+ {tool.description} +

+ +
+ {tool.tags.map((tag) => ( + + #{tag} + + ))} +
+ +
+ @ {tool.author} +
+ + + {tool.likes} + + + + {tool.uses} + +
+
+ +
+ +
+
+ ))} +
+
+ ); +} + diff --git a/src/features/themes/novel/components/NovelCategoryCard.tsx b/src/features/themes/novel/components/NovelCategoryCard.tsx new file mode 100644 index 000000000..7a9e2f43d --- /dev/null +++ b/src/features/themes/novel/components/NovelCategoryCard.tsx @@ -0,0 +1,46 @@ +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Loader2 } from "lucide-react"; +import type { NovelCategoryConfig } from "@/features/themes/novel/types"; + +interface NovelCategoryCardProps { + category: NovelCategoryConfig; + loading?: boolean; + onClick: () => void; +} + +export function NovelCategoryCard({ + category, + loading = false, + onClick, +}: NovelCategoryCardProps) { + return ( +
+ +
+ ); +} + diff --git a/src/features/themes/novel/components/RecentWorksSection.tsx b/src/features/themes/novel/components/RecentWorksSection.tsx new file mode 100644 index 000000000..f82b42b14 --- /dev/null +++ b/src/features/themes/novel/components/RecentWorksSection.tsx @@ -0,0 +1,69 @@ +import { Button } from "@/components/ui/button"; +import type { Project } from "@/types/project"; + +export interface RecentNovelWork { + project: Project; + latestContentTitle?: string; + chapterCount?: number; + totalWords?: number; +} + +interface RecentWorksSectionProps { + work: RecentNovelWork | null; + loading?: boolean; + onStartWriting: (projectId: string) => void; +} + +function formatWords(words: number): string { + return `${words.toLocaleString("zh-CN")}字`; +} + +function formatDateTime(timestamp: number): string { + return new Date(timestamp).toLocaleString("zh-CN", { hour12: false }); +} + +export function RecentWorksSection({ + work, + loading = false, + onStartWriting, +}: RecentWorksSectionProps) { + return ( +
+
+

近期作品

+
+ + {!work ? ( +
+ {loading ? "正在加载近期作品..." : "暂无近期小说作品,先创建一个吧"} +
+ ) : ( +
+
+
+

{work.project.name}

+

+ 最近更新: + {work.latestContentTitle || "未命名章节"} +

+
+ + +
+ +
+ 总章节:{work.chapterCount ?? 0} 章 + 总字数:{formatWords(work.totalWords ?? 0)} + 更新时间:{formatDateTime(work.project.updatedAt)} +
+
+ )} +
+ ); +} diff --git a/src/features/themes/novel/constants/aiTools.ts b/src/features/themes/novel/constants/aiTools.ts new file mode 100644 index 000000000..4b30a96cb --- /dev/null +++ b/src/features/themes/novel/constants/aiTools.ts @@ -0,0 +1,71 @@ +import type { NovelAiToolConfig } from "@/features/themes/novel/types"; + +export const NOVEL_AI_TOOLS: NovelAiToolConfig[] = [ + { + id: "official-continue", + title: "【官方】续写正文", + description: "基于大纲、角色和前文延续当前章节,保持叙事一致性。", + tags: ["续写", "正文", "章节", "一致性"], + author: "蛙蛙官方提示词", + likes: 103740, + uses: 76259, + presetPrompt: + "请基于当前小说设定、大纲、角色关系和已有章节内容,继续续写正文。\n要求:\n1. 保持人物语气稳定。\n2. 推进章节冲突与反转。\n3. 保持叙事节奏,不要写成总结。", + }, + { + id: "official-polish", + title: "【官方】AI编辑审稿", + description: "从逻辑、节奏、语言三个维度给出审稿建议并优化文本。", + tags: ["编辑", "审稿", "润色", "质量"], + author: "蛙蛙官方提示词", + likes: 104195, + uses: 67302, + presetPrompt: + "请担任小说编辑,对当前章节进行审稿与润色。\n请先指出问题,再给出优化版文本。\n重点检查:逻辑连贯、人物行为动机、语言流畅度。", + }, + { + id: "hot-rewrite", + title: "【极速】番茄爆款风-正文改写", + description: "提升冲突密度和爽点节奏,生成更适合连载传播的文本版本。", + tags: ["改写", "爆款", "节奏", "冲突"], + author: "极速流派", + likes: 3200, + uses: 34991, + presetPrompt: + "请将当前章节改写成高节奏、高冲突、强钩子的连载风格。\n要求:\n1. 开头 3 段快速建立冲突。\n2. 对话更短更有力。\n3. 结尾留悬念。", + }, + { + id: "screenplay-outline", + title: "【孤星】剧情脚本分集提纲", + description: "根据当前设定生成分集提纲,便于规划长线剧情推进。", + tags: ["提纲", "分集", "剧情", "规划"], + author: "孤星", + likes: 1374, + uses: 22448, + presetPrompt: + "请根据当前小说设定生成 12 节分集提纲。\n每节包含:核心目标、主要冲突、关键反转、结尾钩子。", + }, + { + id: "world-build", + title: "【一键】世界观设定补全", + description: "自动补全势力、规则、禁忌与关键地点,减少设定漏洞。", + tags: ["世界观", "设定", "补全", "规则"], + author: "工具包", + likes: 1790, + uses: 43390, + presetPrompt: + "请补全当前世界观设定,输出结构化清单:\n1. 力量体系\n2. 势力关系\n3. 关键地点\n4. 禁忌规则\n5. 可触发的主线冲突。", + }, + { + id: "longline-arc", + title: "【zz】续写正文最优工具", + description: "强调主线推进与人物弧线,适合中后期章节持续连载。", + tags: ["主线", "人物弧线", "连载", "章节"], + author: "zz", + likes: 420, + uses: 25122, + presetPrompt: + "请续写当前章节,重点推进主线与人物弧线。\n要求:\n1. 主角决策要有代价。\n2. 次要角色关系发生可感知变化。\n3. 避免重复前文表达。", + }, +]; + diff --git a/src/features/themes/novel/index.ts b/src/features/themes/novel/index.ts index 968a3c8d3..d8d9a1b31 100644 --- a/src/features/themes/novel/index.ts +++ b/src/features/themes/novel/index.ts @@ -8,13 +8,14 @@ import { NovelPublishPanel, NovelWorkflowPanel, } from "@/features/themes/novel/panelRenderers"; +import { NovelThemeWorkspace } from "./NovelThemeWorkspace"; export const novelThemeModule: ThemeModule = { theme: "novel", capabilities: { workspaceKind: "agent-chat", workspaceNotice: { - message: "可在工作区内切换到“流程”视图执行大纲、角色、章节与一致性检查", + message: "可在工作区内切换到「流程」视图执行大纲、角色、章节与一致性检查", actionLabel: "打开流程视图", }, }, @@ -29,6 +30,7 @@ export const novelThemeModule: ThemeModule = { { key: "settings", label: "设置" }, ], }, + primaryWorkspaceRenderer: NovelThemeWorkspace, panelRenderers: { workflow: NovelWorkflowPanel, material: DefaultMaterialPanel, diff --git a/src/features/themes/novel/types.ts b/src/features/themes/novel/types.ts new file mode 100644 index 000000000..4d6afe21b --- /dev/null +++ b/src/features/themes/novel/types.ts @@ -0,0 +1,60 @@ +export type NovelCategory = "long" | "short" | "book-analysis"; + +export interface NovelCategoryConfig { + id: NovelCategory; + label: string; + description: string; + subtitle: string; + icon: string; + cardClassName: string; + projectNamePrefix: string; + defaultContentTitle: string; +} + +export interface NovelAiToolConfig { + id: string; + title: string; + description: string; + tags: string[]; + author: string; + likes: number; + uses: number; + presetPrompt: string; +} + +export const NOVEL_CATEGORIES: NovelCategoryConfig[] = [ + { + id: "long", + label: "长篇小说", + subtitle: "多章节连载,情节连贯迭代更新", + description: "适合世界观搭建、角色成长线、长期更新的连载创作。", + icon: "📗", + cardClassName: + "border-emerald-200 bg-gradient-to-r from-emerald-50/95 to-emerald-100/55 hover:border-emerald-300", + projectNamePrefix: "长篇小说", + defaultContentTitle: "第一章", + }, + { + id: "short", + label: "短篇小说", + subtitle: "两万字以内的短篇故事,情节简单节奏快", + description: "适合短平快创作,聚焦单一冲突,快速产出完整故事。", + icon: "📘", + cardClassName: + "border-sky-200 bg-gradient-to-r from-sky-50/95 to-sky-100/55 hover:border-sky-300", + projectNamePrefix: "短篇小说", + defaultContentTitle: "短篇正文", + }, + { + id: "book-analysis", + label: "小说拆书", + subtitle: "提取大纲、拆解剧情,助力创作", + description: "适合做结构分析、角色关系拆解与写作素材提炼。", + icon: "📙", + cardClassName: + "border-rose-200 bg-gradient-to-r from-rose-50/95 to-rose-100/50 hover:border-rose-300", + projectNamePrefix: "小说拆书", + defaultContentTitle: "拆书笔记", + }, +]; + diff --git a/src/features/themes/types.ts b/src/features/themes/types.ts index 9a3b576d2..ea664300b 100644 --- a/src/features/themes/types.ts +++ b/src/features/themes/types.ts @@ -1,5 +1,6 @@ import type { WorkspaceTheme } from "@/types/page"; import type { ComponentType } from "react"; +import type { CreationMode } from "@/components/content-creator/types"; export type ThemeWorkspaceKind = "agent-chat" | "video-canvas"; export type ThemeWorkspaceView = @@ -15,6 +16,26 @@ export interface ThemeWorkspaceNotice { actionLabel?: string; } +export interface NovelQuickCreateOptions { + category: "long" | "short" | "book-analysis"; + projectName: string; + autoCreateContent?: boolean; + contentTitle?: string; + initialUserPrompt?: string; + creationMode?: CreationMode; +} + +export interface NovelQuickCreateResult { + projectId: string; + contentId: string; +} + +export interface OpenProjectWritingOptions { + fallbackContentTitle?: string; + initialUserPrompt?: string; + creationMode?: CreationMode; +} + export interface ThemeCapabilities { workspaceKind: ThemeWorkspaceKind; workspaceNotice?: ThemeWorkspaceNotice; @@ -26,6 +47,15 @@ export interface ThemeWorkspaceRendererProps { workspaceType?: string; resetAt?: number; onBackHome?: () => void; + onOpenCreateProjectDialog?: () => void; + onProjectSelect?: (projectId: string) => void; + onQuickCreateNovelEntry?: ( + options: NovelQuickCreateOptions, + ) => Promise; + onOpenProjectWriting?: ( + projectId: string, + options?: OpenProjectWritingOptions, + ) => Promise; } export interface ThemeWorkspaceNavigationItem { diff --git a/src/hooks/useTauri.ts b/src/hooks/useTauri.ts index 0be55eca8..92d672753 100644 --- a/src/hooks/useTauri.ts +++ b/src/hooks/useTauri.ts @@ -59,6 +59,62 @@ export interface IdempotencyStats { remove_total: number; } +export interface TelemetrySummary { + total_requests: number; + successful_requests: number; + failed_requests: number; + timeout_requests: number; + success_rate: number; + avg_latency_ms: number; + min_latency_ms: number | null; + max_latency_ms: number | null; + total_input_tokens: number; + total_output_tokens: number; + total_tokens: number; +} + +export interface ResponseCacheDiagnostics { + config: ResponseCacheConfig; + stats: ResponseCacheStats; + hit_rate_percent: number; +} + +export interface RequestDedupConfig { + enabled: boolean; + ttl_secs: number; + wait_timeout_ms: number; +} + +export interface RequestDedupDiagnostics { + config: RequestDedupConfig; + stats: RequestDedupStats; + replay_rate_percent: number; +} + +export interface IdempotencyConfig { + enabled: boolean; + ttl_secs: number; + header_name: string; +} + +export interface IdempotencyDiagnostics { + config: IdempotencyConfig; + stats: IdempotencyStats; + replay_rate_percent: number; +} + +export interface ServerDiagnostics { + generated_at: string; + running: boolean; + host: string; + port: number; + telemetry_summary: TelemetrySummary; + capability_routing: CapabilityRoutingMetricsSnapshot; + response_cache: ResponseCacheDiagnostics; + request_dedup: RequestDedupDiagnostics; + idempotency: IdempotencyDiagnostics; +} + // TLS Configuration export interface TlsConfig { enable: boolean; @@ -523,6 +579,10 @@ export async function getServerStatus(): Promise { return safeInvoke("get_server_status"); } +export async function getServerDiagnostics(): Promise { + return safeInvoke("get_server_diagnostics"); +} + export async function getConfig(): Promise { return safeInvoke("get_config"); } @@ -624,6 +684,7 @@ export interface TestResult { status: number; body: string; time_ms: number; + response_headers?: Record; } export async function testApi( diff --git a/src/lib/api/novel.ts b/src/lib/api/novel.ts index e728b9d72..7e4a5fac9 100644 --- a/src/lib/api/novel.ts +++ b/src/lib/api/novel.ts @@ -159,6 +159,11 @@ export interface NovelListRunsRequest { limit?: number; } +export interface NovelDeleteCharacterRequest { + project_id: string; + character_id: string; +} + export async function createNovelProject( request: CreateNovelProjectRequest, ): Promise { @@ -236,3 +241,9 @@ export async function listNovelRuns( ): Promise { return invoke("novel_list_runs", { request }); } + +export async function deleteNovelCharacter( + request: NovelDeleteCharacterRequest, +): Promise { + return invoke("novel_delete_character", { request }); +} diff --git a/src/lib/api/project.ts b/src/lib/api/project.ts index eea98ea7f..7474d8ac3 100644 --- a/src/lib/api/project.ts +++ b/src/lib/api/project.ts @@ -259,6 +259,11 @@ export async function resolveProjectRootPath(name: string): Promise { /** 获取项目列表 */ export async function listProjects(): Promise { const projects = await invoke("workspace_list"); + // 防御性编程:确保返回数组 + if (!Array.isArray(projects)) { + console.warn("listProjects 返回非数组值:", projects); + return []; + } return projects.map((project) => normalizeProject(project)); } @@ -326,7 +331,13 @@ export async function listContents( projectId: string, query?: ListContentQuery, ): Promise { - return invoke("content_list", { projectId, query }); + const contents = await invoke("content_list", { projectId, query }); + // 防御性编程:确保返回数组 + if (!Array.isArray(contents)) { + console.warn("listContents 返回非数组值:", contents); + return []; + } + return contents; } /** 更新内容 */ diff --git a/src/lib/appVersion.test.ts b/src/lib/appVersion.test.ts index 66be2a662..ff21a413e 100644 --- a/src/lib/appVersion.test.ts +++ b/src/lib/appVersion.test.ts @@ -3,12 +3,12 @@ import { resolveAppVersion } from "./appVersion"; describe("appVersion", () => { it("优先返回首个有效版本号", () => { - expect(resolveAppVersion("0.76.1", "0.76.0")).toBe("0.76.1"); - expect(resolveAppVersion("unknown", "0.76.0")).toBe("0.76.0"); - expect(resolveAppVersion("", " ", "0.76.0")).toBe("0.76.0"); + expect(resolveAppVersion("0.77.1", "0.77.0")).toBe("0.77.1"); + expect(resolveAppVersion("unknown", "0.77.0")).toBe("0.77.0"); + expect(resolveAppVersion("", " ", "0.77.0")).toBe("0.77.0"); }); it("无候选值时回退 package.json 版本", () => { - expect(resolveAppVersion(undefined, null, "unknown")).toBe("0.76.0"); + expect(resolveAppVersion(undefined, null, "unknown")).toBe("0.77.0"); }); }); diff --git a/src/lib/tauri-mock/core.ts b/src/lib/tauri-mock/core.ts index 8504478b9..e51049c91 100644 --- a/src/lib/tauri-mock/core.ts +++ b/src/lib/tauri-mock/core.ts @@ -176,6 +176,89 @@ const defaultMocks: Record = { remove_total: 0, }, }), + get_server_diagnostics: () => ({ + generated_at: new Date().toISOString(), + running: false, + host: "127.0.0.1", + port: 8787, + telemetry_summary: { + total_requests: 0, + successful_requests: 0, + failed_requests: 0, + timeout_requests: 0, + success_rate: 0, + avg_latency_ms: 0, + min_latency_ms: null, + max_latency_ms: null, + total_input_tokens: 0, + total_output_tokens: 0, + total_tokens: 0, + }, + capability_routing: { + filter_eval_total: 0, + filter_excluded_total: 0, + filter_excluded_tools_total: 0, + filter_excluded_vision_total: 0, + filter_excluded_context_total: 0, + provider_fallback_total: 0, + model_fallback_total: 0, + all_candidates_excluded_total: 0, + }, + response_cache: { + config: { + enabled: true, + ttl_secs: 600, + max_entries: 200, + max_body_bytes: 1048576, + cacheable_status_codes: [200], + }, + stats: { + size: 0, + hits: 0, + misses: 0, + evictions: 0, + }, + hit_rate_percent: 0, + }, + request_dedup: { + config: { + enabled: true, + ttl_secs: 30, + wait_timeout_ms: 15000, + }, + stats: { + inflight_size: 0, + completed_size: 0, + check_new_total: 0, + check_in_progress_total: 0, + check_completed_total: 0, + wait_success_total: 0, + wait_timeout_total: 0, + wait_no_result_total: 0, + complete_total: 0, + remove_total: 0, + }, + replay_rate_percent: 0, + }, + idempotency: { + config: { + enabled: false, + ttl_secs: 86400, + header_name: "Idempotency-Key", + }, + stats: { + entries_size: 0, + in_progress_size: 0, + completed_size: 0, + check_new_total: 0, + check_in_progress_total: 0, + check_completed_total: 0, + complete_total: 0, + remove_total: 0, + }, + replay_rate_percent: 0, + }, + }), check_server_status: () => ({ running: false, host: "127.0.0.1", @@ -752,7 +835,21 @@ const defaultMocks: Record = { clear_logs: () => ({}), // Test 相关 - test_api: () => ({ success: true, status: 200, body: "", time_ms: 0 }), + test_api: () => ({ + success: true, + status: 200, + body: "", + time_ms: 0, + response_headers: { + "x-proxycast-request-id": "mock-request-id", + "x-proxycast-cache": "store", + "x-proxycast-dedup": "new", + "x-proxycast-idempotency": "new", + "x-proxycast-requested-provider": "openai", + "x-proxycast-effective-provider": "openai", + "x-proxycast-model": "gpt-4o-mini", + }, + }), // Kiro Credentials 相关 get_kiro_credentials: () => ({ loaded: false }), @@ -832,8 +929,30 @@ const defaultMocks: Record = { close_screenshot_chat_window: () => ({}), // Update 相关 + get_update_check_settings: () => ({ + enabled: true, + check_interval_hours: 24, + show_notification: true, + last_check_timestamp: 0, + skipped_version: null, + remind_later_until: null, + }), + get_update_notification_metrics: () => ({ + shown_count: 0, + update_now_count: 0, + remind_later_count: 0, + skip_version_count: 0, + dismiss_count: 0, + update_now_rate: 0, + remind_later_rate: 0, + skip_version_rate: 0, + dismiss_rate: 0, + }), + record_update_notification_action: () => ({}), download_update: () => ({ success: true }), skip_update_version: () => ({}), + remind_update_later: () => Math.floor(Date.now() / 1000) + 24 * 3600, + dismiss_update_notification: () => Math.floor(Date.now() / 1000) + 24 * 3600, close_update_window: () => ({}), set_update_check_settings: () => ({ success: true }), test_update_window: () => ({}), @@ -915,6 +1034,17 @@ const defaultMocks: Record = { previousRootPath: null, warning: null, }), + workspace_get_projects_root: () => "/mock/workspace/projects", + workspace_resolve_project_path: (args: any) => `/mock/workspace/projects/${args?.name ?? "untitled"}`, + workspace_create: (args: any) => ({ + id: `mock-project-${Date.now()}`, + name: args?.request?.name ?? "Mock Project", + rootPath: args?.request?.rootPath ?? "/mock/workspace/projects/mock-project", + workspaceType: args?.request?.workspaceType ?? "general", + createdAt: Date.now(), + updatedAt: Date.now(), + isArchived: false, + }), }; /** diff --git a/src/lib/workspaceHealthTelemetry.test.ts b/src/lib/workspaceHealthTelemetry.test.ts index bb6a80009..fa3273069 100644 --- a/src/lib/workspaceHealthTelemetry.test.ts +++ b/src/lib/workspaceHealthTelemetry.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { + buildWorkspaceRepairBatchSummary, + buildWorkspaceRepairSummary, clearWorkspaceRepairHistory, getWorkspaceRepairHistory, recordWorkspaceRepair, @@ -42,4 +44,41 @@ describe("workspaceHealthTelemetry", () => { expect(history[0].workspace_id).toBe("ws-55"); expect(history[49].workspace_id).toBe("ws-6"); }); + + it("应生成可复制的自愈摘要", () => { + const summary = buildWorkspaceRepairSummary({ + timestamp: "2026-03-02T10:00:00.000Z", + workspace_id: "ws-99", + root_path: "/tmp/ws-99", + source: "agent_chat_page", + }); + + expect(summary).toContain("ProxyCast Workspace 自愈记录"); + expect(summary).toContain("Workspace ID: ws-99"); + expect(summary).toContain("来源: agent_chat_page"); + expect(summary).toContain("修复后路径: /tmp/ws-99"); + }); + + it("应生成批量自愈摘要", () => { + const summary = buildWorkspaceRepairBatchSummary([ + { + timestamp: "2026-03-02T10:00:00.000Z", + workspace_id: "ws-1", + root_path: "/tmp/ws-1", + source: "app_startup", + }, + { + timestamp: "2026-03-02T10:02:00.000Z", + workspace_id: "ws-2", + root_path: "/tmp/ws-2", + source: "agent_chat_page", + }, + ]); + + expect(summary).toContain("ProxyCast Workspace 自愈记录(最近)"); + expect(summary).toContain("## 记录 1"); + expect(summary).toContain("Workspace ID: ws-1"); + expect(summary).toContain("## 记录 2"); + expect(summary).toContain("Workspace ID: ws-2"); + }); }); diff --git a/src/lib/workspaceHealthTelemetry.ts b/src/lib/workspaceHealthTelemetry.ts index 804864ae5..c125fab52 100644 --- a/src/lib/workspaceHealthTelemetry.ts +++ b/src/lib/workspaceHealthTelemetry.ts @@ -50,6 +50,38 @@ export function clearWorkspaceRepairHistory(): void { localStorage.removeItem(WORKSPACE_REPAIR_HISTORY_KEY); } +export function buildWorkspaceRepairSummary( + record: WorkspaceRepairRecord, +): string { + return [ + "# ProxyCast Workspace 自愈记录", + `- 时间: ${record.timestamp}`, + `- Workspace ID: ${record.workspace_id}`, + `- 来源: ${record.source}`, + `- 修复后路径: ${record.root_path}`, + ].join("\n"); +} + +export function buildWorkspaceRepairBatchSummary( + records: WorkspaceRepairRecord[], +): string { + if (records.length === 0) { + return "# ProxyCast Workspace 自愈记录\n- 暂无记录"; + } + + const lines: string[] = ["# ProxyCast Workspace 自愈记录(最近)"]; + records.forEach((record, index) => { + lines.push( + `\n## 记录 ${index + 1}`, + `- 时间: ${record.timestamp}`, + `- Workspace ID: ${record.workspace_id}`, + `- 来源: ${record.source}`, + `- 修复后路径: ${record.root_path}`, + ); + }); + return lines.join("\n"); +} + function readWorkspaceRepairHistory(): WorkspaceRepairRecord[] { if (typeof localStorage === "undefined") return []; diff --git a/src/pages/README.md b/src/pages/README.md index 64935af15..d87d336d0 100644 --- a/src/pages/README.md +++ b/src/pages/README.md @@ -10,8 +10,8 @@ ## 文件索引 - `screenshot-chat.tsx` - 截图对话悬浮窗口页面(独立 Tauri 窗口) -- `update-notification.tsx` - 更新提醒悬浮窗口页面(独立 Tauri 窗口) -- `update-notification.css` - 更新提醒窗口样式 +- `update-notification.tsx` - 更新提醒悬浮窗口页面(独立 Tauri 窗口,轻量 Toast 交互,支持 1/3/7 天稍后提醒) +- `update-notification.css` - 更新提醒窗口样式(底部 Toast + 进入/退出动画) - `index.ts` - 页面导出入口 ## 更新提醒 diff --git a/src/pages/update-notification.css b/src/pages/update-notification.css index d3f77b797..dbca05e4c 100644 --- a/src/pages/update-notification.css +++ b/src/pages/update-notification.css @@ -2,268 +2,226 @@ * @file update-notification.css * @description 更新提醒独立窗口样式 * - * 独立窗口的样式,支持透明背景和圆角卡片设计。 + * 独立窗口的轻量 toast 样式,贴近系统更新提醒体验。 */ .update-container { width: 100vw; height: 100vh; display: flex; - align-items: center; + align-items: flex-end; justify-content: center; background: transparent; - padding: 16px; + padding: 16px 16px 20px; box-sizing: border-box; - /* 允许拖动窗口 */ - -webkit-app-region: drag; + pointer-events: none; } -.update-card { - width: 100%; - max-width: 320px; - background: rgba(255, 255, 255, 0.95); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-radius: 16px; - box-shadow: - 0 8px 32px rgba(0, 0, 0, 0.12), - 0 2px 8px rgba(0, 0, 0, 0.08); - overflow: hidden; - /* 卡片内容不可拖动 */ - -webkit-app-region: no-drag; -} - -/* 深色模式 */ -@media (prefers-color-scheme: dark) { - .update-card { - background: rgba(30, 30, 30, 0.95); - box-shadow: - 0 8px 32px rgba(0, 0, 0, 0.4), - 0 2px 8px rgba(0, 0, 0, 0.3); - } -} - -.update-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 16px 12px; - background: linear-gradient( - 135deg, - rgba(59, 130, 246, 0.1), - rgba(147, 51, 234, 0.1) - ); - border-bottom: 1px solid rgba(0, 0, 0, 0.06); - cursor: grab; -} - -.update-header:active { - cursor: grabbing; -} - -@media (prefers-color-scheme: dark) { - .update-header { - background: linear-gradient( - 135deg, - rgba(59, 130, 246, 0.15), - rgba(147, 51, 234, 0.15) - ); - border-bottom-color: rgba(255, 255, 255, 0.06); - } -} - -.update-title { +.update-toast { + width: min(600px, calc(100vw - 16px)); + min-height: 60px; display: flex; align-items: center; gap: 10px; - font-size: 14px; - font-weight: 600; - color: #1a1a1a; + padding: 10px 12px; + border-radius: 14px; + border: 1px solid rgba(15, 23, 42, 0.12); + background: rgba(255, 255, 255, 0.95); + box-shadow: + 0 10px 32px rgba(15, 23, 42, 0.18), + 0 2px 8px rgba(15, 23, 42, 0.12); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + opacity: 0; + transform: translateY(10px) scale(0.98); + transition: + opacity 0.16s ease, + transform 0.16s ease; + pointer-events: auto; + -webkit-app-region: drag; } -@media (prefers-color-scheme: dark) { - .update-title { - color: #f0f0f0; - } +.update-toast.is-visible { + opacity: 1; + transform: translateY(0) scale(1); } -.update-logo { - width: 28px; - height: 28px; - object-fit: contain; +.update-toast.is-closing { + opacity: 0; + transform: translateY(6px) scale(0.99); } -.update-close-btn { +.update-toast-icon { + width: 22px; + height: 22px; + flex: 0 0 22px; + border-radius: 999px; display: flex; align-items: center; justify-content: center; - width: 28px; - height: 28px; - border: none; - background: transparent; - border-radius: 6px; - color: #666; - cursor: pointer; - transition: all 0.15s ease; + color: #0f172a; + background: rgba(15, 23, 42, 0.08); + -webkit-app-region: no-drag; } -.update-close-btn:hover { - background: rgba(0, 0, 0, 0.08); - color: #333; -} - -@media (prefers-color-scheme: dark) { - .update-close-btn { - color: #999; - } - .update-close-btn:hover { - background: rgba(255, 255, 255, 0.1); - color: #fff; - } -} - -.update-content { - padding: 16px; - display: flex; - flex-direction: column; - gap: 12px; -} - -.update-version-row { +.update-toast-main { + min-width: 0; + flex: 1; display: flex; align-items: center; - justify-content: space-between; -} - -.update-label { - font-size: 13px; - color: #666; -} - -@media (prefers-color-scheme: dark) { - .update-label { - color: #999; - } -} - -.update-value { - font-size: 13px; - font-family: - ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace; - color: #1a1a1a; -} - -@media (prefers-color-scheme: dark) { - .update-value { - color: #f0f0f0; - } -} - -.update-value.update-new { - color: #16a34a; - font-weight: 500; -} - -.update-actions { - display: flex; - flex-direction: column; gap: 8px; - margin-top: 4px; +} + +.update-toast-message { + min-width: 0; + flex: 1; + font-size: 13px; + font-weight: 500; + color: #0f172a; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.update-toast-sub { + margin-left: 4px; + color: #64748b; + font-size: 12px; + font-weight: 400; +} + +.update-toast-actions { + align-items: center; + display: flex; + gap: 6px; + flex-shrink: 0; + flex-wrap: wrap; + justify-content: flex-end; + -webkit-app-region: no-drag; } .update-btn { display: flex; align-items: center; justify-content: center; - gap: 6px; - padding: 10px 16px; + gap: 5px; + height: 30px; + border-radius: 9px; + padding: 0 9px; border: none; - border-radius: 10px; - font-size: 13px; + background: transparent; + font-size: 12px; font-weight: 500; + color: #334155; cursor: pointer; - transition: all 0.15s ease; + transition: background-color 0.15s ease; + -webkit-app-region: no-drag; +} + +.update-btn:hover:not(:disabled) { + background: rgba(15, 23, 42, 0.07); } .update-btn:disabled { - opacity: 0.6; + opacity: 0.7; cursor: not-allowed; } +.update-btn-ghost { + border: 1px solid rgba(15, 23, 42, 0.12); + color: #334155; +} + .update-btn-primary { - background: #3b82f6; - color: white; + border: 1px solid #111827; + background: #111827; + color: #f8fafc; } .update-btn-primary:hover:not(:disabled) { - background: #2563eb; + background: #1f2937; } -.update-btn-secondary { - flex: 1; - background: transparent; - border: 1px solid rgba(0, 0, 0, 0.12); - color: #333; - padding: 8px 12px; +.update-btn-icon { + width: 30px; + padding: 0; } -.update-btn-secondary:hover:not(:disabled) { - background: rgba(0, 0, 0, 0.04); +.animate-spin { + animation: update-spin 0.8s linear infinite; +} + +@keyframes update-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (max-width: 520px) { + .update-toast { + width: min(420px, calc(100vw - 12px)); + padding: 10px; + } + + .update-toast-main { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .update-toast-message { + white-space: normal; + } + + .update-toast-actions { + justify-content: flex-end; + } } @media (prefers-color-scheme: dark) { - .update-btn-secondary { - border-color: rgba(255, 255, 255, 0.12); - color: #e0e0e0; + .update-toast { + border-color: rgba(148, 163, 184, 0.2); + background: rgba(15, 23, 42, 0.92); + box-shadow: + 0 10px 32px rgba(2, 6, 23, 0.5), + 0 2px 8px rgba(2, 6, 23, 0.4); } - .update-btn-secondary:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.06); + + .update-toast-icon { + color: #e2e8f0; + background: rgba(148, 163, 184, 0.15); + } + + .update-toast-message { + color: #f8fafc; + } + + .update-toast-sub { + color: #94a3b8; + } + + .update-btn { + color: #cbd5e1; + } + + .update-btn:hover:not(:disabled) { + background: rgba(148, 163, 184, 0.16); + } + + .update-btn-ghost { + border-color: rgba(148, 163, 184, 0.28); + } + + .update-btn-primary { + border-color: #e2e8f0; + background: #e2e8f0; + color: #0f172a; + } + + .update-btn-primary:hover:not(:disabled) { + background: #cbd5e1; } } - -.update-btn-row { - display: flex; - gap: 8px; -} - -.update-link { - display: flex; - align-items: center; - justify-content: center; - gap: 4px; - padding: 8px; - background: transparent; - border: none; - font-size: 12px; - color: #666; - cursor: pointer; - transition: color 0.15s ease; -} - -.update-link:hover { - color: #3b82f6; -} - -@media (prefers-color-scheme: dark) { - .update-link { - color: #999; - } - .update-link:hover { - color: #60a5fa; - } -} - -/* 动画 */ -@keyframes bounce { - 0%, - 100% { - transform: translateY(0); - } - 50% { - transform: translateY(-4px); - } -} - -.animate-bounce { - animation: bounce 0.6s ease infinite; -} diff --git a/src/pages/update-notification.tsx b/src/pages/update-notification.tsx index 4353497d9..11ceb3c54 100644 --- a/src/pages/update-notification.tsx +++ b/src/pages/update-notification.tsx @@ -2,8 +2,7 @@ * @file update-notification.tsx * @description 更新提醒独立窗口页面 * - * 独立于主应用的更新提醒悬浮窗口,显示版本信息和更新操作。 - * 参考 screenshot-chat.tsx 的实现模式。 + * 独立于主应用的更新提醒悬浮窗口,采用轻量 toast 形态展示更新操作。 * * input: URL 参数(current, latest, download_url) * output: 更新提醒 UI @@ -14,7 +13,7 @@ import { useEffect, useState, useCallback, type MouseEvent } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { safeInvoke } from "@/lib/dev-bridge"; import { open as shellOpen } from "@tauri-apps/plugin-shell"; -import { Download, X, Clock, SkipForward, ExternalLink } from "lucide-react"; +import { Bell, Download, ExternalLink, SkipForward, X } from "lucide-react"; import "./update-notification.css"; interface UpdateParams { @@ -39,13 +38,17 @@ export function UpdateNotificationPage() { downloadUrl: "", }); const [downloading, setDownloading] = useState(false); + const [visible, setVisible] = useState(false); + const [closing, setClosing] = useState(false); useEffect(() => { setParams(getUpdateParamsFromUrl()); + const timer = window.setTimeout(() => setVisible(true), 10); + return () => window.clearTimeout(timer); }, []); - // 关闭窗口 - const handleClose = useCallback(async () => { + // 直接关闭窗口(无动画) + const closeWindow = useCallback(async () => { try { await safeInvoke("close_update_window"); } catch (err) { @@ -55,16 +58,36 @@ export function UpdateNotificationPage() { } }, []); + // 带动画关闭 + const closeWithAnimation = useCallback(async () => { + if (closing) return; + setClosing(true); + await new Promise((resolve) => window.setTimeout(resolve, 160)); + await closeWindow(); + }, [closing, closeWindow]); + + // 关闭并应用退避策略 + const handleDismiss = useCallback(async () => { + try { + await safeInvoke("dismiss_update_notification", { + version: params.latestVersion || null, + }); + } catch (error) { + console.error("记录关闭提醒失败:", error); + } + await closeWithAnimation(); + }, [params.latestVersion, closeWithAnimation]); + // ESC 关闭窗口 useEffect(() => { const handleKeyDown = async (e: KeyboardEvent) => { if (e.key === "Escape") { - await handleClose(); + await handleDismiss(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [handleClose]); + }, [handleDismiss]); // 开始拖动窗口 const handleStartDrag = useCallback(async (e: MouseEvent) => { @@ -79,6 +102,14 @@ export function UpdateNotificationPage() { // 立即更新 const handleDownload = async () => { setDownloading(true); + try { + await safeInvoke("record_update_notification_action", { + action: "update_now", + }); + } catch (error) { + console.error("记录立即更新行为失败:", error); + } + try { await safeInvoke("download_update"); // download_update 成功后会自动关闭窗口并启动安装程序 @@ -88,7 +119,7 @@ export function UpdateNotificationPage() { if (params.downloadUrl) { try { await shellOpen(params.downloadUrl); - await handleClose(); + await closeWithAnimation(); } catch { window.open(params.downloadUrl, "_blank"); } @@ -99,8 +130,13 @@ export function UpdateNotificationPage() { }; // 稍后提醒 - const handleLater = async () => { - await handleClose(); + const handleLater = async (hours: number) => { + try { + await safeInvoke("remind_update_later", { hours }); + } catch (error) { + console.error("设置稍后提醒失败:", error); + } + await closeWithAnimation(); }; // 跳过此版本 @@ -110,7 +146,7 @@ export function UpdateNotificationPage() { await safeInvoke("skip_update_version", { version: params.latestVersion, }); - await handleClose(); + await closeWithAnimation(); } catch (error) { console.error("跳过版本失败:", error); } @@ -132,73 +168,79 @@ export function UpdateNotificationPage() { return (
-
- {/* 头部 - 可拖动区域 */} -
-
- ProxyCast - 发现新版本 -
- +
+
+
- {/* 内容 */} -
-
- 当前版本 - {params.currentVersion} -
-
- 最新版本 - - {params.latestVersion} - +
+
+ 发现新版本 {params.latestVersion || ""} + {params.currentVersion ? ( + + (当前 {params.currentVersion}) + + ) : null}
- {/* 操作按钮 */} -
+
e.stopPropagation()} + > + + + + + - -
+ {params.downloadUrl ? ( - -
- - {params.downloadUrl && ( - - )} + ) : null}