From 8da33ec9a0c0b5d7f1adf1ed6ae5f3d191fd399d Mon Sep 17 00:00:00 2001 From: coso Date: Thu, 12 Feb 2026 20:30:57 +0800 Subject: [PATCH] release: bump version to 0.64.0 --- IMPLEMENTATION_PLAN.md | 31 - package.json | 2 +- src-tauri/Cargo.lock | 30 +- src-tauri/Cargo.toml | 4 +- src-tauri/crates/scheduler/src/batch_dao.rs | 33 + src-tauri/crates/server/src/handlers/api.rs | 135 ++- .../crates/server/src/handlers/batch_api.rs | 92 +- .../server/src/handlers/batch_executor.rs | 420 +++++++ src-tauri/crates/server/src/handlers/mod.rs | 1 + src-tauri/crates/server/src/lib.rs | 10 + src-tauri/crates/skills/src/lib.rs | 4 +- src-tauri/crates/skills/src/skill_loader.rs | 65 ++ src-tauri/resources/models/aliases/codex.json | 9 +- .../resources/models/providers/aihubmix.json | 29 +- .../models/providers/alibaba-cn.json | 28 +- .../models/providers/amazon-bedrock.json | 140 ++- .../resources/models/providers/anthropic.json | 30 +- .../providers/azure-cognitive-services.json | 83 +- .../resources/models/providers/azure.json | 83 +- .../resources/models/providers/chutes.json | 28 +- .../providers/cloudflare-ai-gateway.json | 30 +- .../resources/models/providers/codex.json | 26 +- .../resources/models/providers/deepinfra.json | 28 +- .../models/providers/fireworks-ai.json | 29 +- .../models/providers/github-copilot.json | 54 +- .../providers/google-vertex-anthropic.json | 220 +--- .../models/providers/huggingface.json | 29 +- .../models/providers/kimi-for-coding.json | 30 +- .../models/providers/moonshotai-cn.json | 29 +- .../models/providers/moonshotai.json | 29 +- .../resources/models/providers/nvidia.json | 28 +- .../models/providers/ollama-cloud.json | 28 +- .../resources/models/providers/openai.json | 54 + .../resources/models/providers/opencode.json | 111 +- .../models/providers/openrouter.json | 84 +- src-tauri/resources/models/providers/poe.json | 83 +- .../models/providers/siliconflow-cn.json | 56 +- .../models/providers/siliconflow.json | 108 +- .../resources/models/providers/synthetic.json | 28 +- .../models/providers/togetherai.json | 134 +-- .../resources/models/providers/venice.json | 58 +- .../resources/models/providers/vercel.json | 111 +- .../resources/models/providers/zenmux.json | 112 +- src-tauri/src/app/bootstrap.rs | 5 + src-tauri/src/commands/agent_cmd.rs | 28 + src-tauri/src/commands/aster_agent_cmd.rs | 9 +- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/skill_error.rs | 50 + src-tauri/src/commands/skill_exec_cmd.rs | 325 +++++- src-tauri/tauri.conf.headless.json | 2 +- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 13 + src/components/AppSidebar.tsx | 35 +- .../agent/chat/components/ChatNavbar.tsx | 20 +- .../Inputbar/components/InputbarTools.tsx | 59 +- .../agent/chat/components/Inputbar/index.tsx | 8 +- .../agent/chat/components/Inputbar/styles.ts | 22 +- .../agent/chat/hooks/skillCommand.ts | 157 ++- .../agent/chat/hooks/useAgentChat.ts | 250 +++- src/components/agent/chat/index.tsx | 54 +- src/components/agent/chat/styles/index.ts | 8 +- .../agent/chat/utils/sessionRecovery.test.ts | 102 ++ .../agent/chat/utils/sessionRecovery.ts | 86 ++ .../agent/chat/utils/skillFailure.test.ts | 46 + .../agent/chat/utils/skillFailure.ts | 200 ++++ src/components/batch/BatchPage.tsx | 311 +++++ src/components/batch/BatchTaskDetail.tsx | 306 +++++ src/components/batch/CreateBatchDialog.tsx | 308 +++++ src/components/batch/TemplateManager.tsx | 324 ++++++ src/components/batch/index.ts | 1 + .../canvas/document/editor/SlashCommand.tsx | 9 +- .../canvas/music/MusicToolbar.tsx | 8 + .../music/renderers/PianoRollRenderer.tsx | 5 +- .../canvas/novel/NovelCanvas.tsx | 144 ++- .../canvas/poster/PosterToolbar.tsx | 13 +- .../LayoutTransition/LayoutTransition.tsx | 1 + .../LayoutTransition/useLayoutTransition.ts | 4 +- src/components/image-gen/ImageGenPage.tsx | 161 +++ src/components/image-gen/types.ts | 1 + src/components/image-gen/useImageGen.ts | 1018 ++++++++++++++++- src/components/memory/MemoryPage.tsx | 6 +- src/components/settings-v2/_layout/index.tsx | 3 +- src/components/workspace/WorkbenchPage.tsx | 238 +++- src/lib/api/agent.ts | 2 + src/lib/api/batch.ts | 178 +++ src/lib/workspace/navigation.test.ts | 60 + src/lib/workspace/navigation.ts | 23 + src/types/page.ts | 3 + 88 files changed, 6412 insertions(+), 953 deletions(-) delete mode 100644 IMPLEMENTATION_PLAN.md create mode 100644 src-tauri/crates/server/src/handlers/batch_executor.rs create mode 100644 src-tauri/src/commands/skill_error.rs create mode 100644 src/components/agent/chat/utils/sessionRecovery.test.ts create mode 100644 src/components/agent/chat/utils/sessionRecovery.ts create mode 100644 src/components/agent/chat/utils/skillFailure.test.ts create mode 100644 src/components/agent/chat/utils/skillFailure.ts create mode 100644 src/components/batch/BatchPage.tsx create mode 100644 src/components/batch/BatchTaskDetail.tsx create mode 100644 src/components/batch/CreateBatchDialog.tsx create mode 100644 src/components/batch/TemplateManager.tsx create mode 100644 src/components/batch/index.ts create mode 100644 src/lib/api/batch.ts create mode 100644 src/lib/workspace/navigation.test.ts create mode 100644 src/lib/workspace/navigation.ts diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md deleted file mode 100644 index 53be1fa85..000000000 --- a/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,31 +0,0 @@ -# 批量任务支持实施计划 - -## Stage 1: 创建 Scheduler Crate -**Goal**: 创建基础调度器模块,定义批量任务数据结构 -**Success Criteria**: Crate 编译通过,基础数据结构定义完成 -**Tests**: 单元测试验证数据结构序列化 -**Status**: Completed - -## Stage 2: 实现批量任务执行器 -**Goal**: 实现 BatchTaskExecutor,支持并发控制和 Orchestrator Fallback -**Success Criteria**: 执行器能够处理批量任务,支持并发控制 -**Tests**: 集成测试验证批量任务执行逻辑 -**Status**: In Progress - -## Stage 3: 创建 Batch API 端点 -**Goal**: 实现批量任务的 REST API -**Success Criteria**: POST /api/batch/tasks 和 GET /api/batch/tasks/:id 可用 -**Tests**: API 测试验证创建和查询功能 -**Status**: Completed - -## Stage 4: 数据库持久化 -**Goal**: 实现批量任务和模板的数据库存储 -**Success Criteria**: 数据可以持久化到 SQLite -**Tests**: DAO 层单元测试 -**Status**: In Progress - -## Stage 5: 前端页面实现 -**Goal**: 创建批量任务管理界面 -**Success Criteria**: 任务列表、创建页面、结果展示完整 -**Tests**: 手动测试 UI 交互流程 -**Status**: Not Started diff --git a/package.json b/package.json index 4fcc7b554..162c44cc7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.63.0", + "version": "0.64.0", "type": "module", "repository": { "type": "git", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d3140de7c..152d0a16d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6621,7 +6621,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.63.0" +version = "0.64.0" dependencies = [ "anyhow", "arboard", @@ -6717,7 +6717,7 @@ dependencies = [ [[package]] name = "proxycast-agent" -version = "0.63.0" +version = "0.64.0" dependencies = [ "aster", "async-trait", @@ -6740,7 +6740,7 @@ dependencies = [ [[package]] name = "proxycast-config" -version = "0.63.0" +version = "0.64.0" dependencies = [ "async-trait", "parking_lot", @@ -6756,7 +6756,7 @@ dependencies = [ [[package]] name = "proxycast-core" -version = "0.63.0" +version = "0.64.0" dependencies = [ "async-trait", "axum 0.7.9", @@ -6795,7 +6795,7 @@ dependencies = [ [[package]] name = "proxycast-credential" -version = "0.63.0" +version = "0.64.0" dependencies = [ "axum 0.7.9", "chrono", @@ -6813,7 +6813,7 @@ dependencies = [ [[package]] name = "proxycast-infra" -version = "0.63.0" +version = "0.64.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -6833,7 +6833,7 @@ dependencies = [ [[package]] name = "proxycast-mcp" -version = "0.63.0" +version = "0.64.0" dependencies = [ "async-trait", "glob", @@ -6848,7 +6848,7 @@ dependencies = [ [[package]] name = "proxycast-processor" -version = "0.63.0" +version = "0.64.0" dependencies = [ "async-trait", "parking_lot", @@ -6867,7 +6867,7 @@ dependencies = [ [[package]] name = "proxycast-providers" -version = "0.63.0" +version = "0.64.0" dependencies = [ "anyhow", "async-stream", @@ -6919,7 +6919,7 @@ dependencies = [ [[package]] name = "proxycast-server" -version = "0.63.0" +version = "0.64.0" dependencies = [ "async-stream", "axum 0.7.9", @@ -6958,7 +6958,7 @@ dependencies = [ [[package]] name = "proxycast-server-utils" -version = "0.63.0" +version = "0.64.0" dependencies = [ "axum 0.7.9", "futures", @@ -6973,7 +6973,7 @@ dependencies = [ [[package]] name = "proxycast-services" -version = "0.63.0" +version = "0.64.0" dependencies = [ "anyhow", "aster", @@ -7014,7 +7014,7 @@ dependencies = [ [[package]] name = "proxycast-skills" -version = "0.63.0" +version = "0.64.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -7030,7 +7030,7 @@ dependencies = [ [[package]] name = "proxycast-terminal" -version = "0.63.0" +version = "0.64.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -7057,7 +7057,7 @@ dependencies = [ [[package]] name = "proxycast-websocket" -version = "0.63.0" +version = "0.64.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b781bd908..8b7c1dee5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.63.0" +version = "0.64.0" edition = "2021" authors = ["you"] repository = "https://github.com/aiclientproxy/proxycast" @@ -181,7 +181,7 @@ version = "2.4" [package] name = "proxycast" -version = "0.63.0" +version = "0.64.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/scheduler/src/batch_dao.rs b/src-tauri/crates/scheduler/src/batch_dao.rs index a8e10554c..5551e1785 100644 --- a/src-tauri/crates/scheduler/src/batch_dao.rs +++ b/src-tauri/crates/scheduler/src/batch_dao.rs @@ -289,6 +289,39 @@ impl BatchTaskDao { Ok(()) } + + /// 更新批量任务结果、状态和时间戳 + /// + /// 用于执行器在每个子任务完成后实时更新数据库 + pub fn update_results( + db: &DbConnection, + id: &Uuid, + status: BatchTaskStatus, + results: &[super::batch::TaskResult], + started_at: Option>, + completed_at: Option>, + ) -> Result<()> { + let conn = db.lock().unwrap(); + + let results_json = if results.is_empty() { + None + } else { + Some(serde_json::to_string(results)?) + }; + + conn.execute( + "UPDATE batch_tasks SET status = ?1, results_json = ?2, started_at = ?3, completed_at = ?4 WHERE id = ?5", + params![ + serde_json::to_string(&status)?, + results_json, + started_at.map(|t| t.to_rfc3339()), + completed_at.map(|t| t.to_rfc3339()), + id.to_string(), + ], + )?; + + Ok(()) + } } /// 模板 DAO diff --git a/src-tauri/crates/server/src/handlers/api.rs b/src-tauri/crates/server/src/handlers/api.rs index 492033d5d..368422940 100644 --- a/src-tauri/crates/server/src/handlers/api.rs +++ b/src-tauri/crates/server/src/handlers/api.rs @@ -23,6 +23,7 @@ use axum::{ Json, }; use serde_json::json; +use std::future::Future; use crate::client_detector::ClientType; use crate::{record_request_telemetry, record_token_usage, AppState}; @@ -124,6 +125,119 @@ async fn select_credential_for_request( } } +async fn call_with_single_provider_resilience( + state: &AppState, + request_id: &str, + provider_label: &str, + is_stream: bool, + mut operation: F, +) -> Response +where + F: FnMut() -> Fut, + Fut: Future, +{ + let retrier = state.processor.retrier.clone(); + let timeout_controller = state.processor.timeout.clone(); + let max_retries = if is_stream { + 0 + } else { + retrier.config().max_retries + }; + let total_attempts = max_retries + 1; + let mut attempt = 0u32; + + loop { + attempt += 1; + + let response = match timeout_controller.execute_with_timeout(operation()).await { + Ok(resp) => resp, + Err(timeout_err) => { + if attempt <= max_retries { + let delay = retrier.backoff_delay(attempt - 1); + state.logs.write().await.add( + "warn", + &format!( + "[RETRY] request_id={} provider={} attempt={}/{} timeout={} delay_ms={}", + request_id, + provider_label, + attempt, + total_attempts, + timeout_err, + delay.as_millis() + ), + ); + tokio::time::sleep(delay).await; + continue; + } + + state.logs.write().await.add( + "error", + &format!( + "[TIMEOUT] request_id={} provider={} attempts={} error={}", + request_id, provider_label, attempt, timeout_err + ), + ); + + return ( + StatusCode::GATEWAY_TIMEOUT, + Json(json!({ + "error": { + "type": "timeout_error", + "code": "provider_timeout", + "message": format!("Provider request timeout: {}", timeout_err) + } + })), + ) + .into_response(); + } + }; + + let status_code = response.status().as_u16(); + let should_retry = attempt <= max_retries && retrier.config().is_retryable(status_code); + + if should_retry { + let delay = retrier.backoff_delay(attempt - 1); + + if status_code == StatusCode::TOO_MANY_REQUESTS.as_u16() { + state.logs.write().await.add( + "warn", + &format!( + "[QUOTA] request_id={} provider={} attempt={}/{} status=429", + request_id, provider_label, attempt, total_attempts + ), + ); + } + + state.logs.write().await.add( + "warn", + &format!( + "[RETRY] request_id={} provider={} attempt={}/{} status={} delay_ms={}", + request_id, + provider_label, + attempt, + total_attempts, + status_code, + delay.as_millis() + ), + ); + tokio::time::sleep(delay).await; + continue; + } + + if attempt > 1 { + state.logs.write().await.add( + "info", + &format!( + "[RETRY] request_id={} provider={} completed attempts={} final_status={}", + request_id, provider_label, attempt, status_code + ), + ); + } + + return response; + } +} + // ============================================================================ // Provider 选择辅助函数 // ============================================================================ @@ -393,7 +507,15 @@ pub async fn chat_completions( // **Validates: Requirements 2.1, 2.3, 2.5** eprintln!("[CHAT_COMPLETIONS] 调用 Provider: {}", cred.provider_type); - let response = call_provider_openai(&state, &cred, &request, None).await; + let provider_label = cred.provider_type.to_string(); + let response = call_with_single_provider_resilience( + &state, + &ctx.request_id, + &provider_label, + request.stream, + || async { call_provider_openai(&state, &cred, &request, None).await }, + ) + .await; eprintln!( "[CHAT_COMPLETIONS] Provider 响应状态: {}", response.status() @@ -411,6 +533,7 @@ pub async fn chat_completions( // 如果成功且需要 Flow 捕获,提取响应体内容和响应头 // 注意:非流式响应需要读取 body,所以必须在这里处理 + return response; } // 回退到旧的单凭证模式(仅当选择的 Provider 是 Kiro 时) @@ -909,7 +1032,15 @@ pub async fn anthropic_messages( // 检查是否需要拦截请求 // **Validates: Requirements 2.1, 2.3, 2.5** - let response = call_provider_anthropic(&state, &cred, &request, None).await; + let provider_label = cred.provider_type.to_string(); + let response = call_with_single_provider_resilience( + &state, + &ctx.request_id, + &provider_label, + request.stream, + || async { call_provider_anthropic(&state, &cred, &request, None).await }, + ) + .await; // 记录请求统计 let is_success = response.status().is_success(); diff --git a/src-tauri/crates/server/src/handlers/batch_api.rs b/src-tauri/crates/server/src/handlers/batch_api.rs index 013867d45..ffa303703 100644 --- a/src-tauri/crates/server/src/handlers/batch_api.rs +++ b/src-tauri/crates/server/src/handlers/batch_api.rs @@ -146,8 +146,10 @@ pub async fn create_batch_task( ), ); - // TODO: 启动异步执行任务 - // 这里需要集成 BatchTaskExecutor + // 启动异步执行任务 + if let Some(executor) = state.batch_executor.read().await.as_ref() { + executor.start_batch(batch_id).await; + } // 返回响应 ( @@ -256,23 +258,87 @@ pub async fn list_batch_tasks(State(state): State) -> Response { /// DELETE /api/batch/tasks/:id - 取消批量任务 pub async fn cancel_batch_task(State(state): State, Path(id): Path) -> Response { - // TODO: 取消批量任务 + let db = match &state.db { + Some(db) => db, + None => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": { + "message": "数据库未初始化", + "type": "database_error" + } + })), + ) + .into_response(); + } + }; + + // 检查任务是否存在 + let batch_task = match BatchTaskDao::get_by_id(db, &id) { + Ok(Some(task)) => task, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": { + "message": format!("批量任务不存在: {}", id), + "type": "not_found" + } + })), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": { + "message": format!("查询批量任务失败: {}", e), + "type": "database_error" + } + })), + ) + .into_response(); + } + }; + + // 只能取消运行中的任务 + if batch_task.status != proxycast_scheduler::BatchTaskStatus::Running + && batch_task.status != proxycast_scheduler::BatchTaskStatus::Pending + { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": { + "message": format!("任务状态为 {:?},无法取消", batch_task.status), + "type": "invalid_state" + } + })), + ) + .into_response(); + } + + // 通过执行器取消 + let cancelled = if let Some(executor) = state.batch_executor.read().await.as_ref() { + executor.cancel_batch(&id).await + } else { + false + }; + + if !cancelled { + // 如果执行器中没有找到(可能还没开始执行),直接更新 DB 状态 + let _ = + BatchTaskDao::update_status(db, &id, proxycast_scheduler::BatchTaskStatus::Cancelled); + } + state .logs .write() .await .add("info", &format!("[BATCH] 取消批量任务: id={}", id)); - ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": { - "message": format!("批量任务不存在: {}", id), - "type": "not_found" - } - })), - ) - .into_response() + (StatusCode::OK, Json(serde_json::json!({"cancelled": true}))).into_response() } /// POST /api/batch/templates - 创建任务模板 diff --git a/src-tauri/crates/server/src/handlers/batch_executor.rs b/src-tauri/crates/server/src/handlers/batch_executor.rs new file mode 100644 index 000000000..f8ad3102e --- /dev/null +++ b/src-tauri/crates/server/src/handlers/batch_executor.rs @@ -0,0 +1,420 @@ +//! 批量任务执行器 +//! +//! 负责异步执行批量任务,支持并发控制、重试、超时和取消 + +use std::collections::HashMap; +use std::sync::Arc; + +use axum::http::StatusCode; +use proxycast_core::models::openai::{ + ChatCompletionRequest, ChatCompletionResponse, ChatMessage, MessageContent, +}; +use proxycast_scheduler::{BatchTaskDao, BatchTaskStatus, TaskResult, TemplateDao, TokenUsage}; +use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use crate::AppState; + +/// 批量任务执行器 +#[derive(Clone)] +pub struct BatchTaskExecutor { + state: AppState, + cancel_tokens: Arc>>, +} + +impl BatchTaskExecutor { + pub fn new(state: AppState) -> Self { + Self { + state, + cancel_tokens: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// 启动批量任务执行(spawn 后台任务) + pub async fn start_batch(&self, batch_id: Uuid) { + let cancel_token = CancellationToken::new(); + self.cancel_tokens + .write() + .await + .insert(batch_id, cancel_token.clone()); + + let state = self.state.clone(); + let cancel_tokens = self.cancel_tokens.clone(); + + tokio::spawn(async move { + Self::execute_batch(state, batch_id, cancel_token).await; + // 执行完毕后清理 cancel token + cancel_tokens.write().await.remove(&batch_id); + }); + } + + /// 取消运行中的批量任务 + pub async fn cancel_batch(&self, batch_id: &Uuid) -> bool { + if let Some(token) = self.cancel_tokens.read().await.get(batch_id) { + token.cancel(); + true + } else { + false + } + } + + /// 核心执行逻辑 + async fn execute_batch(state: AppState, batch_id: Uuid, cancel_token: CancellationToken) { + let db = match &state.db { + Some(db) => db, + None => { + tracing::error!("[BATCH] 数据库未初始化, batch_id={}", batch_id); + return; + } + }; + + // 1. 从 DB 加载 BatchTask + let mut batch_task = match BatchTaskDao::get_by_id(db, &batch_id) { + Ok(Some(task)) => task, + Ok(None) => { + tracing::error!("[BATCH] 批量任务不存在: {}", batch_id); + return; + } + Err(e) => { + tracing::error!("[BATCH] 加载批量任务失败: {}", e); + return; + } + }; + + // 2. 加载模板 + let template = match TemplateDao::get_by_id(db, &batch_task.template_id) { + Ok(Some(t)) => t, + Ok(None) => { + tracing::error!("[BATCH] 模板不存在: {}", batch_task.template_id); + let _ = BatchTaskDao::update_status(db, &batch_id, BatchTaskStatus::Failed); + return; + } + Err(e) => { + tracing::error!("[BATCH] 加载模板失败: {}", e); + let _ = BatchTaskDao::update_status(db, &batch_id, BatchTaskStatus::Failed); + return; + } + }; + + // 3. 更新状态为 Running + let now = chrono::Utc::now(); + batch_task.status = BatchTaskStatus::Running; + batch_task.started_at = Some(now); + let _ = BatchTaskDao::update_results( + db, + &batch_id, + BatchTaskStatus::Running, + &batch_task.results, + batch_task.started_at, + None, + ); + + tracing::info!( + "[BATCH] 开始执行批量任务: id={}, name={}, task_count={}", + batch_id, + batch_task.name, + batch_task.tasks.len() + ); + + // 4. 用 Semaphore 控制并发 + let concurrency = batch_task.options.concurrency.max(1); + let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency)); + let results = Arc::new(RwLock::new(Vec::::new())); + let mut handles = Vec::new(); + + for task_def in &batch_task.tasks { + let task_id = task_def.id.unwrap_or_else(Uuid::new_v4); + let variables = task_def.variables.clone(); + let sem = semaphore.clone(); + let state = state.clone(); + let cancel = cancel_token.clone(); + let results = results.clone(); + let model = template.model.clone(); + let system_prompt = template.system_prompt.clone(); + let user_message = template.render_user_message(&variables); + let temperature = template.temperature; + let max_tokens = template.max_tokens; + let retry_count = batch_task.options.retry_count; + let timeout_secs = batch_task.options.timeout_seconds; + let db_clone = db.clone(); + let batch_id_clone = batch_id; + + let handle = tokio::spawn(async move { + let _permit = sem.acquire().await.unwrap(); + + // 检查取消 + if cancel.is_cancelled() { + let result = TaskResult { + task_id, + status: proxycast_scheduler::BatchTaskStatus2::Cancelled, + content: None, + error: Some("任务已取消".to_string()), + usage: TokenUsage::default(), + started_at: chrono::Utc::now(), + completed_at: Some(chrono::Utc::now()), + }; + results.write().await.push(result); + return; + } + + let result = Self::execute_single_task( + &state, + task_id, + &model, + system_prompt.as_deref(), + &user_message, + temperature, + max_tokens, + retry_count, + timeout_secs, + &cancel, + ) + .await; + + results.write().await.push(result); + + // 实时更新 DB 进度 + let current_results = results.read().await.clone(); + let _ = BatchTaskDao::update_results( + &db_clone, + &batch_id_clone, + BatchTaskStatus::Running, + ¤t_results, + None, + None, + ); + }); + + handles.push(handle); + } + + // 等待所有任务完成 + for handle in handles { + let _ = handle.await; + } + + // 5. 计算最终状态 + let final_results = results.read().await.clone(); + let total = batch_task.tasks.len(); + let completed = final_results + .iter() + .filter(|r| r.status == proxycast_scheduler::BatchTaskStatus2::Completed) + .count(); + let cancelled = final_results + .iter() + .filter(|r| r.status == proxycast_scheduler::BatchTaskStatus2::Cancelled) + .count(); + + let final_status = if cancel_token.is_cancelled() { + BatchTaskStatus::Cancelled + } else if completed == total { + BatchTaskStatus::Completed + } else if completed == 0 { + BatchTaskStatus::Failed + } else { + BatchTaskStatus::PartiallyCompleted + }; + + let completed_at = chrono::Utc::now(); + let _ = BatchTaskDao::update_results( + db, + &batch_id, + final_status, + &final_results, + batch_task.started_at, + Some(completed_at), + ); + + tracing::info!( + "[BATCH] 批量任务完成: id={}, status={:?}, completed={}/{}, cancelled={}", + batch_id, + final_status, + completed, + total, + cancelled + ); + } + + /// 执行单个子任务(含重试和超时) + async fn execute_single_task( + state: &AppState, + task_id: Uuid, + model: &str, + system_prompt: Option<&str>, + user_message: &str, + temperature: Option, + max_tokens: Option, + retry_count: usize, + timeout_secs: u64, + cancel: &CancellationToken, + ) -> TaskResult { + let started_at = chrono::Utc::now(); + let max_attempts = retry_count + 1; + + for attempt in 0..max_attempts { + if cancel.is_cancelled() { + return TaskResult { + task_id, + status: proxycast_scheduler::BatchTaskStatus2::Cancelled, + content: None, + error: Some("任务已取消".to_string()), + usage: TokenUsage::default(), + started_at, + completed_at: Some(chrono::Utc::now()), + }; + } + + if attempt > 0 { + tracing::info!( + "[BATCH] 重试任务: task_id={}, attempt={}/{}", + task_id, + attempt + 1, + max_attempts + ); + } + + // 构建请求 + let mut messages = Vec::new(); + if let Some(sys) = system_prompt { + messages.push(ChatMessage { + role: "system".to_string(), + content: Some(MessageContent::Text(sys.to_string())), + tool_calls: None, + tool_call_id: None, + reasoning_content: None, + }); + } + messages.push(ChatMessage { + role: "user".to_string(), + content: Some(MessageContent::Text(user_message.to_string())), + tool_calls: None, + tool_call_id: None, + reasoning_content: None, + }); + + let request = ChatCompletionRequest { + model: model.to_string(), + messages, + temperature, + max_tokens, + top_p: None, + stream: false, + tools: None, + tool_choice: None, + reasoning_effort: None, + }; + + // 调用 LLM(带超时) + let result = tokio::time::timeout( + std::time::Duration::from_secs(timeout_secs), + Self::call_llm(state, &request), + ) + .await; + + match result { + Ok(Ok((content, usage))) => { + return TaskResult { + task_id, + status: proxycast_scheduler::BatchTaskStatus2::Completed, + content: Some(content), + error: None, + usage, + started_at, + completed_at: Some(chrono::Utc::now()), + }; + } + Ok(Err(e)) => { + if attempt == max_attempts - 1 { + return TaskResult { + task_id, + status: proxycast_scheduler::BatchTaskStatus2::Failed, + content: None, + error: Some(e), + usage: TokenUsage::default(), + started_at, + completed_at: Some(chrono::Utc::now()), + }; + } + // 重试前等待 + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + Err(_) => { + if attempt == max_attempts - 1 { + return TaskResult { + task_id, + status: proxycast_scheduler::BatchTaskStatus2::Failed, + content: None, + error: Some(format!("任务超时 ({}s)", timeout_secs)), + usage: TokenUsage::default(), + started_at, + completed_at: Some(chrono::Utc::now()), + }; + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + } + } + + // 不应到达这里 + TaskResult { + task_id, + status: proxycast_scheduler::BatchTaskStatus2::Failed, + content: None, + error: Some("未知错误".to_string()), + usage: TokenUsage::default(), + started_at, + completed_at: Some(chrono::Utc::now()), + } + } + + /// 调用 LLM:选择凭证 + 调用 provider + async fn call_llm( + state: &AppState, + request: &ChatCompletionRequest, + ) -> Result<(String, TokenUsage), String> { + let db = state.db.as_ref().ok_or("数据库未初始化")?; + + // 选择凭证 + let credential = state + .pool_service + .select_credential_with_fallback( + db, + &state.api_key_service, + "", + Some(&request.model), + None, + None, + ) + .await? + .ok_or_else(|| format!("没有可用的凭证来调用模型: {}", request.model))?; + + // 调用 provider + let response = + super::provider_calls::call_provider_openai(state, &credential, request, None).await; + + // 解析响应 + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), 10 * 1024 * 1024) + .await + .map_err(|e| format!("读取响应体失败: {}", e))?; + + if status != StatusCode::OK { + let error_text = String::from_utf8_lossy(&body); + return Err(format!("LLM 调用失败 ({}): {}", status, error_text)); + } + + let resp: ChatCompletionResponse = + serde_json::from_slice(&body).map_err(|e| format!("解析响应失败: {}", e))?; + + let content = resp + .choices + .first() + .and_then(|c| c.message.content.clone()) + .unwrap_or_default(); + + let usage = TokenUsage::new(resp.usage.prompt_tokens, resp.usage.completion_tokens); + + Ok((content, usage)) + } +} diff --git a/src-tauri/crates/server/src/handlers/mod.rs b/src-tauri/crates/server/src/handlers/mod.rs index 2dca34b28..bfff844ff 100644 --- a/src-tauri/crates/server/src/handlers/mod.rs +++ b/src-tauri/crates/server/src/handlers/mod.rs @@ -5,6 +5,7 @@ pub mod api; pub mod api_key_provider_utils; pub mod batch_api; +pub mod batch_executor; pub mod credentials_api; pub mod image_handler; pub mod kiro_credential; diff --git a/src-tauri/crates/server/src/lib.rs b/src-tauri/crates/server/src/lib.rs index 59d0410f6..cfc1b489c 100644 --- a/src-tauri/crates/server/src/lib.rs +++ b/src-tauri/crates/server/src/lib.rs @@ -458,6 +458,9 @@ pub struct AppState { pub kiro_event_service: Arc, /// API Key Provider 服务(用于智能降级) pub api_key_service: Arc, + /// 批量任务执行器 + pub batch_executor: + Arc>>, } /// 启动配置文件监控 @@ -863,8 +866,15 @@ async fn run_server( endpoint_providers, kiro_event_service, api_key_service, + batch_executor: Arc::new(tokio::sync::RwLock::new(None)), }; + // 初始化批量任务执行器 + { + let executor = handlers::batch_executor::BatchTaskExecutor::new(state.clone()); + *state.batch_executor.write().await = Some(executor); + } + // ========== 开发模式:通过回调启动桥接服务器 ========== if let Some(callback) = dev_bridge_callback { callback(state.clone()); diff --git a/src-tauri/crates/skills/src/lib.rs b/src-tauri/crates/skills/src/lib.rs index 9b136b53f..4e782b0f3 100644 --- a/src-tauri/crates/skills/src/lib.rs +++ b/src-tauri/crates/skills/src/lib.rs @@ -19,6 +19,6 @@ pub use llm_provider::{LlmProvider, SkillError}; pub use proxycast_llm_provider::ProxyCastLlmProvider; pub use skill_loader::{ find_skill_by_name, get_proxycast_skills_dir, load_skill_from_file, load_skills_from_directory, - parse_allowed_tools, parse_boolean, parse_skill_frontmatter, LoadedSkillDefinition, - SkillFrontmatter, + parse_allowed_tools, parse_boolean, parse_skill_frontmatter, parse_workflow_steps, + LoadedSkillDefinition, SkillFrontmatter, WorkflowStep, }; diff --git a/src-tauri/crates/skills/src/skill_loader.rs b/src-tauri/crates/skills/src/skill_loader.rs index 1f2312a04..cfc117e7c 100644 --- a/src-tauri/crates/skills/src/skill_loader.rs +++ b/src-tauri/crates/skills/src/skill_loader.rs @@ -6,6 +6,28 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; +/// Workflow 步骤定义 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowStep { + /// 步骤 ID + pub id: String, + /// 步骤名称 + pub name: String, + /// 步骤提示词(作为该步骤的 system_prompt 或追加指令) + pub prompt: String, + /// 可选的模型覆盖 + pub model: Option, + /// 可选的温度参数 + pub temperature: Option, + /// 执行模式:prompt(默认)、elicitation + #[serde(default = "default_step_execution_mode")] + pub execution_mode: String, +} + +fn default_step_execution_mode() -> String { + "prompt".to_string() +} + /// Skill 前置元数据 #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct SkillFrontmatter { @@ -24,6 +46,9 @@ pub struct SkillFrontmatter { pub disable_model_invocation: Option, #[serde(rename = "execution-mode")] pub execution_mode: Option, + /// Workflow 步骤定义(JSON 格式) + #[serde(rename = "steps-json")] + pub steps_json: Option, } /// 内部 Skill 定义(用于加载和执行) @@ -40,6 +65,8 @@ pub struct LoadedSkillDefinition { pub provider: Option, pub disable_model_invocation: bool, pub execution_mode: String, + /// Workflow 步骤定义(仅 execution_mode == "workflow" 时有效) + pub workflow_steps: Vec, } /// 解析 Skill 文件的 frontmatter @@ -77,6 +104,7 @@ pub fn parse_skill_frontmatter(content: &str) -> (SkillFrontmatter, String) { frontmatter.disable_model_invocation = Some(clean_value) } "execution-mode" => frontmatter.execution_mode = Some(clean_value), + "steps-json" => frontmatter.steps_json = Some(clean_value), _ => {} } } @@ -117,6 +145,33 @@ pub fn parse_boolean(value: Option<&str>, default: bool) -> bool { .unwrap_or(default) } +/// 解析 workflow steps +/// +/// 支持两种来源: +/// 1. frontmatter 中的 `steps-json` 字段(单行 JSON 数组) +/// 2. markdown body 中的 `` 注释块 +pub fn parse_workflow_steps(steps_json: Option<&str>, markdown_content: &str) -> Vec { + // 优先使用 frontmatter 中的 steps-json + if let Some(json) = steps_json { + if let Ok(steps) = serde_json::from_str::>(json) { + return steps; + } + } + + // 回退:从 markdown body 中解析 + let re = regex::Regex::new(r"").unwrap(); + if let Some(captures) = re.captures(markdown_content) { + if let Some(json_match) = captures.get(1) { + if let Ok(steps) = serde_json::from_str::>(json_match.as_str().trim()) + { + return steps; + } + } + } + + Vec::new() +} + /// 从文件加载 Skill 定义 pub fn load_skill_from_file( skill_name: &str, @@ -140,6 +195,15 @@ pub fn load_skill_from_file( .clone() .unwrap_or_else(|| "prompt".to_string()); + let workflow_steps = parse_workflow_steps(frontmatter.steps_json.as_deref(), &markdown_content); + + // 如果有 steps 但 execution_mode 未显式设置,自动升级为 workflow + let execution_mode = if !workflow_steps.is_empty() && execution_mode == "prompt" { + "workflow".to_string() + } else { + execution_mode + }; + Ok(LoadedSkillDefinition { skill_name: skill_name.to_string(), display_name, @@ -152,6 +216,7 @@ pub fn load_skill_from_file( provider: frontmatter.provider, disable_model_invocation, execution_mode, + workflow_steps, }) } diff --git a/src-tauri/resources/models/aliases/codex.json b/src-tauri/resources/models/aliases/codex.json index c4732d39f..c3bf639ce 100644 --- a/src-tauri/resources/models/aliases/codex.json +++ b/src-tauri/resources/models/aliases/codex.json @@ -3,12 +3,19 @@ "provider": "codex", "description": "OpenAI Codex CLI 支持的模型", "models": [ + "gpt-5.3-codex", "gpt-5.2-codex", "gpt-5.1-codex-max", "gpt-5.1-codex-mini", "gpt-5.2" ], "aliases": { + "gpt-5.3-codex": { + "actual": "gpt-5.3-codex", + "internal_name": "gpt-5.3-codex", + "provider": "openai", + "description": "Codex 最新一代模型,编码与推理能力增强" + }, "gpt-5.2-codex": { "actual": "gpt-5.2-codex", "internal_name": "gpt-5.2-codex", @@ -34,5 +41,5 @@ "description": "最新前沿模型,跨知识、推理和编码的全面提升" } }, - "updated_at": "2026-01-13T00:00:00Z" + "updated_at": "2026-02-11T00:00:00Z" } diff --git a/src-tauri/resources/models/providers/aihubmix.json b/src-tauri/resources/models/providers/aihubmix.json index a316970ec..ad359f453 100644 --- a/src-tauri/resources/models/providers/aihubmix.json +++ b/src-tauri/resources/models/providers/aihubmix.json @@ -186,8 +186,35 @@ "status": "active", "release_date": "2025-12-22", "is_latest": true + }, + { + "id": "kimi-k2.5", + "name": "Kimi K2.5", + "family": "kimi", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0.6, + "output": 3, + "cache_read": 0.1, + "currency": "USD" + }, + "limits": { + "context": 262144, + "max_output": 262144 + }, + "status": "active", + "release_date": "2026-01-27", + "is_latest": true } ], - "updated_at": "2026-01-12T00:00:00.000Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "aihubmix.com" } diff --git a/src-tauri/resources/models/providers/alibaba-cn.json b/src-tauri/resources/models/providers/alibaba-cn.json index 5998fde49..cf8e80fcc 100644 --- a/src-tauri/resources/models/providers/alibaba-cn.json +++ b/src-tauri/resources/models/providers/alibaba-cn.json @@ -264,8 +264,34 @@ "status": "active", "release_date": "2024-09-01", "is_latest": false + }, + { + "id": "kimi-k2.5", + "name": "Moonshot Kimi K2.5", + "family": "kimi", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0.574, + "output": 2.411, + "currency": "USD" + }, + "limits": { + "context": 262144, + "max_output": 32768 + }, + "status": "active", + "release_date": "2025-01-27", + "is_latest": true } ], - "updated_at": "2026-01-12T00:00:00.000Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "alibaba.com" } diff --git a/src-tauri/resources/models/providers/amazon-bedrock.json b/src-tauri/resources/models/providers/amazon-bedrock.json index a9055f952..29831b04d 100644 --- a/src-tauri/resources/models/providers/amazon-bedrock.json +++ b/src-tauri/resources/models/providers/amazon-bedrock.json @@ -266,8 +266,146 @@ "status": "active", "release_date": "2024-04-30", "is_latest": false + }, + { + "id": "us.anthropic.claude-opus-4-6-v1", + "name": "Claude Opus 4.6 (US)", + "family": "claude-opus", + "tier": "max", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25, + "currency": "USD" + }, + "limits": { + "context": 1000000, + "max_output": 128000 + }, + "status": "active", + "release_date": "2026-02-05", + "is_latest": true + }, + { + "id": "anthropic.claude-opus-4-6-v1", + "name": "Claude Opus 4.6", + "family": "claude-opus", + "tier": "max", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25, + "currency": "USD" + }, + "limits": { + "context": 1000000, + "max_output": 128000 + }, + "status": "active", + "release_date": "2026-02-05", + "is_latest": true + }, + { + "id": "moonshotai.kimi-k2.5", + "name": "Kimi K2.5", + "family": "kimi", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0.6, + "output": 3, + "currency": "USD" + }, + "limits": { + "context": 256000, + "max_output": 256000 + }, + "status": "active", + "release_date": "2026-02-06", + "is_latest": true + }, + { + "id": "global.anthropic.claude-opus-4-6-v1", + "name": "Claude Opus 4.6 (Global)", + "family": "claude-opus", + "tier": "max", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25, + "currency": "USD" + }, + "limits": { + "context": 1000000, + "max_output": 128000 + }, + "status": "active", + "release_date": "2026-02-05", + "is_latest": true + }, + { + "id": "eu.anthropic.claude-opus-4-6-v1", + "name": "Claude Opus 4.6 (EU)", + "family": "claude-opus", + "tier": "max", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25, + "currency": "USD" + }, + "limits": { + "context": 1000000, + "max_output": 128000 + }, + "status": "active", + "release_date": "2026-02-05", + "is_latest": true } ], - "updated_at": "2026-01-12T00:00:00.000Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "aws.amazon.com" } diff --git a/src-tauri/resources/models/providers/anthropic.json b/src-tauri/resources/models/providers/anthropic.json index 8ee3019dd..4795d8de2 100644 --- a/src-tauri/resources/models/providers/anthropic.json +++ b/src-tauri/resources/models/providers/anthropic.json @@ -116,8 +116,36 @@ "status": "active", "release_date": "2025-10-01", "is_latest": false + }, + { + "id": "claude-opus-4-6", + "name": "Claude Opus 4.6", + "family": "claude-opus", + "tier": "max", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25, + "currency": "USD" + }, + "limits": { + "context": 200000, + "max_output": 128000 + }, + "status": "active", + "release_date": "2026-02-05", + "is_latest": true } ], - "updated_at": "2026-01-12T00:00:00.000Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "anthropic.com" } diff --git a/src-tauri/resources/models/providers/azure-cognitive-services.json b/src-tauri/resources/models/providers/azure-cognitive-services.json index d65b85c88..6e9b4f101 100644 --- a/src-tauri/resources/models/providers/azure-cognitive-services.json +++ b/src-tauri/resources/models/providers/azure-cognitive-services.json @@ -245,8 +245,89 @@ "status": "active", "release_date": "2024-01-25", "is_latest": false + }, + { + "id": "kimi-k2.5", + "name": "Kimi K2.5", + "family": "kimi", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0.6, + "output": 3, + "currency": "USD" + }, + "limits": { + "context": 262144, + "max_output": 262144 + }, + "status": "active", + "release_date": "2026-02-06", + "is_latest": true + }, + { + "id": "claude-opus-4-6", + "name": "Claude Opus 4.6", + "family": "claude-opus", + "tier": "max", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25, + "currency": "USD" + }, + "limits": { + "context": 200000, + "max_output": 128000 + }, + "status": "active", + "release_date": "2026-02-05", + "is_latest": true + }, + { + "id": "gpt-5.2-codex", + "name": "GPT-5.2 Codex", + "family": "gpt-codex", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 1.75, + "output": 14, + "cache_read": 0.175, + "currency": "USD" + }, + "limits": { + "context": 400000, + "max_output": 128000 + }, + "status": "active", + "release_date": "2026-01-14", + "is_latest": false } ], - "updated_at": "2026-01-12T00:00:00.000Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "azure.microsoft.com" } diff --git a/src-tauri/resources/models/providers/azure.json b/src-tauri/resources/models/providers/azure.json index ae197b583..fd4f11799 100644 --- a/src-tauri/resources/models/providers/azure.json +++ b/src-tauri/resources/models/providers/azure.json @@ -299,8 +299,89 @@ "status": "active", "release_date": "2024-01-25", "is_latest": false + }, + { + "id": "gpt-5.2-codex", + "name": "GPT-5.2 Codex", + "family": "gpt-codex", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 1.75, + "output": 14, + "cache_read": 0.175, + "currency": "USD" + }, + "limits": { + "context": 400000, + "max_output": 128000 + }, + "status": "active", + "release_date": "2026-01-14", + "is_latest": false + }, + { + "id": "claude-opus-4-6", + "name": "Claude Opus 4.6", + "family": "claude-opus", + "tier": "max", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25, + "currency": "USD" + }, + "limits": { + "context": 200000, + "max_output": 128000 + }, + "status": "active", + "release_date": "2026-02-05", + "is_latest": true + }, + { + "id": "kimi-k2.5", + "name": "Kimi K2.5", + "family": "kimi", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0.6, + "output": 3, + "currency": "USD" + }, + "limits": { + "context": 262144, + "max_output": 262144 + }, + "status": "active", + "release_date": "2026-02-06", + "is_latest": true } ], - "updated_at": "2026-01-12T00:00:00.000Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "azure.microsoft.com" } diff --git a/src-tauri/resources/models/providers/chutes.json b/src-tauri/resources/models/providers/chutes.json index 456b92d3a..9d4246301 100644 --- a/src-tauri/resources/models/providers/chutes.json +++ b/src-tauri/resources/models/providers/chutes.json @@ -186,8 +186,34 @@ "status": "active", "release_date": "2025-08-05", "is_latest": true + }, + { + "id": "moonshotai/Kimi-K2.5-TEE", + "name": "Kimi K2.5 TEE", + "family": "kimi", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0.6, + "output": 3, + "currency": "USD" + }, + "limits": { + "context": 262144, + "max_output": 65535 + }, + "status": "active", + "release_date": "2026-01-27", + "is_latest": true } ], - "updated_at": "2026-01-12T00:00:00.000Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "chutes.ai" } diff --git a/src-tauri/resources/models/providers/cloudflare-ai-gateway.json b/src-tauri/resources/models/providers/cloudflare-ai-gateway.json index a468d3dd7..c32a51a03 100644 --- a/src-tauri/resources/models/providers/cloudflare-ai-gateway.json +++ b/src-tauri/resources/models/providers/cloudflare-ai-gateway.json @@ -245,8 +245,36 @@ "status": "active", "release_date": "2024-11-01", "is_latest": true + }, + { + "id": "anthropic/claude-opus-4-6", + "name": "Claude Opus 4.6 (latest)", + "family": "claude-opus", + "tier": "max", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25, + "currency": "USD" + }, + "limits": { + "context": 1000000, + "max_output": 64000 + }, + "status": "active", + "release_date": "2026-02-05", + "is_latest": true } ], - "updated_at": "2026-01-12T00:00:00.000Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "cloudflare.com" } diff --git a/src-tauri/resources/models/providers/codex.json b/src-tauri/resources/models/providers/codex.json index e45fd6a26..ed207b13b 100644 --- a/src-tauri/resources/models/providers/codex.json +++ b/src-tauri/resources/models/providers/codex.json @@ -32,8 +32,8 @@ "is_latest": true }, { - "id": "gpt-5.1-codex-max", - "name": "GPT-5.1 Codex Max", + "id": "gpt-5.3-codex", + "name": "GPT-5.3 Codex", "family": "gpt-5-codex", "tier": "pro", "capabilities": { @@ -50,18 +50,18 @@ "currency": "USD" }, "limits": { - "context": 400000, + "context": 272000, "max_output": 128000 }, "status": "active", - "release_date": "2025-11-13", - "is_latest": false + "release_date": "2026-02-05", + "is_latest": true }, { - "id": "gpt-5.1-codex-mini", - "name": "GPT-5.1 Codex Mini", - "family": "gpt-5-codex-mini", - "tier": "mini", + "id": "gpt-5.2-codex", + "name": "GPT-5.2 Codex", + "family": "gpt-5-codex", + "tier": "pro", "capabilities": { "vision": true, "tools": true, @@ -76,14 +76,14 @@ "currency": "USD" }, "limits": { - "context": 400000, + "context": 272000, "max_output": 128000 }, "status": "active", - "release_date": "2025-11-13", + "release_date": "2025-12-11", "is_latest": false } ], - "updated_at": "2026-01-13T00:00:00.000Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "codex-cli" -} +} \ No newline at end of file diff --git a/src-tauri/resources/models/providers/deepinfra.json b/src-tauri/resources/models/providers/deepinfra.json index f2ff81199..430f57d98 100644 --- a/src-tauri/resources/models/providers/deepinfra.json +++ b/src-tauri/resources/models/providers/deepinfra.json @@ -239,8 +239,34 @@ "status": "active", "release_date": "2025-07-11", "is_latest": false + }, + { + "id": "moonshotai/Kimi-K2.5", + "name": "Kimi K2.5", + "family": "kimi", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0.5, + "output": 2.8, + "currency": "USD" + }, + "limits": { + "context": 262144, + "max_output": 32768 + }, + "status": "active", + "release_date": "2026-01-27", + "is_latest": true } ], - "updated_at": "2026-01-06T10:24:55.372Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "models.dev" } diff --git a/src-tauri/resources/models/providers/fireworks-ai.json b/src-tauri/resources/models/providers/fireworks-ai.json index e618f774a..fab60259b 100644 --- a/src-tauri/resources/models/providers/fireworks-ai.json +++ b/src-tauri/resources/models/providers/fireworks-ai.json @@ -215,8 +215,35 @@ "status": "active", "release_date": "2025-04-29", "is_latest": false + }, + { + "id": "accounts/fireworks/models/kimi-k2p5", + "name": "Kimi K2.5", + "family": "kimi-thinking", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0.6, + "output": 3, + "cache_read": 0.1, + "currency": "USD" + }, + "limits": { + "context": 256000, + "max_output": 256000 + }, + "status": "active", + "release_date": "2026-01-27", + "is_latest": true } ], - "updated_at": "2026-01-12T00:00:00.000Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "fireworks.ai" } diff --git a/src-tauri/resources/models/providers/github-copilot.json b/src-tauri/resources/models/providers/github-copilot.json index 8ce53c4ff..dbdc3402a 100644 --- a/src-tauri/resources/models/providers/github-copilot.json +++ b/src-tauri/resources/models/providers/github-copilot.json @@ -160,8 +160,60 @@ "status": "active", "release_date": "2025-03-20", "is_latest": true + }, + { + "id": "gpt-5.2-codex", + "name": "GPT-5.2-Codex", + "family": "gpt-codex", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0, + "output": 0, + "currency": "USD" + }, + "limits": { + "context": 272000, + "max_output": 128000 + }, + "status": "active", + "release_date": "2025-12-11", + "is_latest": false + }, + { + "id": "claude-opus-4.6", + "name": "Claude Opus 4.6", + "family": "claude-opus", + "tier": "max", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0, + "output": 0, + "currency": "USD" + }, + "limits": { + "context": 128000, + "max_output": 64000 + }, + "status": "active", + "release_date": "2026-02-05", + "is_latest": true } ], - "updated_at": "2026-01-12T00:00:00.000Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "github.com" } diff --git a/src-tauri/resources/models/providers/google-vertex-anthropic.json b/src-tauri/resources/models/providers/google-vertex-anthropic.json index f73c1f69b..f5da83aab 100644 --- a/src-tauri/resources/models/providers/google-vertex-anthropic.json +++ b/src-tauri/resources/models/providers/google-vertex-anthropic.json @@ -34,64 +34,8 @@ "is_latest": false }, { - "id": "claude-haiku-4-5@20251001", - "name": "Claude Haiku 4.5", - "family": "claude-haiku", - "tier": "mini", - "capabilities": { - "vision": true, - "tools": true, - "streaming": true, - "json_mode": true, - "function_calling": true, - "reasoning": true - }, - "pricing": { - "input": 1, - "output": 5, - "cache_read": 0.1, - "cache_write": 1.25, - "currency": "USD" - }, - "limits": { - "context": 200000, - "max_output": 64000 - }, - "status": "active", - "release_date": "2025-10-15", - "is_latest": false - }, - { - "id": "claude-sonnet-4-5@20250929", - "name": "Claude Sonnet 4.5", - "family": "claude-sonnet", - "tier": "pro", - "capabilities": { - "vision": true, - "tools": true, - "streaming": true, - "json_mode": true, - "function_calling": true, - "reasoning": true - }, - "pricing": { - "input": 3, - "output": 15, - "cache_read": 0.3, - "cache_write": 3.75, - "currency": "USD" - }, - "limits": { - "context": 200000, - "max_output": 64000 - }, - "status": "active", - "release_date": "2025-09-29", - "is_latest": false - }, - { - "id": "claude-opus-4-1@20250805", - "name": "Claude Opus 4.1", + "id": "claude-opus-4-6@default", + "name": "Claude Opus 4.6", "family": "claude-opus", "tier": "max", "capabilities": { @@ -103,161 +47,21 @@ "reasoning": true }, "pricing": { - "input": 15, - "output": 75, - "cache_read": 1.5, - "cache_write": 18.75, + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25, "currency": "USD" }, "limits": { - "context": 200000, - "max_output": 32000 + "context": 1000000, + "max_output": 128000 }, "status": "active", - "release_date": "2025-08-05", - "is_latest": false - }, - { - "id": "claude-sonnet-4@20250514", - "name": "Claude Sonnet 4", - "family": "claude-sonnet", - "tier": "pro", - "capabilities": { - "vision": true, - "tools": true, - "streaming": true, - "json_mode": true, - "function_calling": true, - "reasoning": true - }, - "pricing": { - "input": 3, - "output": 15, - "cache_read": 0.3, - "cache_write": 3.75, - "currency": "USD" - }, - "limits": { - "context": 200000, - "max_output": 64000 - }, - "status": "active", - "release_date": "2025-05-22", - "is_latest": false - }, - { - "id": "claude-opus-4@20250514", - "name": "Claude Opus 4", - "family": "claude-opus", - "tier": "max", - "capabilities": { - "vision": true, - "tools": true, - "streaming": true, - "json_mode": true, - "function_calling": true, - "reasoning": true - }, - "pricing": { - "input": 15, - "output": 75, - "cache_read": 1.5, - "cache_write": 18.75, - "currency": "USD" - }, - "limits": { - "context": 200000, - "max_output": 32000 - }, - "status": "active", - "release_date": "2025-05-22", - "is_latest": false - }, - { - "id": "claude-3-7-sonnet@20250219", - "name": "Claude Sonnet 3.7", - "family": "claude-sonnet", - "tier": "pro", - "capabilities": { - "vision": true, - "tools": true, - "streaming": true, - "json_mode": true, - "function_calling": true, - "reasoning": true - }, - "pricing": { - "input": 3, - "output": 15, - "cache_read": 0.3, - "cache_write": 3.75, - "currency": "USD" - }, - "limits": { - "context": 200000, - "max_output": 64000 - }, - "status": "active", - "release_date": "2025-02-19", - "is_latest": false - }, - { - "id": "claude-3-5-sonnet@20241022", - "name": "Claude Sonnet 3.5 v2", - "family": "claude-sonnet", - "tier": "pro", - "capabilities": { - "vision": true, - "tools": true, - "streaming": true, - "json_mode": true, - "function_calling": true, - "reasoning": false - }, - "pricing": { - "input": 3, - "output": 15, - "cache_read": 0.3, - "cache_write": 3.75, - "currency": "USD" - }, - "limits": { - "context": 200000, - "max_output": 8192 - }, - "status": "active", - "release_date": "2024-10-22", - "is_latest": false - }, - { - "id": "claude-3-5-haiku@20241022", - "name": "Claude Haiku 3.5", - "family": "claude-haiku", - "tier": "mini", - "capabilities": { - "vision": true, - "tools": true, - "streaming": true, - "json_mode": true, - "function_calling": true, - "reasoning": false - }, - "pricing": { - "input": 0.8, - "output": 4, - "cache_read": 0.08, - "cache_write": 1, - "currency": "USD" - }, - "limits": { - "context": 200000, - "max_output": 8192 - }, - "status": "active", - "release_date": "2024-10-22", - "is_latest": false + "release_date": "2026-02-05", + "is_latest": true } ], - "updated_at": "2026-01-06T10:24:55.362Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "models.dev" -} +} \ No newline at end of file diff --git a/src-tauri/resources/models/providers/huggingface.json b/src-tauri/resources/models/providers/huggingface.json index 7e2f1bdd6..7249c0e3f 100644 --- a/src-tauri/resources/models/providers/huggingface.json +++ b/src-tauri/resources/models/providers/huggingface.json @@ -134,8 +134,35 @@ "status": "active", "release_date": "2024-11-01", "is_latest": true + }, + { + "id": "moonshotai/Kimi-K2.5", + "name": "Kimi-K2.5", + "family": "kimi", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0.6, + "output": 3, + "cache_read": 0.1, + "currency": "USD" + }, + "limits": { + "context": 262144, + "max_output": 262144 + }, + "status": "active", + "release_date": "2026-01-01", + "is_latest": true } ], - "updated_at": "2026-01-12T00:00:00.000Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "huggingface.co" } diff --git a/src-tauri/resources/models/providers/kimi-for-coding.json b/src-tauri/resources/models/providers/kimi-for-coding.json index 3a8b5223d..0720bd9a4 100644 --- a/src-tauri/resources/models/providers/kimi-for-coding.json +++ b/src-tauri/resources/models/providers/kimi-for-coding.json @@ -32,8 +32,36 @@ "status": "active", "release_date": "2025-11", "is_latest": false + }, + { + "id": "k2p5", + "name": "Kimi K2.5", + "family": "kimi-thinking", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0, + "currency": "USD" + }, + "limits": { + "context": 262144, + "max_output": 32768 + }, + "status": "active", + "release_date": "2026-01", + "is_latest": true } ], - "updated_at": "2026-01-06T10:24:55.364Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "models.dev" } diff --git a/src-tauri/resources/models/providers/moonshotai-cn.json b/src-tauri/resources/models/providers/moonshotai-cn.json index 6e6b46700..26a16cbdf 100644 --- a/src-tauri/resources/models/providers/moonshotai-cn.json +++ b/src-tauri/resources/models/providers/moonshotai-cn.json @@ -139,8 +139,35 @@ "status": "active", "release_date": "2025-07-14", "is_latest": false + }, + { + "id": "kimi-k2.5", + "name": "Kimi K2.5", + "family": "kimi", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0.6, + "output": 3, + "cache_read": 0.1, + "currency": "USD" + }, + "limits": { + "context": 262144, + "max_output": 262144 + }, + "status": "active", + "release_date": "2026-01", + "is_latest": true } ], - "updated_at": "2026-01-06T10:24:55.353Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "models.dev" } diff --git a/src-tauri/resources/models/providers/moonshotai.json b/src-tauri/resources/models/providers/moonshotai.json index 65b2e4a72..6bb0bafbd 100644 --- a/src-tauri/resources/models/providers/moonshotai.json +++ b/src-tauri/resources/models/providers/moonshotai.json @@ -139,8 +139,35 @@ "status": "active", "release_date": "2025-07-14", "is_latest": false + }, + { + "id": "kimi-k2.5", + "name": "Kimi K2.5", + "family": "kimi", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0.6, + "output": 3, + "cache_read": 0.1, + "currency": "USD" + }, + "limits": { + "context": 262144, + "max_output": 262144 + }, + "status": "active", + "release_date": "2026-01", + "is_latest": true } ], - "updated_at": "2026-01-06T10:24:55.356Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "models.dev" } diff --git a/src-tauri/resources/models/providers/nvidia.json b/src-tauri/resources/models/providers/nvidia.json index e3f0c6955..482846567 100644 --- a/src-tauri/resources/models/providers/nvidia.json +++ b/src-tauri/resources/models/providers/nvidia.json @@ -238,8 +238,34 @@ "status": "active", "release_date": "2024-06-14", "is_latest": false + }, + { + "id": "moonshotai/kimi-k2.5", + "name": "Kimi K2.5", + "family": "kimi", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0, + "output": 0, + "currency": "USD" + }, + "limits": { + "context": 262144, + "max_output": 262144 + }, + "status": "active", + "release_date": "2026-01-27", + "is_latest": true } ], - "updated_at": "2026-01-12T00:00:00.000Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "nvidia.com" } diff --git a/src-tauri/resources/models/providers/ollama-cloud.json b/src-tauri/resources/models/providers/ollama-cloud.json index 5d43bc845..eadfcbb15 100644 --- a/src-tauri/resources/models/providers/ollama-cloud.json +++ b/src-tauri/resources/models/providers/ollama-cloud.json @@ -134,8 +134,34 @@ "status": "active", "release_date": "2024-11-01", "is_latest": true + }, + { + "id": "kimi-k2.5", + "name": "kimi-k2.5", + "family": "kimi", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0, + "output": 0, + "currency": "USD" + }, + "limits": { + "context": 262144, + "max_output": 262144 + }, + "status": "active", + "release_date": "2026-01-27", + "is_latest": true } ], - "updated_at": "2026-01-12T00:00:00.000Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "ollama.com" } diff --git a/src-tauri/resources/models/providers/openai.json b/src-tauri/resources/models/providers/openai.json index fb5af4b1d..474cbb02e 100644 --- a/src-tauri/resources/models/providers/openai.json +++ b/src-tauri/resources/models/providers/openai.json @@ -58,6 +58,60 @@ "release_date": "2025-12-11", "is_latest": true }, + { + "id": "gpt-5.3-codex", + "name": "GPT-5.3 Codex", + "family": "gpt-5-codex", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 1.75, + "output": 14, + "cache_read": 0.175, + "currency": "USD" + }, + "limits": { + "context": 272000, + "max_output": 128000 + }, + "status": "active", + "release_date": "2026-02-05", + "is_latest": true + }, + { + "id": "gpt-5.2-codex", + "name": "GPT-5.2 Codex", + "family": "gpt-5-codex", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 1.75, + "output": 14, + "cache_read": 0.175, + "currency": "USD" + }, + "limits": { + "context": 272000, + "max_output": 128000 + }, + "status": "active", + "release_date": "2025-12-11", + "is_latest": false + }, { "id": "gpt-5.1-codex", "name": "GPT-5.1 Codex", diff --git a/src-tauri/resources/models/providers/opencode.json b/src-tauri/resources/models/providers/opencode.json index e991d886d..6ce95497b 100644 --- a/src-tauri/resources/models/providers/opencode.json +++ b/src-tauri/resources/models/providers/opencode.json @@ -160,8 +160,117 @@ "status": "active", "release_date": "2025-01-20", "is_latest": true + }, + { + "id": "gpt-5.2-codex", + "name": "GPT-5.2 Codex", + "family": "gpt-codex", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 1.75, + "output": 14, + "cache_read": 0.175, + "currency": "USD" + }, + "limits": { + "context": 272000, + "max_output": 128000 + }, + "status": "active", + "release_date": "2026-01-14", + "is_latest": false + }, + { + "id": "claude-opus-4-6", + "name": "Claude Opus 4.6", + "family": "claude-opus", + "tier": "max", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25, + "currency": "USD" + }, + "limits": { + "context": 1000000, + "max_output": 128000 + }, + "status": "active", + "release_date": "2026-02-05", + "is_latest": true + }, + { + "id": "kimi-k2.5", + "name": "Kimi K2.5", + "family": "kimi", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0.6, + "output": 3, + "cache_read": 0.08, + "currency": "USD" + }, + "limits": { + "context": 262144, + "max_output": 262144 + }, + "status": "active", + "release_date": "2026-01-27", + "is_latest": true + }, + { + "id": "kimi-k2.5-free", + "name": "Kimi K2.5 Free", + "family": "kimi-free", + "tier": "mini", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0, + "output": 0, + "cache_read": 0, + "currency": "USD" + }, + "limits": { + "context": 262144, + "max_output": 262144 + }, + "status": "active", + "release_date": "2026-01-27", + "is_latest": true } ], - "updated_at": "2026-01-12T00:00:00.000Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "opencode.ai" } diff --git a/src-tauri/resources/models/providers/openrouter.json b/src-tauri/resources/models/providers/openrouter.json index 6c3749958..f1ed21b5e 100644 --- a/src-tauri/resources/models/providers/openrouter.json +++ b/src-tauri/resources/models/providers/openrouter.json @@ -536,8 +536,90 @@ "status": "active", "release_date": "2024-04-04", "is_latest": false + }, + { + "id": "moonshotai/kimi-k2.5", + "name": "Kimi K2.5", + "family": "kimi", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0.6, + "output": 3, + "cache_read": 0.1, + "currency": "USD" + }, + "limits": { + "context": 262144, + "max_output": 262144 + }, + "status": "active", + "release_date": "2026-01-27", + "is_latest": true + }, + { + "id": "openai/gpt-5.2-codex", + "name": "GPT-5.2-Codex", + "family": "gpt-codex", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 1.75, + "output": 14, + "cache_read": 0.175, + "currency": "USD" + }, + "limits": { + "context": 400000, + "max_output": 128000 + }, + "status": "active", + "release_date": "2026-01-14", + "is_latest": false + }, + { + "id": "anthropic/claude-opus-4.6", + "name": "Claude Opus 4.6", + "family": "claude-opus", + "tier": "max", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25, + "currency": "USD" + }, + "limits": { + "context": 1000000, + "max_output": 128000 + }, + "status": "active", + "release_date": "2026-02-05", + "is_latest": true } ], - "updated_at": "2026-01-12T00:00:00.000Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "openrouter.ai" } diff --git a/src-tauri/resources/models/providers/poe.json b/src-tauri/resources/models/providers/poe.json index 08c162f1b..3a983bc99 100644 --- a/src-tauri/resources/models/providers/poe.json +++ b/src-tauri/resources/models/providers/poe.json @@ -316,8 +316,89 @@ "status": "active", "release_date": "2025-04-05", "is_latest": true + }, + { + "id": "openai/gpt-5.2-codex", + "name": "gpt-5.2-codex", + "family": "gpt-codex", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 1.6, + "output": 13, + "cache_read": 0.16, + "currency": "USD" + }, + "limits": { + "context": 400000, + "max_output": 128000 + }, + "status": "active", + "release_date": "2026-01-14", + "is_latest": false + }, + { + "id": "anthropic/claude-opus-4-6", + "name": "claude-opus-4-6", + "family": "claude-opus", + "tier": "max", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 4.3, + "output": 21, + "cache_read": 0.43, + "cache_write": 5.3, + "currency": "USD" + }, + "limits": { + "context": 983040, + "max_output": 128000 + }, + "status": "active", + "release_date": "2026-02-04", + "is_latest": true + }, + { + "id": "novita/kimi-k2.5", + "name": "kimi-k2.5", + "family": "kimi", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0, + "output": 0, + "currency": "USD" + }, + "limits": { + "context": 256000, + "max_output": 262144 + }, + "status": "active", + "release_date": "2026-01-27", + "is_latest": true } ], - "updated_at": "2026-01-12T00:00:00.000Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "poe.com" } diff --git a/src-tauri/resources/models/providers/siliconflow-cn.json b/src-tauri/resources/models/providers/siliconflow-cn.json index 180565913..99ac17ee2 100644 --- a/src-tauri/resources/models/providers/siliconflow-cn.json +++ b/src-tauri/resources/models/providers/siliconflow-cn.json @@ -109,32 +109,6 @@ "release_date": "2025-10-10", "is_latest": true }, - { - "id": "deepseek-ai/DeepSeek-R1", - "name": "DeepSeek R1", - "family": "deepseek-r1", - "tier": "pro", - "capabilities": { - "vision": false, - "tools": true, - "streaming": true, - "json_mode": true, - "function_calling": true, - "reasoning": true - }, - "pricing": { - "input": 0.5, - "output": 2.18, - "currency": "USD" - }, - "limits": { - "context": 164000, - "max_output": 164000 - }, - "status": "active", - "release_date": "2025-05-28", - "is_latest": false - }, { "id": "Qwen/Qwen3-VL-235B-A22B-Thinking", "name": "Qwen3-VL 235B Thinking", @@ -316,8 +290,34 @@ "status": "active", "release_date": "2025-03-06", "is_latest": false + }, + { + "id": "Pro/moonshotai/Kimi-K2.5", + "name": "Pro/moonshotai/Kimi-K2.5", + "family": "kimi", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0.55, + "output": 3, + "currency": "USD" + }, + "limits": { + "context": 262000, + "max_output": 262000 + }, + "status": "active", + "release_date": "2026-01-27", + "is_latest": true } ], - "updated_at": "2026-01-12T00:00:00.000Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "siliconflow.cn" -} +} \ No newline at end of file diff --git a/src-tauri/resources/models/providers/siliconflow.json b/src-tauri/resources/models/providers/siliconflow.json index 8bf3bb3af..1ad47b468 100644 --- a/src-tauri/resources/models/providers/siliconflow.json +++ b/src-tauri/resources/models/providers/siliconflow.json @@ -5,58 +5,6 @@ "name": "SiliconFlow" }, "models": [ - { - "id": "moonshotai/Kimi-K2-Thinking", - "name": "Kimi K2 Thinking", - "family": "kimi-k2", - "tier": "pro", - "capabilities": { - "vision": false, - "tools": true, - "streaming": true, - "json_mode": true, - "function_calling": true, - "reasoning": true - }, - "pricing": { - "input": 0.55, - "output": 2.5, - "currency": "USD" - }, - "limits": { - "context": 262000, - "max_output": 262000 - }, - "status": "active", - "release_date": "2025-11-07", - "is_latest": true - }, - { - "id": "moonshotai/Kimi-K2-Instruct-0905", - "name": "Kimi K2 Instruct", - "family": "kimi-k2", - "tier": "pro", - "capabilities": { - "vision": false, - "tools": true, - "streaming": true, - "json_mode": true, - "function_calling": true, - "reasoning": false - }, - "pricing": { - "input": 0.4, - "output": 2, - "currency": "USD" - }, - "limits": { - "context": 262000, - "max_output": 262000 - }, - "status": "active", - "release_date": "2025-09-08", - "is_latest": false - }, { "id": "MiniMaxAI/MiniMax-M2", "name": "MiniMax M2", @@ -109,32 +57,6 @@ "release_date": "2025-10-10", "is_latest": true }, - { - "id": "deepseek-ai/DeepSeek-R1", - "name": "DeepSeek R1", - "family": "deepseek-r1", - "tier": "pro", - "capabilities": { - "vision": false, - "tools": true, - "streaming": true, - "json_mode": true, - "function_calling": true, - "reasoning": true - }, - "pricing": { - "input": 0.5, - "output": 2.18, - "currency": "USD" - }, - "limits": { - "context": 164000, - "max_output": 164000 - }, - "status": "active", - "release_date": "2025-05-28", - "is_latest": false - }, { "id": "Qwen/Qwen3-VL-235B-A22B-Thinking", "name": "Qwen3-VL 235B Thinking", @@ -316,8 +238,34 @@ "status": "active", "release_date": "2025-03-06", "is_latest": false + }, + { + "id": "moonshotai/Kimi-K2.5", + "name": "moonshotai/Kimi-K2.5", + "family": "kimi", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0.55, + "output": 3, + "currency": "USD" + }, + "limits": { + "context": 262000, + "max_output": 262000 + }, + "status": "active", + "release_date": "2026-01-27", + "is_latest": true } ], - "updated_at": "2026-01-12T00:00:00.000Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "siliconflow.cn" -} +} \ No newline at end of file diff --git a/src-tauri/resources/models/providers/synthetic.json b/src-tauri/resources/models/providers/synthetic.json index ec054263a..59125e34c 100644 --- a/src-tauri/resources/models/providers/synthetic.json +++ b/src-tauri/resources/models/providers/synthetic.json @@ -134,8 +134,34 @@ "status": "active", "release_date": "2025-12-01", "is_latest": true + }, + { + "id": "hf:moonshotai/Kimi-K2.5", + "name": "Kimi K2.5", + "family": "kimi", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0.55, + "output": 2.19, + "currency": "USD" + }, + "limits": { + "context": 262144, + "max_output": 65536 + }, + "status": "active", + "release_date": "2026-01", + "is_latest": true } ], - "updated_at": "2026-01-12T00:00:00.000Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "synthetic.ai" } diff --git a/src-tauri/resources/models/providers/togetherai.json b/src-tauri/resources/models/providers/togetherai.json index ba4b8bb34..779c7a9b5 100644 --- a/src-tauri/resources/models/providers/togetherai.json +++ b/src-tauri/resources/models/providers/togetherai.json @@ -5,110 +5,6 @@ "name": "Together AI" }, "models": [ - { - "id": "moonshotai/Kimi-K2-Thinking", - "name": "Kimi K2 Thinking", - "family": "kimi-k2", - "tier": "pro", - "capabilities": { - "vision": false, - "tools": true, - "streaming": true, - "json_mode": true, - "function_calling": true, - "reasoning": true - }, - "pricing": { - "input": 1.2, - "output": 4, - "currency": "USD" - }, - "limits": { - "context": 262144, - "max_output": 32768 - }, - "status": "active", - "release_date": "2025-11-06", - "is_latest": true - }, - { - "id": "moonshotai/Kimi-K2-Instruct", - "name": "Kimi K2 Instruct", - "family": "kimi-k2", - "tier": "pro", - "capabilities": { - "vision": false, - "tools": true, - "streaming": true, - "json_mode": true, - "function_calling": true, - "reasoning": false - }, - "pricing": { - "input": 1, - "output": 3, - "currency": "USD" - }, - "limits": { - "context": 131072, - "max_output": 32768 - }, - "status": "active", - "release_date": "2025-07-14", - "is_latest": false - }, - { - "id": "deepseek-ai/DeepSeek-V3-1", - "name": "DeepSeek V3.1", - "family": "deepseek-v3", - "tier": "max", - "capabilities": { - "vision": false, - "tools": true, - "streaming": true, - "json_mode": true, - "function_calling": true, - "reasoning": true - }, - "pricing": { - "input": 0.6, - "output": 1.7, - "currency": "USD" - }, - "limits": { - "context": 131072, - "max_output": 12288 - }, - "status": "active", - "release_date": "2025-08-21", - "is_latest": true - }, - { - "id": "deepseek-ai/DeepSeek-R1", - "name": "DeepSeek R1", - "family": "deepseek-r1", - "tier": "pro", - "capabilities": { - "vision": false, - "tools": false, - "streaming": true, - "json_mode": true, - "function_calling": false, - "reasoning": true - }, - "pricing": { - "input": 3, - "output": 7, - "currency": "USD" - }, - "limits": { - "context": 163839, - "max_output": 12288 - }, - "status": "active", - "release_date": "2024-12-26", - "is_latest": false - }, { "id": "openai/gpt-oss-120b", "name": "GPT OSS 120B", @@ -186,8 +82,34 @@ "status": "active", "release_date": "2024-12-06", "is_latest": false + }, + { + "id": "moonshotai/Kimi-K2.5", + "name": "Kimi K2.5", + "family": "kimi", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0.5, + "output": 2.8, + "currency": "USD" + }, + "limits": { + "context": 262144, + "max_output": 262144 + }, + "status": "active", + "release_date": "2026-01-27", + "is_latest": true } ], - "updated_at": "2026-01-12T00:00:00.000Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "together.ai" -} +} \ No newline at end of file diff --git a/src-tauri/resources/models/providers/venice.json b/src-tauri/resources/models/providers/venice.json index 860634e66..23c92fa25 100644 --- a/src-tauri/resources/models/providers/venice.json +++ b/src-tauri/resources/models/providers/venice.json @@ -57,32 +57,6 @@ "release_date": "2025-04-29", "is_latest": true }, - { - "id": "deepseek-r1", - "name": "DeepSeek R1", - "family": "deepseek-r1", - "tier": "pro", - "capabilities": { - "vision": false, - "tools": true, - "streaming": true, - "json_mode": true, - "function_calling": true, - "reasoning": true - }, - "pricing": { - "input": 0.55, - "output": 2.19, - "currency": "USD" - }, - "limits": { - "context": 163840, - "max_output": 65536 - }, - "status": "active", - "release_date": "2025-01-20", - "is_latest": true - }, { "id": "mistral-large", "name": "Mistral Large", @@ -134,8 +108,36 @@ "status": "active", "release_date": "2024-12-06", "is_latest": false + }, + { + "id": "claude-opus-4-6", + "name": "Claude Opus 4.6", + "family": "claude-opus", + "tier": "max", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 6, + "output": 30, + "cache_read": 0.6, + "cache_write": 7.5, + "currency": "USD" + }, + "limits": { + "context": 1000000, + "max_output": 128000 + }, + "status": "active", + "release_date": "2026-02-05", + "is_latest": true } ], - "updated_at": "2026-01-12T00:00:00.000Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "venice.ai" -} +} \ No newline at end of file diff --git a/src-tauri/resources/models/providers/vercel.json b/src-tauri/resources/models/providers/vercel.json index 45d30dfe0..91451aaaa 100644 --- a/src-tauri/resources/models/providers/vercel.json +++ b/src-tauri/resources/models/providers/vercel.json @@ -193,32 +193,6 @@ "release_date": "2025-09-29", "is_latest": true }, - { - "id": "deepseek/deepseek-r1", - "name": "DeepSeek R1", - "family": "deepseek-r1", - "tier": "pro", - "capabilities": { - "vision": false, - "tools": true, - "streaming": true, - "json_mode": true, - "function_calling": true, - "reasoning": true - }, - "pricing": { - "input": 0.55, - "output": 2.19, - "currency": "USD" - }, - "limits": { - "context": 163840, - "max_output": 65536 - }, - "status": "active", - "release_date": "2025-01-20", - "is_latest": false - }, { "id": "x-ai/grok-4.1-fast", "name": "Grok 4.1 Fast", @@ -323,8 +297,89 @@ "status": "active", "release_date": "2025-04-05", "is_latest": true + }, + { + "id": "moonshotai/kimi-k2.5", + "name": "Kimi K2.5", + "family": "kimi", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0.6, + "output": 1.2, + "currency": "USD" + }, + "limits": { + "context": 262144, + "max_output": 262144 + }, + "status": "active", + "release_date": "2026-01-26", + "is_latest": true + }, + { + "id": "openai/gpt-5.2-codex", + "name": "GPT-5.2-Codex", + "family": "gpt-codex", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 1.75, + "output": 14, + "cache_read": 0.175, + "currency": "USD" + }, + "limits": { + "context": 400000, + "max_output": 128000 + }, + "status": "active", + "release_date": "2025-12", + "is_latest": false + }, + { + "id": "anthropic/claude-opus-4.6", + "name": "Claude Opus 4.6", + "family": "claude-opus", + "tier": "max", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25, + "currency": "USD" + }, + "limits": { + "context": 1000000, + "max_output": 128000 + }, + "status": "active", + "release_date": "2026-02", + "is_latest": true } ], - "updated_at": "2026-01-12T00:00:00.000Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "vercel.com" -} +} \ No newline at end of file diff --git a/src-tauri/resources/models/providers/zenmux.json b/src-tauri/resources/models/providers/zenmux.json index ef3bc5a82..5d775f6b0 100644 --- a/src-tauri/resources/models/providers/zenmux.json +++ b/src-tauri/resources/models/providers/zenmux.json @@ -135,32 +135,6 @@ "release_date": "2025-12-01", "is_latest": true }, - { - "id": "deepseek/deepseek-r1", - "name": "DeepSeek R1", - "family": "deepseek-r1", - "tier": "pro", - "capabilities": { - "vision": false, - "tools": true, - "streaming": true, - "json_mode": true, - "function_calling": true, - "reasoning": true - }, - "pricing": { - "input": 0.55, - "output": 2.19, - "currency": "USD" - }, - "limits": { - "context": 163840, - "max_output": 65536 - }, - "status": "active", - "release_date": "2025-01-20", - "is_latest": true - }, { "id": "z-ai/glm-4.6v-flash-free", "name": "GLM 4.6V Flash (Free)", @@ -185,8 +159,90 @@ "status": "active", "release_date": "2025-12-30", "is_latest": true + }, + { + "id": "moonshotai/kimi-k2.5", + "name": "Kimi K2.5", + "family": "kimi", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0.58, + "output": 3.02, + "cache_read": 0.1, + "currency": "USD" + }, + "limits": { + "context": 262000, + "max_output": 64000 + }, + "status": "active", + "release_date": "2026-01-27", + "is_latest": true + }, + { + "id": "openai/gpt-5.2-codex", + "name": "GPT-5.2-Codex", + "family": "gpt-codex", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 1.75, + "output": 14, + "cache_read": 0.17, + "currency": "USD" + }, + "limits": { + "context": 400000, + "max_output": 64000 + }, + "status": "active", + "release_date": "2026-01-15", + "is_latest": false + }, + { + "id": "anthropic/claude-opus-4.6", + "name": "Claude Opus 4.6", + "family": "claude-opus", + "tier": "max", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25, + "currency": "USD" + }, + "limits": { + "context": 1000000, + "max_output": 128000 + }, + "status": "active", + "release_date": "2026-02-06", + "is_latest": true } ], - "updated_at": "2026-01-12T00:00:00.000Z", + "updated_at": "2026-02-11T00:00:00.000Z", "source": "zenmux.ai" -} +} \ No newline at end of file diff --git a/src-tauri/src/app/bootstrap.rs b/src-tauri/src/app/bootstrap.rs index 4196b67dc..079920ac6 100644 --- a/src-tauri/src/app/bootstrap.rs +++ b/src-tauri/src/app/bootstrap.rs @@ -89,6 +89,11 @@ pub fn init_states(config: &Config) -> Result { // 数据库 let db = database::init_database().map_err(|e| format!("数据库初始化失败: {e}"))?; + // 初始化批量任务表 + if let Err(e) = proxycast_scheduler::BatchTaskDao::init_tables(&db) { + tracing::warn!("[Bootstrap] 批量任务表初始化失败: {}", e); + } + // 服务状态 let skill_service = SkillService::new().map_err(|e| format!("SkillService 初始化失败: {e}"))?; let skill_service_state = SkillServiceState(Arc::new(skill_service)); diff --git a/src-tauri/src/commands/agent_cmd.rs b/src-tauri/src/commands/agent_cmd.rs index 92a230026..23a870286 100644 --- a/src-tauri/src/commands/agent_cmd.rs +++ b/src-tauri/src/commands/agent_cmd.rs @@ -29,6 +29,23 @@ fn truncate_string(s: &str, max_chars: usize) -> String { } } +fn resolve_workspace_id_by_working_dir( + conn: &rusqlite::Connection, + working_dir: Option<&str>, +) -> Option { + let resolved_working_dir = working_dir?.trim(); + if resolved_working_dir.is_empty() { + return None; + } + + conn.query_row( + "SELECT id FROM workspaces WHERE root_path = ? LIMIT 1", + rusqlite::params![resolved_working_dir], + |row| row.get::<_, String>(0), + ) + .ok() +} + /// Agent 进程状态响应 #[derive(Debug, Serialize)] pub struct AgentProcessStatus { @@ -270,6 +287,8 @@ pub struct SessionInfo { pub created_at: String, pub last_activity: String, pub messages_count: usize, + pub workspace_id: Option, + pub working_dir: Option, } /// 获取会话列表 @@ -283,6 +302,9 @@ pub async fn agent_list_sessions(db: State<'_, DbConnection>) -> Result) -> Result|` +//! 例如:`skill_not_found|未找到名为 "writer" 的 Skill` + +pub const SKILL_ERR_CATALOG_UNAVAILABLE: &str = "skill_catalog_unavailable"; +pub const SKILL_ERR_NOT_FOUND: &str = "skill_not_found"; +pub const SKILL_ERR_SESSION_INIT_FAILED: &str = "skill_session_init_failed"; +pub const SKILL_ERR_PROVIDER_UNAVAILABLE: &str = "skill_provider_unavailable"; +pub const SKILL_ERR_STREAM_FAILED: &str = "skill_stream_failed"; +pub const SKILL_ERR_EXECUTE_FAILED: &str = "skill_execute_failed"; + +pub fn format_skill_error(code: &str, message: impl AsRef) -> String { + format!("{code}|{}", message.as_ref()) +} + +pub fn map_find_skill_error(error: String) -> String { + let normalized = error.to_lowercase(); + if normalized.contains("not found") || error.contains("不存在") { + return format_skill_error(SKILL_ERR_NOT_FOUND, error); + } + + format_skill_error( + SKILL_ERR_EXECUTE_FAILED, + format!("加载 Skill 失败: {error}"), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_skill_error() { + let result = format_skill_error(SKILL_ERR_NOT_FOUND, "foo"); + assert_eq!(result, "skill_not_found|foo"); + } + + #[test] + fn test_map_find_skill_error_not_found() { + let result = map_find_skill_error("Skill not found: demo".to_string()); + assert!(result.starts_with("skill_not_found|")); + } + + #[test] + fn test_map_find_skill_error_generic() { + let result = map_find_skill_error("io failure".to_string()); + assert!(result.starts_with("skill_execute_failed|")); + } +} diff --git a/src-tauri/src/commands/skill_exec_cmd.rs b/src-tauri/src/commands/skill_exec_cmd.rs index 35a20e4eb..f81d2647a 100644 --- a/src-tauri/src/commands/skill_exec_cmd.rs +++ b/src-tauri/src/commands/skill_exec_cmd.rs @@ -24,6 +24,11 @@ use aster::conversation::message::Message; use crate::agent::aster_state::SessionConfigBuilder; use crate::agent::{AsterAgentState, TauriAgentEvent}; +use crate::commands::skill_error::{ + format_skill_error, map_find_skill_error, SKILL_ERR_CATALOG_UNAVAILABLE, + SKILL_ERR_EXECUTE_FAILED, SKILL_ERR_PROVIDER_UNAVAILABLE, SKILL_ERR_SESSION_INIT_FAILED, + SKILL_ERR_STREAM_FAILED, +}; use crate::database::DbConnection; use crate::skills::TauriExecutionCallback; use proxycast_agent::event_converter::convert_agent_event; @@ -169,11 +174,14 @@ pub async fn execute_skill( ); // 1. 从 registry 加载 skill(Requirements 3.2) - let skill = find_skill_by_name(&skill_name)?; + let skill = find_skill_by_name(&skill_name).map_err(map_find_skill_error)?; // 检查是否禁用了模型调用 if skill.disable_model_invocation { - return Err(format!("Skill '{}' 已禁用模型调用,无法执行", skill_name)); + return Err(format_skill_error( + SKILL_ERR_EXECUTE_FAILED, + format!("Skill '{}' 已禁用模型调用,无法执行", skill_name), + )); } // 2. 创建 TauriExecutionCallback @@ -182,7 +190,12 @@ pub async fn execute_skill( // 3. 初始化 Agent(如果未初始化) if !aster_state.is_initialized().await { tracing::info!("[execute_skill] Agent 未初始化,开始初始化..."); - aster_state.init_agent_with_db(&db).await?; + aster_state.init_agent_with_db(&db).await.map_err(|e| { + format_skill_error( + SKILL_ERR_SESSION_INIT_FAILED, + format!("初始化 Agent 失败: {e}"), + ) + })?; tracing::info!("[execute_skill] Agent 初始化完成"); } @@ -239,7 +252,12 @@ pub async fn execute_skill( } configure_result.map_err(|e| { - format!("无法配置任何可用的 Provider(需要支持工具调用的 Provider,如 Anthropic、OpenAI 或 Google): {e}") + format_skill_error( + SKILL_ERR_PROVIDER_UNAVAILABLE, + format!( + "无法配置任何可用的 Provider(需要支持工具调用的 Provider,如 Anthropic、OpenAI 或 Google): {e}" + ), + ) })?; tracing::info!( @@ -248,36 +266,69 @@ pub async fn execute_skill( preferred_model ); - // 5. 发送步骤开始事件 + // 5. 根据 execution_mode 分支执行 + if skill.execution_mode == "workflow" && !skill.workflow_steps.is_empty() { + // ========== Workflow 模式:按步骤顺序执行 ========== + execute_skill_workflow( + &app_handle, + &aster_state, + &skill, + &user_input, + &execution_id, + &session_id, + &callback, + ) + .await + } else { + // ========== Prompt 模式:单次执行 ========== + execute_skill_prompt( + &app_handle, + &aster_state, + &skill, + &user_input, + &execution_id, + &session_id, + &callback, + ) + .await + } +} + +/// Prompt 模式执行(单步) +async fn execute_skill_prompt( + app_handle: &tauri::AppHandle, + aster_state: &AsterAgentState, + skill: &proxycast_skills::LoadedSkillDefinition, + user_input: &str, + execution_id: &str, + session_id: &str, + callback: &TauriExecutionCallback, +) -> Result { + // 发送步骤开始事件 callback.on_step_start("main", &skill.display_name, 1, 1); - // 6. 构建 SessionConfig,将 skill 内容作为 system_prompt - let session_config = SessionConfigBuilder::new(&session_id) + // 构建 SessionConfig + let session_config = SessionConfigBuilder::new(session_id) .system_prompt(&skill.markdown_content) .build(); - // 7. 创建用户消息 - let user_message = Message::user().with_text(&user_input); + let user_message = Message::user().with_text(user_input); - // 8. 获取 Agent 并执行 + // 获取 Agent 并执行 let agent_arc = aster_state.get_agent_arc(); let guard = agent_arc.read().await; - let agent = guard.as_ref().ok_or("Agent not initialized")?; + let agent = guard.as_ref().ok_or_else(|| { + format_skill_error(SKILL_ERR_SESSION_INIT_FAILED, "Agent not initialized") + })?; - // 创建取消令牌 - let cancel_token = aster_state.create_cancel_token(&session_id).await; - - // 获取事件流 + let cancel_token = aster_state.create_cancel_token(session_id).await; let stream_result = agent .reply(user_message, session_config, Some(cancel_token.clone())) .await; - // 9. 处理流式事件并收集结果 let mut final_output = String::new(); let mut has_error = false; let mut error_message: Option = None; - - // 用于发送流式事件的 event_name let event_name = format!("skill-exec-{}", execution_id); match stream_result { @@ -285,16 +336,11 @@ pub async fn execute_skill( while let Some(event_result) = stream.next().await { match event_result { Ok(agent_event) => { - // 转换 Aster 事件为 Tauri 事件 let tauri_events = convert_agent_event(agent_event); - for tauri_event in tauri_events { - // 收集文本输出 if let TauriAgentEvent::TextDelta { ref text } = tauri_event { final_output.push_str(text); } - - // 发送事件到前端 if let Err(e) = app_handle.emit(&event_name, &tauri_event) { tracing::error!("[execute_skill] 发送事件失败: {}", e); } @@ -302,13 +348,15 @@ pub async fn execute_skill( } Err(e) => { has_error = true; - error_message = Some(format!("Stream error: {e}")); + error_message = Some(format_skill_error( + SKILL_ERR_STREAM_FAILED, + format!("Stream error: {e}"), + )); tracing::error!("[execute_skill] 流处理错误: {}", e); } } } - // 发送完成事件 let done_event = TauriAgentEvent::FinalDone { usage: None }; if let Err(e) = app_handle.emit(&event_name, &done_event) { tracing::error!("[execute_skill] 发送完成事件失败: {}", e); @@ -316,33 +364,29 @@ pub async fn execute_skill( } Err(e) => { has_error = true; - error_message = Some(format!("Agent error: {e}")); + error_message = Some(format_skill_error( + SKILL_ERR_STREAM_FAILED, + format!("Agent error: {e}"), + )); tracing::error!("[execute_skill] Agent 错误: {}", e); } } - // 清理取消令牌 - aster_state.remove_cancel_token(&session_id).await; + aster_state.remove_cancel_token(session_id).await; - // 10. 返回执行结果(Requirements 3.5) if has_error { - let err_msg = error_message.unwrap_or_else(|| "Unknown error".to_string()); + let err_msg = error_message + .unwrap_or_else(|| format_skill_error(SKILL_ERR_EXECUTE_FAILED, "Unknown error")); callback.on_step_error("main", &err_msg, false); callback.on_complete(false, None, Some(&err_msg)); - tracing::error!( - "[execute_skill] Skill 执行失败: name={}, error={}", - skill_name, - err_msg - ); - Ok(SkillExecutionResult { success: false, output: None, error: Some(err_msg.clone()), steps_completed: vec![StepResult { step_id: "main".to_string(), - step_name: skill.display_name, + step_name: skill.display_name.clone(), success: false, output: None, error: Some(err_msg), @@ -352,19 +396,13 @@ pub async fn execute_skill( callback.on_step_complete("main", &final_output); callback.on_complete(true, Some(&final_output), None); - tracing::info!( - "[execute_skill] Skill 执行成功: name={}, output_len={}", - skill_name, - final_output.len() - ); - Ok(SkillExecutionResult { success: true, output: Some(final_output.clone()), error: None, steps_completed: vec![StepResult { step_id: "main".to_string(), - step_name: skill.display_name, + step_name: skill.display_name.clone(), success: true, output: Some(final_output), error: None, @@ -373,6 +411,183 @@ pub async fn execute_skill( } } +/// Workflow 模式执行(多步骤顺序执行) +async fn execute_skill_workflow( + app_handle: &tauri::AppHandle, + aster_state: &AsterAgentState, + skill: &proxycast_skills::LoadedSkillDefinition, + user_input: &str, + execution_id: &str, + session_id: &str, + callback: &TauriExecutionCallback, +) -> Result { + let steps = &skill.workflow_steps; + let total_steps = steps.len(); + let event_name = format!("skill-exec-{}", execution_id); + let mut steps_completed = Vec::new(); + let mut accumulated_context = user_input.to_string(); + let mut final_output = String::new(); + + tracing::info!( + "[execute_skill_workflow] 开始 workflow 执行: steps={}, skill={}", + total_steps, + skill.skill_name + ); + + for (idx, step) in steps.iter().enumerate() { + let step_num = idx + 1; + + // 发送步骤开始事件 + callback.on_step_start(&step.id, &step.name, step_num, total_steps); + + tracing::info!( + "[execute_skill_workflow] 执行步骤 {}/{}: id={}, name={}", + step_num, + total_steps, + step.id, + step.name + ); + + // 构建该步骤的 system_prompt:基础 skill prompt + 步骤 prompt + let step_system_prompt = format!( + "{}\n\n---\n\n## 当前步骤: {} ({}/{})\n\n{}", + skill.markdown_content, step.name, step_num, total_steps, step.prompt + ); + + let step_session_id = format!("{}-step-{}", session_id, step.id); + let session_config = SessionConfigBuilder::new(&step_session_id) + .system_prompt(&step_system_prompt) + .build(); + + // 用户消息 = 原始输入 + 前序步骤的累积上下文 + let step_input = if idx == 0 { + accumulated_context.clone() + } else { + format!( + "原始需求:{}\n\n前序步骤输出:\n{}", + user_input, accumulated_context + ) + }; + + let user_message = Message::user().with_text(&step_input); + + // 获取 Agent 并执行 + let agent_arc = aster_state.get_agent_arc(); + let guard = agent_arc.read().await; + let agent = guard.as_ref().ok_or_else(|| { + format_skill_error(SKILL_ERR_SESSION_INIT_FAILED, "Agent not initialized") + })?; + + let cancel_token = aster_state.create_cancel_token(&step_session_id).await; + let stream_result = agent + .reply(user_message, session_config, Some(cancel_token.clone())) + .await; + + let mut step_output = String::new(); + let mut step_error: Option = None; + + match stream_result { + Ok(mut stream) => { + while let Some(event_result) = stream.next().await { + match event_result { + Ok(agent_event) => { + let tauri_events = convert_agent_event(agent_event); + for tauri_event in tauri_events { + if let TauriAgentEvent::TextDelta { ref text } = tauri_event { + step_output.push_str(text); + } + if let Err(e) = app_handle.emit(&event_name, &tauri_event) { + tracing::error!("[execute_skill_workflow] 发送事件失败: {}", e); + } + } + } + Err(e) => { + step_error = Some(format!("Stream error: {e}")); + tracing::error!( + "[execute_skill_workflow] 步骤 {} 流处理错误: {}", + step.id, + e + ); + break; + } + } + } + } + Err(e) => { + step_error = Some(format!("Agent error: {e}")); + tracing::error!( + "[execute_skill_workflow] 步骤 {} Agent 错误: {}", + step.id, + e + ); + } + } + + aster_state.remove_cancel_token(&step_session_id).await; + + if let Some(err) = &step_error { + callback.on_step_error(&step.id, err, false); + steps_completed.push(StepResult { + step_id: step.id.clone(), + step_name: step.name.clone(), + success: false, + output: None, + error: Some(err.clone()), + }); + + // 步骤失败,终止 workflow + let err_msg = format_skill_error( + SKILL_ERR_EXECUTE_FAILED, + format!("步骤 '{}' 执行失败: {}", step.name, err), + ); + callback.on_complete(false, None, Some(&err_msg)); + + let done_event = TauriAgentEvent::FinalDone { usage: None }; + let _ = app_handle.emit(&event_name, &done_event); + + return Ok(SkillExecutionResult { + success: false, + output: None, + error: Some(err_msg), + steps_completed, + }); + } + + // 步骤成功 + callback.on_step_complete(&step.id, &step_output); + steps_completed.push(StepResult { + step_id: step.id.clone(), + step_name: step.name.clone(), + success: true, + output: Some(step_output.clone()), + error: None, + }); + + // 累积上下文供下一步使用 + accumulated_context = step_output.clone(); + final_output = step_output; + } + + // 所有步骤完成 + callback.on_complete(true, Some(&final_output), None); + + let done_event = TauriAgentEvent::FinalDone { usage: None }; + let _ = app_handle.emit(&event_name, &done_event); + + tracing::info!( + "[execute_skill_workflow] Workflow 执行完成: skill={}, steps_completed={}", + skill.skill_name, + steps_completed.len() + ); + + Ok(SkillExecutionResult { + success: true, + output: Some(final_output), + error: None, + steps_completed, + }) +} + /// 列出可执行的 Skills /// /// 返回所有可以执行的 Skills 列表,过滤掉 disable_model_invocation=true 的 Skills。 @@ -388,8 +603,8 @@ pub async fn execute_skill( /// - 4.4: 过滤 disable_model_invocation=true 的 skills #[tauri::command] pub async fn list_executable_skills() -> Result, String> { - let skills_dir = - get_proxycast_skills_dir().ok_or_else(|| "无法获取 Skills 目录".to_string())?; + let skills_dir = get_proxycast_skills_dir() + .ok_or_else(|| format_skill_error(SKILL_ERR_CATALOG_UNAVAILABLE, "无法获取 Skills 目录"))?; // 加载所有 skills let all_skills = load_skills_from_directory(&skills_dir); @@ -437,7 +652,7 @@ pub async fn list_executable_skills() -> Result, String #[tauri::command] pub async fn get_skill_detail(skill_name: String) -> Result { // 查找 skill(Requirements 5.1, 5.4) - let skill = find_skill_by_name(&skill_name)?; + let skill = find_skill_by_name(&skill_name).map_err(map_find_skill_error)?; // 转换为 SkillDetailInfo(Requirements 5.2, 5.3) let detail = SkillDetailInfo { @@ -452,7 +667,21 @@ pub async fn get_skill_detail(skill_name: String) -> Result ); @@ -375,6 +377,17 @@ function AppContent() { +
+ +
+
currentPage === "agent", }, { id: "image-gen", label: "绘画", icon: Image, page: "image-gen" }, + { id: "batch", label: "批量任务", icon: Layers, page: "batch" }, { id: "plugins", label: "插件中心", icon: Compass, page: "plugins" }, ]; @@ -522,11 +528,12 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) { const params: PageParams | undefined = item.id === "home-general" - ? ({ - ...(item.params as AgentPageParams | undefined), - newChatAt: Date.now(), - } as AgentPageParams) - : item.params; + ? buildHomeAgentParams(item.params as AgentPageParams | undefined) + : isThemeWorkspacePage(item.page) + ? buildWorkspaceResetParams( + item.params as AgentPageParams | undefined, + ) + : item.params; onNavigate(item.page, params); }; @@ -534,15 +541,7 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) { return ( - - onNavigate("agent", { - theme: "general", - lockTheme: false, - newChatAt: Date.now(), - }) - } - > + onNavigate("agent", buildHomeAgentParams())}> ProxyCast @@ -551,13 +550,7 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) { - onNavigate("agent", { - theme: "general", - lockTheme: false, - newChatAt: Date.now(), - }) - } + onClick={() => onNavigate("agent", buildHomeAgentParams())} > 搜索 diff --git a/src/components/agent/chat/components/ChatNavbar.tsx b/src/components/agent/chat/components/ChatNavbar.tsx index 0cf76aaa5..ebef14ba5 100644 --- a/src/components/agent/chat/components/ChatNavbar.tsx +++ b/src/components/agent/chat/components/ChatNavbar.tsx @@ -7,6 +7,7 @@ import { Navbar } from "../styles"; interface ChatNavbarProps { isRunning: boolean; onToggleHistory: () => void; + showHistoryToggle?: boolean; onToggleFullscreen: () => void; onToggleSettings?: () => void; onBackHome?: () => void; @@ -18,6 +19,7 @@ interface ChatNavbarProps { export const ChatNavbar: React.FC = ({ isRunning: _isRunning, onToggleHistory, + showHistoryToggle = true, onToggleFullscreen: _onToggleFullscreen, onToggleSettings, onBackHome, @@ -39,14 +41,16 @@ export const ChatNavbar: React.FC = ({ )} - + {showHistoryToggle && ( + + )}
diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarTools.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarTools.tsx index 445e36a29..582ecedd0 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarTools.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarTools.tsx @@ -1,15 +1,6 @@ import React from "react"; -import { - Paperclip, - Lightbulb, - Globe, - Zap, - Brush, - MessageSquareDiff, - Maximize2, - PanelRight, -} from "lucide-react"; -import { ToolButton, Divider } from "../styles"; +import { Paperclip, Lightbulb, Globe, MessageSquareDiff } from "lucide-react"; +import { ToolButton } from "../styles"; import { Tooltip, TooltipContent, @@ -20,14 +11,13 @@ import { interface InputbarToolsProps { onToolClick?: (tool: string) => void; activeTools?: Record; - /** 画布是否打开 */ + /** 画布是否打开(兼容保留,不再展示画布图标) */ isCanvasOpen?: boolean; } export const InputbarTools: React.FC = ({ onToolClick, activeTools = {}, - isCanvasOpen = false, }) => { return ( @@ -81,49 +71,6 @@ export const InputbarTools: React.FC = ({ 联网搜索 {activeTools["web_search"] ? "(已开启)" : ""} - - - - - - onToolClick?.("quick_action")}> - - - - 快捷指令 - - - - - onToolClick?.("fullscreen")}> - - - - 全屏编辑 - - - - - onToolClick?.("canvas")} - className={isCanvasOpen ? "active" : ""} - > - - - - - {isCanvasOpen ? "关闭画布" : "打开画布"} - - - - - - onToolClick?.("clear")}> - - - - 清除输入 -
); diff --git a/src/components/agent/chat/components/Inputbar/index.tsx b/src/components/agent/chat/components/Inputbar/index.tsx index b0171be41..417f328a9 100644 --- a/src/components/agent/chat/components/Inputbar/index.tsx +++ b/src/components/agent/chat/components/Inputbar/index.tsx @@ -14,10 +14,10 @@ import { ChatModelSelector } from "../ChatModelSelector"; const TaskFilesArea = styled.div` display: flex; justify-content: flex-end; - padding: 0 18px 8px 18px; + padding: 0 8px 8px 8px; width: 100%; - max-width: 900px; - margin: 0 auto; + max-width: none; + margin: 0; `; // 按钮和面板的包装容器 @@ -163,8 +163,6 @@ export const Inputbar: React.FC = ({ fileInputRef.current?.click(); break; case "quick_action": - toast.info("快捷指令功能开发中..."); - break; case "translate": toast.info("翻译功能开发中..."); break; diff --git a/src/components/agent/chat/components/Inputbar/styles.ts b/src/components/agent/chat/components/Inputbar/styles.ts index 6eea5ee2d..a7ea52636 100644 --- a/src/components/agent/chat/components/Inputbar/styles.ts +++ b/src/components/agent/chat/components/Inputbar/styles.ts @@ -27,10 +27,10 @@ export const Container = styled.div` flex-direction: column; position: relative; z-index: 2; - padding: 0 18px 18px 18px; /* Cherry Studio Exact: 0 18px 18px 18px */ + padding: 0 8px 12px 8px; width: 100%; - max-width: 900px; - margin: 0 auto; + max-width: none; + margin: 0; `; export const InputBarContainer = styled.div` @@ -102,6 +102,7 @@ export const BottomBar = styled.div` position: relative; z-index: 2; flex-shrink: 0; + min-width: 0; `; // ... (LeftSection and RightSection seem fine without vars, skipping for brevity of replace block if possible but might as well include to be safe or target specific chunks) @@ -112,13 +113,26 @@ export const LeftSection = styled.div` align-items: center; flex: 1; min-width: 0; - /* Cherry Studio uses ToolWrapper with margin-right: 6px, handled in component or here */ + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } + + > * { + flex-shrink: 0; + } `; export const RightSection = styled.div` display: flex; align-items: center; gap: 6px; /* Cherry Studio Exact: 6px */ + flex-shrink: 0; + margin-left: 4px; `; // --- InputbarTools Styles --- diff --git a/src/components/agent/chat/hooks/skillCommand.ts b/src/components/agent/chat/hooks/skillCommand.ts index 779facaec..c359cc282 100644 --- a/src/components/agent/chat/hooks/skillCommand.ts +++ b/src/components/agent/chat/hooks/skillCommand.ts @@ -7,6 +7,10 @@ import { type ExecutableSkillInfo, } from "@/lib/api/skill-execution"; import type { ActionRequired, Message } from "../types"; +import { + formatSkillFailureMessage, + resolveSkillFailure, +} from "../utils/skillFailure"; /** 解析 /skill-name args 命令 */ export interface ParsedSkillCommand { @@ -26,6 +30,8 @@ export interface SlashSkillExecutionContext { setIsSending: (value: boolean) => void; setCurrentAssistantMsgId: (id: string | null) => void; setStreamUnlisten: (unlisten: UnlistenFn | null) => void; + setActiveSessionIdForStop: (sessionId: string | null) => void; + isExecutionCancelled: () => boolean; playTypewriterSound: () => void; playToolcallSound: () => void; onWriteFile?: (content: string, fileName: string) => void; @@ -176,15 +182,26 @@ function tryHandleToolWriteFile( } } +interface MatchedSkillResult { + matchedSkill: ExecutableSkillInfo | null; + catalogLoadFailed: boolean; +} + async function findMatchedSkill( skillName: string, -): Promise { +): Promise { try { const skills = await skillExecutionApi.listExecutableSkills(); - return skills.find((skill) => skill.name === skillName) || null; + return { + matchedSkill: skills.find((skill) => skill.name === skillName) || null, + catalogLoadFailed: false, + }; } catch (error) { - console.warn("[SkillCommand] 获取可执行 Skills 失败,回退普通对话:", error); - return null; + console.warn("[SkillCommand] 获取可执行 Skills 失败:", error); + return { + matchedSkill: null, + catalogLoadFailed: true, + }; } } @@ -207,38 +224,71 @@ export async function tryExecuteSlashSkillCommand( setIsSending, setCurrentAssistantMsgId, setStreamUnlisten, + setActiveSessionIdForStop, + isExecutionCancelled, playTypewriterSound, playToolcallSound, onWriteFile, } = ctx; - const matchedSkill = await findMatchedSkill(command.skillName); + const { matchedSkill, catalogLoadFailed } = await findMatchedSkill( + command.skillName, + ); if (!matchedSkill) { - return false; - } + const failure = resolveSkillFailure( + catalogLoadFailed + ? "skill_catalog_unavailable|无法加载可执行 Skills 列表" + : `skill_not_found|未找到名为 "${command.skillName}" 的 Skill`, + ); + const failureText = formatSkillFailureMessage(failure); - const activeSessionId = await ensureSession(); - if (!activeSessionId) { setMessages((prev) => prev.map((msg) => msg.id === assistantMsgId ? { ...msg, - content: "Skill 执行失败:无法创建会话", + content: failureText, isThinking: false, thinkingContent: undefined, - contentParts: [ - { type: "text" as const, text: "Skill 执行失败:无法创建会话" }, - ], + contentParts: [{ type: "text" as const, text: failureText }], } : msg, ), ); setIsSending(false); setCurrentAssistantMsgId(null); + setActiveSessionIdForStop(null); return true; } + const activeSessionId = await ensureSession(); + if (!activeSessionId) { + const failure = resolveSkillFailure( + "skill_session_init_failed|无法创建或恢复 Skill 会话", + ); + const failureText = formatSkillFailureMessage(failure); + + setMessages((prev) => + prev.map((msg) => + msg.id === assistantMsgId + ? { + ...msg, + content: failureText, + isThinking: false, + thinkingContent: undefined, + contentParts: [{ type: "text" as const, text: failureText }], + } + : msg, + ), + ); + setIsSending(false); + setCurrentAssistantMsgId(null); + setActiveSessionIdForStop(null); + return true; + } + + setActiveSessionIdForStop(activeSessionId); + setMessages((prev) => prev.map((msg) => msg.id === assistantMsgId @@ -265,20 +315,47 @@ export async function tryExecuteSlashSkillCommand( let accumulatedContent = ""; let skillUnlisten: UnlistenFn | null = null; + let stepUnlisteners: UnlistenFn[] = []; const cleanup = () => { if (skillUnlisten) { skillUnlisten(); skillUnlisten = null; } + for (const ul of stepUnlisteners) { + ul(); + } + stepUnlisteners = []; setStreamUnlisten(null); setIsSending(false); setCurrentAssistantMsgId(null); + setActiveSessionIdForStop(null); }; try { const eventName = `skill-exec-${assistantMsgId}`; + // 监听 workflow 步骤事件 + const stepStartUl = await safeListen<{ + execution_id: string; + step_id: string; + step_name: string; + current_step: number; + total_steps: number; + }>("skill:step_start", ({ payload }) => { + if (payload.execution_id !== assistantMsgId) return; + // 注入步骤分隔标记 + const marker = + payload.total_steps > 1 + ? `\n\n---\n**步骤 ${payload.current_step}/${payload.total_steps}: ${payload.step_name}**\n\n` + : ""; + if (marker) { + accumulatedContent += marker; + setMessages((prev) => appendTextPart(prev, assistantMsgId, marker)); + } + }); + stepUnlisteners.push(stepStartUl); + skillUnlisten = await safeListen(eventName, ({ payload }) => { const streamEvent = parseStreamEvent(payload as unknown); if (!streamEvent) return; @@ -477,10 +554,38 @@ export async function tryExecuteSlashSkillCommand( `[SkillCommand] 执行完成: name=${command.skillName}, success=${result.success}, output_len=${result.output?.length ?? 0}, stream_stats=${JSON.stringify(streamCounters)}`, ); + if (isExecutionCancelled()) { + console.info( + `[SkillCommand] 执行结果已忽略(用户已取消): ${command.skillName}`, + ); + cleanup(); + return true; + } + const hasStreamedContent = accumulatedContent.trim().length > 0; - const finalContent = hasStreamedContent - ? accumulatedContent - : result.output || result.error || "Skill 执行完成"; + const failure = !result.success + ? resolveSkillFailure( + result.error || "skill_execute_failed|Skill 执行返回失败", + ) + : null; + + const failureText = failure ? formatSkillFailureMessage(failure) : ""; + + const finalContent = failure + ? hasStreamedContent + ? `${accumulatedContent} + +${failureText}` + : failureText + : hasStreamedContent + ? accumulatedContent + : result.output || "Skill 执行完成"; + + if (failure) { + console.warn( + `[SkillCommand] 执行完成但返回失败: name=${command.skillName}, code=${failure.code}, message=${failure.message}`, + ); + } setMessages((prev) => prev.map((msg) => { @@ -504,7 +609,21 @@ export async function tryExecuteSlashSkillCommand( cleanup(); return true; } catch (error) { - console.error(`[SkillCommand] 执行失败: ${command.skillName}`, error); + if (isExecutionCancelled()) { + console.info( + `[SkillCommand] 执行异常已忽略(用户已取消): ${command.skillName}`, + ); + cleanup(); + return true; + } + + const failure = resolveSkillFailure(error); + const failureText = formatSkillFailureMessage(failure); + + console.error( + `[SkillCommand] 执行失败: ${command.skillName}, code=${failure.code}`, + error, + ); setMessages((prev) => prev.map((msg) => @@ -513,11 +632,11 @@ export async function tryExecuteSlashSkillCommand( ...msg, isThinking: false, thinkingContent: undefined, - content: `Skill 执行失败: ${error instanceof Error ? error.message : String(error)}`, + content: failureText, contentParts: [ { type: "text", - text: `Skill 执行失败: ${error instanceof Error ? error.message : String(error)}`, + text: failureText, }, ], } diff --git a/src/components/agent/chat/hooks/useAgentChat.ts b/src/components/agent/chat/hooks/useAgentChat.ts index 249a16b5f..1449412dd 100644 --- a/src/components/agent/chat/hooks/useAgentChat.ts +++ b/src/components/agent/chat/hooks/useAgentChat.ts @@ -15,6 +15,7 @@ import { generateAgentTitle, parseStreamEvent, sendPermissionResponse, + stopAsterSession, type AgentProcessStatus, type SessionInfo, type StreamEvent, @@ -36,6 +37,10 @@ import { parseSkillSlashCommand, tryExecuteSlashSkillCommand, } from "./skillCommand"; +import { + isValidSessionId, + resolveRestorableSessionId, +} from "../utils/sessionRecovery"; /** 话题(会话)信息 */ export interface Topic { @@ -264,6 +269,8 @@ export function useAgentChat(options: UseAgentChatOptions) { const unlistenRef = useRef(null); // 用于保存当前正在处理的消息 ID const currentAssistantMsgIdRef = useRef(null); + // 当前流式请求对应的会话 ID(用于 stop 时通知后端取消) + const currentStreamingSessionIdRef = useRef(null); // 自动恢复/水合状态跟踪 const restoredWorkspaceRef = useRef(null); const hydratedSessionRef = useRef(null); @@ -365,6 +372,7 @@ export function useAgentChat(options: UseAgentChatOptions) { if (!workspaceId?.trim()) { setSessionId(null); setMessages([]); + currentStreamingSessionIdRef.current = null; _setRoundCount(0); setA2uiFormDataMap({}); restoredWorkspaceRef.current = null; @@ -395,7 +403,35 @@ export function useAgentChat(options: UseAgentChatOptions) { const loadTopics = async () => { try { const sessions = await listAgentSessions(); - const topicList: Topic[] = sessions.map((s: SessionInfo) => ({ + const resolvedWorkspaceId = workspaceId?.trim(); + + const validSessions = sessions.filter((s) => + isValidSessionId(s.session_id), + ); + + for (const session of validSessions) { + if (session.workspace_id) { + savePersisted( + `agent_session_workspace_${session.session_id}`, + session.workspace_id, + ); + } + } + + const filteredSessions = resolvedWorkspaceId + ? validSessions.filter((session) => { + const mappedWorkspaceId = + session.workspace_id || + loadPersisted( + `agent_session_workspace_${session.session_id}`, + null, + ); + + return mappedWorkspaceId === resolvedWorkspaceId; + }) + : validSessions; + + const topicList: Topic[] = filteredSessions.map((s: SessionInfo) => ({ id: s.session_id, title: s.title || generateTopicTitle(s), createdAt: new Date(s.created_at), @@ -515,13 +551,7 @@ export function useAgentChat(options: UseAgentChatOptions) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId]); - // Ensure an active session exists (internal helper) - const _ensureSession = async (): Promise => { - // If we already have a session, we might want to continue using it. - // However, check if we need to "re-initialize" if critical params changed? - // User said: "选择模型后,不用和会话绑定". So we keep the session ID if it exists. - if (sessionId) return sessionId; - + const createFreshSession = async (): Promise => { try { // TEMPORARY FIX: Disable skills integration due to API type mismatch (Backend expects []SystemMessage, Client sends String) // const [claudeSkills, proxyCastSkills] = await Promise.all([ @@ -565,6 +595,104 @@ export function useAgentChat(options: UseAgentChatOptions) { } }; + // Ensure an active session exists (internal helper) + const _ensureSession = async (): Promise => { + // If we already have a session, we might want to continue using it. + // However, check if we need to "re-initialize" if critical params changed? + // User said: "选择模型后,不用和会话绑定". So we keep the session ID if it exists. + if (sessionId) return sessionId; + return createFreshSession(); + }; + + const isSessionWorkspaceMismatchError = (error: unknown): boolean => { + const errorMessage = `${error}`; + return ( + errorMessage.includes("workspace_mismatch|") || + errorMessage.includes("会话工作目录与 workspace 不匹配") + ); + }; + + const resetBrokenSessionBinding = (staleSessionId: string | null) => { + const resolvedWorkspaceId = workspaceId?.trim(); + + sessionResetVersionRef.current += 1; + setSessionId(null); + currentStreamingSessionIdRef.current = null; + hydratedSessionRef.current = null; + skipAutoRestoreRef.current = true; + restoredWorkspaceRef.current = resolvedWorkspaceId || null; + + if (resolvedWorkspaceId) { + saveTransient(`agent_curr_sessionId_${resolvedWorkspaceId}`, null); + savePersisted(`agent_last_sessionId_${resolvedWorkspaceId}`, null); + } + + if (staleSessionId) { + savePersisted(`agent_session_workspace_${staleSessionId}`, "__invalid__"); + } + }; + + const sendStreamWithSessionRecovery = async ( + message: string, + eventName: string, + resolvedWorkspaceId: string, + activeSessionId: string, + modelName?: string, + images?: Array<{ data: string; media_type: string }>, + projectId?: string, + ): Promise => { + currentStreamingSessionIdRef.current = activeSessionId; + + try { + await sendAgentMessageStream( + message, + eventName, + resolvedWorkspaceId, + activeSessionId, + modelName, + images, + providerType, + undefined, + projectId, + ); + return; + } catch (error) { + if (!isSessionWorkspaceMismatchError(error)) { + throw error; + } + + console.warn("[AgentChat] 检测到会话工作目录不匹配,准备自动重建会话", { + sessionId: activeSessionId, + workspaceId: resolvedWorkspaceId, + }); + + resetBrokenSessionBinding(activeSessionId); + const freshSessionId = await createFreshSession(); + if (!freshSessionId) { + throw error; + } + + currentStreamingSessionIdRef.current = freshSessionId; + toast.info("检测到旧会话目录异常,已自动切换新会话"); + console.info("[AgentChat] session_auto_recovered", { + staleSessionId: activeSessionId, + freshSessionId, + workspaceId: resolvedWorkspaceId, + }); + await sendAgentMessageStream( + message, + eventName, + resolvedWorkspaceId, + freshSessionId, + modelName, + images, + providerType, + undefined, + projectId, + ); + } + }; + const sendMessage = async ( content: string, images: MessageImage[], @@ -631,6 +759,11 @@ export function useAgentChat(options: UseAgentChatOptions) { setStreamUnlisten: (unlistenFn) => { unlistenRef.current = unlistenFn; }, + setActiveSessionIdForStop: (sessionIdForStop) => { + currentStreamingSessionIdRef.current = sessionIdForStop; + }, + isExecutionCancelled: () => + currentAssistantMsgIdRef.current !== assistantMsgId, playTypewriterSound, playToolcallSound, onWriteFile, @@ -707,6 +840,7 @@ export function useAgentChat(options: UseAgentChatOptions) { if (!activeSessionId) { throw new Error("无法创建或获取会话"); } + currentStreamingSessionIdRef.current = activeSessionId; // 3. 创建唯一事件名称 const eventName = `agent_stream_${assistantMsgId}`; @@ -815,6 +949,7 @@ export function useAgentChat(options: UseAgentChatOptions) { // 清理 ref unlistenRef.current = null; currentAssistantMsgIdRef.current = null; + currentStreamingSessionIdRef.current = null; if (unlisten) { unlisten(); unlisten = null; @@ -862,6 +997,7 @@ export function useAgentChat(options: UseAgentChatOptions) { // 清理 ref unlistenRef.current = null; currentAssistantMsgIdRef.current = null; + currentStreamingSessionIdRef.current = null; if (unlisten) { unlisten(); unlisten = null; @@ -1073,14 +1209,13 @@ export function useAgentChat(options: UseAgentChatOptions) { }); const resolvedWorkspaceId = getRequiredWorkspaceId(); - await sendAgentMessageStream( + await sendStreamWithSessionRecovery( messageToSend, eventName, resolvedWorkspaceId, - activeSessionId, // 传递 sessionId 以保持上下文 + activeSessionId, model || undefined, imagesToSend, - providerType, // 传递用户选择的 provider ); } catch (error) { console.error("[AgentChat] Send failed:", error); @@ -1091,6 +1226,7 @@ export function useAgentChat(options: UseAgentChatOptions) { // Remove the optimistic assistant message on failure setMessages((prev) => prev.filter((msg) => msg.id !== assistantMsgId)); setIsSending(false); + currentStreamingSessionIdRef.current = null; if (unlisten) { unlisten(); } @@ -1124,6 +1260,7 @@ export function useAgentChat(options: UseAgentChatOptions) { setMessages([]); setSessionId(null); + currentStreamingSessionIdRef.current = null; _setRoundCount(0); setA2uiFormDataMap({}); restoredWorkspaceRef.current = resolvedWorkspaceId || null; @@ -1149,6 +1286,23 @@ export function useAgentChat(options: UseAgentChatOptions) { const switchTopic = async (topicId: string) => { if (topicId === sessionId && messages.length > 0) return; + const resolvedWorkspaceId = workspaceId?.trim(); + if (resolvedWorkspaceId) { + const topicWorkspaceId = loadPersisted( + `agent_session_workspace_${topicId}`, + null, + ); + if (topicWorkspaceId && topicWorkspaceId !== resolvedWorkspaceId) { + console.warn("[AgentChat] cross_workspace_topic_blocked", { + topicId, + topicWorkspaceId, + currentWorkspaceId: resolvedWorkspaceId, + }); + toast.error("该话题不属于当前项目,已阻止切换"); + return; + } + } + const restoreRequestVersion = sessionResetVersionRef.current; skipAutoRestoreRef.current = false; console.log("[useAgentChat] 切换话题:", topicId); @@ -1232,6 +1386,7 @@ export function useAgentChat(options: UseAgentChatOptions) { // 加载失败时回退到新会话态,避免卡在无效会话 setMessages([]); setSessionId(null); + currentStreamingSessionIdRef.current = null; saveTransient(getScopedSessionKey(), null); savePersisted(getScopedPersistedSessionKey(), null); toast.error("加载对话历史失败"); @@ -1249,39 +1404,31 @@ export function useAgentChat(options: UseAgentChatOptions) { restoredWorkspaceRef.current = resolvedWorkspaceId; - const scopedCandidate = - loadTransient(getScopedSessionKey(), null) || - loadPersisted(getScopedPersistedSessionKey(), null); - - const legacyCandidateRaw = loadTransient( + const scopedTransientCandidate = loadTransient( + getScopedSessionKey(), + null, + ); + const scopedPersistedCandidate = loadPersisted( + getScopedPersistedSessionKey(), + null, + ); + const legacyCandidate = loadTransient( "agent_curr_sessionId", null, ); - const legacyCandidateWorkspace = legacyCandidateRaw - ? loadPersisted( - `agent_session_workspace_${legacyCandidateRaw}`, - null, - ) - : null; - const legacyCandidate = - legacyCandidateRaw && - (!legacyCandidateWorkspace || - legacyCandidateWorkspace === resolvedWorkspaceId) - ? legacyCandidateRaw - : null; - const mappedFallbackCandidate = - topics.find( - (topic) => - loadPersisted( - `agent_session_workspace_${topic.id}`, - null, - ) === resolvedWorkspaceId, - )?.id || null; - const fallbackCandidate = - mappedFallbackCandidate || (topics.length === 1 ? topics[0]?.id : null); - const targetSessionId = - scopedCandidate || legacyCandidate || fallbackCandidate; + const targetSessionId = resolveRestorableSessionId({ + workspaceId: resolvedWorkspaceId, + topics, + scopedTransientCandidate, + scopedPersistedCandidate, + legacyCandidate, + resolveWorkspaceIdBySessionId: (candidate) => + loadPersisted( + `agent_session_workspace_${candidate}`, + null, + ), + }); if (!targetSessionId) { return; @@ -1333,6 +1480,7 @@ export function useAgentChat(options: UseAgentChatOptions) { if (topicId === sessionId) { setSessionId(null); setMessages([]); + currentStreamingSessionIdRef.current = null; } toast.success("话题已删除"); } catch (_error) { @@ -1355,13 +1503,16 @@ export function useAgentChat(options: UseAgentChatOptions) { await stopAgentProcess(); setProcessStatus({ running: false }); setSessionId(null); // Reset session on stop + currentStreamingSessionIdRef.current = null; } catch (_e) { toast.error("Stop failed"); } }; // 停止当前发送中的消息 - const stopSending = () => { + const stopSending = async () => { + const streamingSessionId = currentStreamingSessionIdRef.current; + // 取消事件监听 if (unlistenRef.current) { unlistenRef.current(); @@ -1384,8 +1535,17 @@ export function useAgentChat(options: UseAgentChatOptions) { currentAssistantMsgIdRef.current = null; } + currentStreamingSessionIdRef.current = null; setIsSending(false); toast.info("已停止生成"); + + if (streamingSessionId) { + try { + await stopAsterSession(streamingSessionId); + } catch (error) { + console.warn("[useAgentChat] 停止会话失败:", error); + } + } }; // 触发 AI 引导(不显示用户消息,直接让 AI 开始引导) @@ -1464,6 +1624,7 @@ export function useAgentChat(options: UseAgentChatOptions) { if (!activeSessionId) { throw new Error("无法创建或获取会话"); } + currentStreamingSessionIdRef.current = activeSessionId; // 创建唯一事件名称 const eventName = `agent_stream_${assistantMsgId}`; @@ -1556,6 +1717,7 @@ export function useAgentChat(options: UseAgentChatOptions) { setIsSending(false); unlistenRef.current = null; currentAssistantMsgIdRef.current = null; + currentStreamingSessionIdRef.current = null; if (unlisten) { unlisten(); unlisten = null; @@ -1585,6 +1747,7 @@ export function useAgentChat(options: UseAgentChatOptions) { setIsSending(false); unlistenRef.current = null; currentAssistantMsgIdRef.current = null; + currentStreamingSessionIdRef.current = null; if (unlisten) { unlisten(); unlisten = null; @@ -1739,14 +1902,12 @@ export function useAgentChat(options: UseAgentChatOptions) { // 发送空消息,让 AI 根据系统提示词开始引导 console.log("[AgentChat] triggerAIGuide 发送空消息触发引导"); const resolvedWorkspaceId = getRequiredWorkspaceId(); - await sendAgentMessageStream( + await sendStreamWithSessionRecovery( "", // 空消息,让 AI 根据系统提示词开始引导 eventName, resolvedWorkspaceId, activeSessionId, model || undefined, - undefined, - providerType, ); } catch (error) { console.error("[AgentChat] triggerAIGuide failed:", error); @@ -1756,6 +1917,7 @@ export function useAgentChat(options: UseAgentChatOptions) { }); setMessages((prev) => prev.filter((msg) => msg.id !== assistantMsgId)); setIsSending(false); + currentStreamingSessionIdRef.current = null; if (unlisten) { unlisten(); } diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index 3629b326a..9906dd73c 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -61,6 +61,7 @@ import { } from "@/lib/api/memory"; import type { Page, PageParams } from "@/types/page"; import { SettingsTabs } from "@/types/settings"; +import { buildHomeAgentParams } from "@/lib/workspace/navigation"; import type { MessageImage } from "./types"; import type { ThemeType, LayoutMode } from "@/components/content-creator/types"; @@ -116,7 +117,7 @@ const ChatContent = styled.div` flex-direction: column; flex: 1; min-height: 0; - padding: 0 16px; + padding: 0 6px; overflow: hidden; height: 100%; `; @@ -179,7 +180,9 @@ export function AgentChatPage({ projectId: externalProjectId, contentId, theme: initialTheme, + initialCreationMode, lockTheme = false, + hideHistoryToggle = false, newChatAt, onRecommendationClick: _onRecommendationClick, onHasMessagesChange, @@ -188,7 +191,9 @@ export function AgentChatPage({ projectId?: string; contentId?: string; theme?: string; + initialCreationMode?: CreationMode; lockTheme?: boolean; + hideHistoryToggle?: boolean; newChatAt?: number; onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void; onHasMessagesChange?: (hasMessages: boolean) => void; @@ -200,7 +205,9 @@ export function AgentChatPage({ const [activeTheme, setActiveTheme] = useState( normalizeInitialTheme(initialTheme), ); - const [creationMode, setCreationMode] = useState("guided"); + const [creationMode, setCreationMode] = useState( + initialCreationMode ?? "guided", + ); const [layoutMode, setLayoutMode] = useState("chat"); useEffect(() => { @@ -208,6 +215,11 @@ export function AgentChatPage({ setActiveTheme(normalizeInitialTheme(initialTheme)); }, [initialTheme]); + useEffect(() => { + if (!initialCreationMode) return; + setCreationMode(initialCreationMode); + }, [initialCreationMode]); + // 内部 projectId 状态(当外部未提供时使用) const [internalProjectId, setInternalProjectId] = useState( null, @@ -764,7 +776,7 @@ export function AgentChatPage({ setProjectMemory(null); setActiveTheme("general"); setCreationMode("guided"); - _onNavigate?.("agent", { theme: "general", lockTheme: false }); + _onNavigate?.("agent", buildHomeAgentParams()); }, [clearMessages, _onNavigate]); // 当开始对话时自动折叠侧边栏 @@ -780,21 +792,40 @@ export function AgentChatPage({ } }, [hasMessages]); - // 当有文件时默认在画布中显示最后一个文件 + // 当有文件时默认在画布中显示最新文件(按更新时间) useEffect(() => { if (taskFiles.length > 0) { - const lastFile = taskFiles[taskFiles.length - 1]; + const latestFile = taskFiles.reduce( + (candidate, file) => { + if (!candidate) { + return file; + } + const candidateTimestamp = Math.max( + candidate.updatedAt, + candidate.createdAt, + ); + const fileTimestamp = Math.max(file.updatedAt, file.createdAt); + return fileTimestamp >= candidateTimestamp ? file : candidate; + }, + null, + ); + + if (!latestFile) { + return; + } + // 设置选中的文件 - setSelectedFileId(lastFile.id); + setSelectedFileId(latestFile.id); // 如果文件有内容,在画布中显示 - if (lastFile.content) { + const latestContent = latestFile.content; + if (latestContent) { setCanvasState((prev) => { if (mappedTheme === "music") { - const sections = parseLyrics(lastFile.content!); + const sections = parseLyrics(latestContent); if (!prev || prev.type !== "music") { const musicState = createInitialMusicState(); musicState.sections = sections; - const titleMatch = lastFile.content!.match(/^#\s*(.+)$/m); + const titleMatch = latestContent.match(/^#\s*(.+)$/m); if (titleMatch) { musicState.spec.title = titleMatch[1].trim(); } @@ -803,9 +834,9 @@ export function AgentChatPage({ return { ...prev, sections }; } if (!prev || prev.type !== "document") { - return createInitialDocumentState(lastFile.content!); + return createInitialDocumentState(latestContent); } - return { ...prev, content: lastFile.content! }; + return { ...prev, content: latestContent }; }); setLayoutMode("chat-canvas"); } @@ -1510,6 +1541,7 @@ export function AgentChatPage({ {}} projectId={projectId ?? null} onProjectChange={(newProjectId) => setInternalProjectId(newProjectId)} diff --git a/src/components/agent/chat/styles/index.ts b/src/components/agent/chat/styles/index.ts index 7a2340ec8..96fe5b589 100644 --- a/src/components/agent/chat/styles/index.ts +++ b/src/components/agent/chat/styles/index.ts @@ -69,11 +69,11 @@ export const MessageWrapper = styled.div<{ $isUser: boolean }>` display: flex; flex-direction: row; align-items: flex-start; - padding: 16px 24px; - gap: 16px; + padding: 12px 6px; + gap: 12px; width: 100%; - max-width: 900px; - margin: 0 auto; + max-width: none; + margin: 0; &:hover .message-actions { opacity: 1; diff --git a/src/components/agent/chat/utils/sessionRecovery.test.ts b/src/components/agent/chat/utils/sessionRecovery.test.ts new file mode 100644 index 000000000..fb3f888c3 --- /dev/null +++ b/src/components/agent/chat/utils/sessionRecovery.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { + isValidSessionId, + resolveRestorableSessionId, +} from "./sessionRecovery"; + +describe("isValidSessionId", () => { + it("应拒绝空值与空字符串", () => { + expect(isValidSessionId(null)).toBe(false); + expect(isValidSessionId(undefined)).toBe(false); + expect(isValidSessionId(" ")).toBe(false); + }); + + it("应拒绝已知非法会话 ID", () => { + expect(isValidSessionId("abc/def")).toBe(false); + expect(isValidSessionId("[object Promise]")).toBe(false); + }); + + it("应接受正常会话 ID", () => { + expect(isValidSessionId("session_123")).toBe(true); + expect(isValidSessionId("f65b8b87-9b5b-4312-9cd4-8f55f20cb5dd")).toBe(true); + }); +}); + +describe("resolveRestorableSessionId", () => { + const workspaceMap: Record = { + s1: "ws-a", + s2: "ws-a", + s3: "ws-a", + s4: "ws-b", + }; + + const resolveWorkspaceIdBySessionId = (sessionId: string) => + workspaceMap[sessionId] ?? null; + + const topics = [{ id: "s1" }, { id: "s2" }, { id: "s4" }]; + + it("应优先使用 scoped transient 候选", () => { + const result = resolveRestorableSessionId({ + workspaceId: "ws-a", + topics, + scopedTransientCandidate: "s2", + scopedPersistedCandidate: "s1", + legacyCandidate: "s3", + resolveWorkspaceIdBySessionId, + }); + + expect(result).toBe("s2"); + }); + + it("应在 transient 非法时使用 scoped persisted", () => { + const result = resolveRestorableSessionId({ + workspaceId: "ws-a", + topics, + scopedTransientCandidate: "[object Promise]", + scopedPersistedCandidate: "s1", + legacyCandidate: "s3", + resolveWorkspaceIdBySessionId, + }); + + expect(result).toBe("s1"); + }); + + it("应在 scoped 候选失效时回退到 legacy", () => { + const result = resolveRestorableSessionId({ + workspaceId: "ws-a", + topics, + scopedTransientCandidate: "s4", + scopedPersistedCandidate: "unknown", + legacyCandidate: "s1", + resolveWorkspaceIdBySessionId, + }); + + expect(result).toBe("s1"); + }); + + it("应拒绝跨 workspace 候选并回退到 topics 首项", () => { + const result = resolveRestorableSessionId({ + workspaceId: "ws-a", + topics, + scopedTransientCandidate: "s4", + scopedPersistedCandidate: "s4", + legacyCandidate: "s4", + resolveWorkspaceIdBySessionId, + }); + + expect(result).toBe("s1"); + }); + + it("应在缺失 topics 时返回 null", () => { + const result = resolveRestorableSessionId({ + workspaceId: "ws-a", + topics: [], + scopedTransientCandidate: "s1", + scopedPersistedCandidate: "s1", + legacyCandidate: "s1", + resolveWorkspaceIdBySessionId, + }); + + expect(result).toBeNull(); + }); +}); diff --git a/src/components/agent/chat/utils/sessionRecovery.ts b/src/components/agent/chat/utils/sessionRecovery.ts new file mode 100644 index 000000000..a105cdd53 --- /dev/null +++ b/src/components/agent/chat/utils/sessionRecovery.ts @@ -0,0 +1,86 @@ +export interface TopicSessionRef { + id: string; +} + +export interface ResolveRestorableSessionIdOptions { + workspaceId: string; + topics: TopicSessionRef[]; + scopedTransientCandidate: string | null; + scopedPersistedCandidate: string | null; + legacyCandidate: string | null; + resolveWorkspaceIdBySessionId: (sessionId: string) => string | null; +} + +export function isValidSessionId( + sessionId: string | null | undefined, +): sessionId is string { + const normalized = sessionId?.trim(); + if (!normalized) { + return false; + } + + if (normalized.includes("/") || normalized.includes("[object Promise]")) { + return false; + } + + return true; +} + +export function resolveRestorableSessionId({ + workspaceId, + topics, + scopedTransientCandidate, + scopedPersistedCandidate, + legacyCandidate, + resolveWorkspaceIdBySessionId, +}: ResolveRestorableSessionIdOptions): string | null { + if (!workspaceId || topics.length === 0) { + return null; + } + + const topicIdSet = new Set(topics.map((topic) => topic.id)); + + const normalizeCandidate = (candidate: string | null): string | null => { + if (!isValidSessionId(candidate)) { + return null; + } + + if (!topicIdSet.has(candidate)) { + return null; + } + + const candidateWorkspaceId = resolveWorkspaceIdBySessionId(candidate); + if (candidateWorkspaceId !== workspaceId) { + return null; + } + + return candidate; + }; + + const scopedTransient = normalizeCandidate(scopedTransientCandidate); + if (scopedTransient) { + return scopedTransient; + } + + const scopedPersisted = normalizeCandidate(scopedPersistedCandidate); + if (scopedPersisted) { + return scopedPersisted; + } + + const legacy = normalizeCandidate(legacyCandidate); + if (legacy) { + return legacy; + } + + const fallback = topics[0]?.id ?? null; + if (!isValidSessionId(fallback)) { + return null; + } + + const fallbackWorkspaceId = resolveWorkspaceIdBySessionId(fallback); + if (fallbackWorkspaceId && fallbackWorkspaceId !== workspaceId) { + return null; + } + + return fallback; +} diff --git a/src/components/agent/chat/utils/skillFailure.test.ts b/src/components/agent/chat/utils/skillFailure.test.ts new file mode 100644 index 000000000..0dcb8877d --- /dev/null +++ b/src/components/agent/chat/utils/skillFailure.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { formatSkillFailureMessage, resolveSkillFailure } from "./skillFailure"; + +describe("resolveSkillFailure", () => { + it("应解析显式错误码", () => { + const result = resolveSkillFailure("skill_not_found|Skill 'writer' 不存在"); + + expect(result.code).toBe("skill_not_found"); + expect(result.message).toContain("不存在"); + }); + + it("应识别 provider 不可用错误", () => { + const result = resolveSkillFailure("无法配置任何可用的 Provider"); + + expect(result.code).toBe("skill_provider_unavailable"); + }); + + it("应识别取消执行错误", () => { + const result = resolveSkillFailure("Execution cancelled by user"); + + expect(result.code).toBe("skill_cancelled"); + }); + + it("应识别 stream 错误", () => { + const result = resolveSkillFailure("Stream error: connection reset"); + + expect(result.code).toBe("skill_stream_failed"); + }); + + it("未知错误应回退为通用错误码", () => { + const result = resolveSkillFailure("some unknown failure"); + + expect(result.code).toBe("skill_execute_failed"); + }); +}); + +describe("formatSkillFailureMessage", () => { + it("应输出包含错误码与恢复建议的文案", () => { + const text = formatSkillFailureMessage( + resolveSkillFailure("skill_session_init_failed|无法创建会话"), + ); + + expect(text).toContain("skill_session_init_failed"); + expect(text).toContain("建议:"); + }); +}); diff --git a/src/components/agent/chat/utils/skillFailure.ts b/src/components/agent/chat/utils/skillFailure.ts new file mode 100644 index 000000000..f4a863f37 --- /dev/null +++ b/src/components/agent/chat/utils/skillFailure.ts @@ -0,0 +1,200 @@ +export type SkillFailureCode = + | "skill_catalog_unavailable" + | "skill_not_found" + | "skill_session_init_failed" + | "skill_provider_unavailable" + | "skill_workspace_mismatch" + | "skill_stream_failed" + | "skill_cancelled" + | "skill_execute_failed"; + +export interface SkillFailureInfo { + code: SkillFailureCode; + message: string; + recoveryHint: string; +} + +const DEFAULT_FAILURE: SkillFailureInfo = { + code: "skill_execute_failed", + message: "执行过程中发生未知错误", + recoveryHint: "请重试;若持续失败,请切换模型或新建话题后再试。", +}; + +function normalizeErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message || DEFAULT_FAILURE.message; + } + + if (typeof error === "string") { + return error.trim() || DEFAULT_FAILURE.message; + } + + if (error && typeof error === "object") { + const message = (error as { message?: unknown }).message; + if (typeof message === "string" && message.trim()) { + return message.trim(); + } + } + + return DEFAULT_FAILURE.message; +} + +function splitErrorCode(rawMessage: string): { + explicitCode: string | null; + message: string; +} { + const separatorIndex = rawMessage.indexOf("|"); + if (separatorIndex <= 0) { + return { explicitCode: null, message: rawMessage }; + } + + const maybeCode = rawMessage.slice(0, separatorIndex).trim(); + if (!/^[a-z0-9_:-]+$/i.test(maybeCode)) { + return { explicitCode: null, message: rawMessage }; + } + + const message = rawMessage.slice(separatorIndex + 1).trim(); + return { + explicitCode: maybeCode, + message: message || rawMessage, + }; +} + +function byCode(code: SkillFailureCode, message: string): SkillFailureInfo { + switch (code) { + case "skill_catalog_unavailable": + return { + code, + message, + recoveryHint: "技能目录暂不可用,请稍后重试或检查本地技能配置。", + }; + case "skill_not_found": + return { + code, + message, + recoveryHint: "请确认技能名称拼写,并在技能管理页查看可用技能。", + }; + case "skill_session_init_failed": + return { + code, + message, + recoveryHint: "请先新建话题或重新选择项目后再执行技能。", + }; + case "skill_provider_unavailable": + return { + code, + message, + recoveryHint: "当前凭证或模型不可用,请切换模型/Provider 后重试。", + }; + case "skill_workspace_mismatch": + return { + code, + message, + recoveryHint: "项目目录已变化,请回到首页重新进入该项目后再试。", + }; + case "skill_stream_failed": + return { + code, + message, + recoveryHint: "流式执行中断,请重试;必要时先停止当前会话。", + }; + case "skill_cancelled": + return { + code, + message, + recoveryHint: "已取消执行,可直接重新发送同一技能命令。", + }; + case "skill_execute_failed": + default: + return { + code: "skill_execute_failed", + message, + recoveryHint: DEFAULT_FAILURE.recoveryHint, + }; + } +} + +function inferSkillFailureCode(message: string): SkillFailureCode { + const lower = message.toLowerCase(); + + if ( + lower.includes("cancel") || + lower.includes("取消") || + lower.includes("stopped") + ) { + return "skill_cancelled"; + } + + if ( + lower.includes("workspace_mismatch") || + lower.includes("工作目录") || + lower.includes("workspace 不匹配") + ) { + return "skill_workspace_mismatch"; + } + + if ( + lower.includes("provider") || + lower.includes("无法配置任何可用") || + lower.includes("credential") + ) { + return "skill_provider_unavailable"; + } + + if (lower.includes("session") || lower.includes("无法创建会话")) { + return "skill_session_init_failed"; + } + + if (lower.includes("stream error") || lower.includes("agent error")) { + return "skill_stream_failed"; + } + + if ( + lower.includes("skill") && + (lower.includes("not found") || lower.includes("不存在")) + ) { + return "skill_not_found"; + } + + return "skill_execute_failed"; +} + +function normalizeSkillFailureCode( + code: string | null, +): SkillFailureCode | null { + if (!code) { + return null; + } + + const normalized = code.trim().toLowerCase(); + const allowed: SkillFailureCode[] = [ + "skill_catalog_unavailable", + "skill_not_found", + "skill_session_init_failed", + "skill_provider_unavailable", + "skill_workspace_mismatch", + "skill_stream_failed", + "skill_cancelled", + "skill_execute_failed", + ]; + + return allowed.includes(normalized as SkillFailureCode) + ? (normalized as SkillFailureCode) + : null; +} + +export function resolveSkillFailure(error: unknown): SkillFailureInfo { + const raw = normalizeErrorMessage(error); + const { explicitCode, message } = splitErrorCode(raw); + + const normalizedCode = normalizeSkillFailureCode(explicitCode); + if (normalizedCode) { + return byCode(normalizedCode, message); + } + + return byCode(inferSkillFailureCode(message), message); +} + +export function formatSkillFailureMessage(failure: SkillFailureInfo): string { + return `Skill 执行失败(${failure.code}):${failure.message}\n建议:${failure.recoveryHint}`; +} diff --git a/src/components/batch/BatchPage.tsx b/src/components/batch/BatchPage.tsx new file mode 100644 index 000000000..1256e050f --- /dev/null +++ b/src/components/batch/BatchPage.tsx @@ -0,0 +1,311 @@ +/** + * 批量任务主页面 + * + * 展示任务列表、创建入口和模板管理 + */ + +import React, { useState, useEffect, useCallback } from "react"; +import styled from "styled-components"; +import { Plus, RefreshCw, Layers, FileText } from "lucide-react"; +import type { Page } from "@/types/page"; +import type { BatchTask, TaskTemplate } from "@/lib/api/batch"; +import { listBatchTasks, listTemplates } from "@/lib/api/batch"; +import { CreateBatchDialog } from "./CreateBatchDialog"; +import { BatchTaskDetail } from "./BatchTaskDetail"; +import { TemplateManager } from "./TemplateManager"; + +interface BatchPageProps { + onNavigate?: (page: Page) => void; +} + +const Container = styled.div` + display: flex; + flex-direction: column; + height: 100%; + padding: 24px; + gap: 16px; + overflow: auto; +`; + +const Header = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +`; + +const Title = styled.h1` + font-size: 20px; + font-weight: 600; + color: hsl(var(--foreground)); +`; + +const Actions = styled.div` + display: flex; + gap: 8px; +`; + +const Btn = styled.button` + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 6px; + border: 1px solid hsl(var(--border)); + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font-size: 13px; + cursor: pointer; + &:hover { + background: hsl(var(--accent)); + } + svg { + width: 14px; + height: 14px; + } +`; + +const PrimaryBtn = styled(Btn)` + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + border-color: hsl(var(--primary)); + &:hover { + opacity: 0.9; + background: hsl(var(--primary)); + } +`; + +const Tabs = styled.div` + display: flex; + gap: 4px; + border-bottom: 1px solid hsl(var(--border)); + padding-bottom: 0; +`; + +const Tab = styled.button<{ $active: boolean }>` + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: none; + background: none; + color: ${(p) => + p.$active ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))"}; + border-bottom: 2px solid + ${(p) => (p.$active ? "hsl(var(--primary))" : "transparent")}; + cursor: pointer; + font-size: 13px; + font-weight: ${(p) => (p.$active ? 600 : 400)}; + svg { + width: 14px; + height: 14px; + } +`; + +const TaskList = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const TaskCard = styled.div` + padding: 12px 16px; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + cursor: pointer; + &:hover { + background: hsl(var(--accent)); + } +`; + +const TaskHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +const TaskName = styled.span` + font-weight: 500; + font-size: 14px; +`; + +const StatusBadge = styled.span<{ $status: string }>` + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 500; + background: ${(p) => { + switch (p.$status) { + case "completed": + return "hsl(142 76% 36% / 0.15)"; + case "running": + return "hsl(217 91% 60% / 0.15)"; + case "failed": + return "hsl(0 84% 60% / 0.15)"; + case "cancelled": + return "hsl(0 0% 50% / 0.15)"; + case "partiallycompleted": + return "hsl(38 92% 50% / 0.15)"; + default: + return "hsl(0 0% 50% / 0.1)"; + } + }}; + color: ${(p) => { + switch (p.$status) { + case "completed": + return "hsl(142 76% 36%)"; + case "running": + return "hsl(217 91% 60%)"; + case "failed": + return "hsl(0 84% 60%)"; + case "cancelled": + return "hsl(0 0% 50%)"; + case "partiallycompleted": + return "hsl(38 92% 50%)"; + default: + return "hsl(0 0% 50%)"; + } + }}; +`; + +const TaskMeta = styled.div` + display: flex; + gap: 16px; + margin-top: 6px; + font-size: 12px; + color: hsl(var(--muted-foreground)); +`; + +const Empty = styled.div` + text-align: center; + padding: 48px; + color: hsl(var(--muted-foreground)); + font-size: 14px; +`; + +const STATUS_LABELS: Record = { + pending: "等待中", + running: "运行中", + completed: "已完成", + partiallycompleted: "部分完成", + failed: "失败", + cancelled: "已取消", +}; + +export const BatchPage: React.FC = () => { + const [tab, setTab] = useState<"tasks" | "templates">("tasks"); + const [tasks, setTasks] = useState([]); + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(false); + const [showCreate, setShowCreate] = useState(false); + const [selectedTaskId, setSelectedTaskId] = useState(null); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const [t, tpl] = await Promise.all([listBatchTasks(), listTemplates()]); + setTasks(t); + setTemplates(tpl); + } catch (e) { + console.error("[BatchPage] 加载失败:", e); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + // 自动刷新运行中的任务 + useEffect(() => { + const hasRunning = tasks.some((t) => t.status === "running"); + if (!hasRunning) return; + const timer = setInterval(refresh, 3000); + return () => clearInterval(timer); + }, [tasks, refresh]); + + if (selectedTaskId) { + return ( + { + setSelectedTaskId(null); + refresh(); + }} + /> + ); + } + + return ( + +
+ 批量任务 + + + 刷新 + + setShowCreate(true)}> + 创建任务 + + +
+ + + setTab("tasks")}> + 任务列表 + + setTab("templates")}> + 模板管理 + + + + {tab === "tasks" && ( + + {tasks.length === 0 ? ( + 暂无批量任务,点击"创建任务"开始 + ) : ( + tasks.map((task) => ( + setSelectedTaskId(task.id)} + > + + {task.name} + + {STATUS_LABELS[task.status] || task.status} + + + + 子任务: {task.tasks.length} + + 完成:{" "} + { + task.results.filter((r) => r.status === "completed") + .length + } + + {new Date(task.created_at).toLocaleString()} + + + )) + )} + + )} + + {tab === "templates" && ( + + )} + + {showCreate && ( + setShowCreate(false)} + onCreated={() => { + setShowCreate(false); + refresh(); + }} + /> + )} +
+ ); +}; diff --git a/src/components/batch/BatchTaskDetail.tsx b/src/components/batch/BatchTaskDetail.tsx new file mode 100644 index 000000000..e48532422 --- /dev/null +++ b/src/components/batch/BatchTaskDetail.tsx @@ -0,0 +1,306 @@ +/** + * 批量任务详情页 + * + * 展示任务进度、结果和统计信息 + */ + +import React, { useState, useEffect, useCallback } from "react"; +import styled from "styled-components"; +import { + ArrowLeft, + XCircle, + CheckCircle, + AlertCircle, + Clock, + RefreshCw, +} from "lucide-react"; +import type { BatchTaskDetail as BatchTaskDetailType } from "@/lib/api/batch"; +import { getBatchTask, cancelBatchTask } from "@/lib/api/batch"; + +interface Props { + taskId: string; + onBack: () => void; +} + +const Container = styled.div` + display: flex; + flex-direction: column; + height: 100%; + padding: 24px; + gap: 16px; + overflow: auto; +`; + +const Header = styled.div` + display: flex; + align-items: center; + gap: 12px; +`; + +const BackBtn = styled.button` + background: none; + border: none; + cursor: pointer; + color: hsl(var(--muted-foreground)); + padding: 4px; + &:hover { + color: hsl(var(--foreground)); + } + svg { + width: 18px; + height: 18px; + } +`; + +const Title = styled.h2` + font-size: 18px; + font-weight: 600; + flex: 1; +`; + +const Btn = styled.button` + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 6px; + border: 1px solid hsl(var(--border)); + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font-size: 13px; + cursor: pointer; + &:hover { + background: hsl(var(--accent)); + } + svg { + width: 14px; + height: 14px; + } +`; + +const DangerBtn = styled(Btn)` + color: hsl(0 84% 60%); + border-color: hsl(0 84% 60% / 0.3); + &:hover { + background: hsl(0 84% 60% / 0.1); + } +`; + +const StatsGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 12px; +`; + +const StatCard = styled.div` + padding: 12px; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + text-align: center; +`; + +const StatValue = styled.div` + font-size: 24px; + font-weight: 700; + color: hsl(var(--foreground)); +`; + +const StatLabel = styled.div` + font-size: 11px; + color: hsl(var(--muted-foreground)); + margin-top: 2px; +`; + +const ProgressBar = styled.div` + height: 8px; + background: hsl(var(--muted) / 0.3); + border-radius: 4px; + overflow: hidden; +`; + +const ProgressFill = styled.div<{ $pct: number; $color: string }>` + height: 100%; + width: ${(p) => p.$pct}%; + background: ${(p) => p.$color}; + transition: width 0.3s ease; +`; + +const ResultList = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const ResultCard = styled.div` + padding: 12px; + border: 1px solid hsl(var(--border)); + border-radius: 8px; +`; + +const ResultHeader = styled.div` + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + font-size: 13px; + font-weight: 500; +`; + +const ResultContent = styled.pre` + font-size: 12px; + color: hsl(var(--muted-foreground)); + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + overflow: auto; + margin: 0; + padding: 8px; + background: hsl(var(--muted) / 0.2); + border-radius: 4px; +`; + +const StatusIcon: React.FC<{ status: string }> = ({ status }) => { + switch (status) { + case "completed": + return ( + + ); + case "failed": + return ( + + ); + case "cancelled": + return ( + + ); + case "running": + return ( + + ); + default: + return ( + + ); + } +}; + +export const BatchTaskDetail: React.FC = ({ taskId, onBack }) => { + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + try { + const d = await getBatchTask(taskId); + setDetail(d); + } catch (e) { + console.error("[BatchTaskDetail] 加载失败:", e); + } finally { + setLoading(false); + } + }, [taskId]); + + useEffect(() => { + refresh(); + }, [refresh]); + + // 自动刷新运行中的任务 + useEffect(() => { + if (!detail || detail.batch_task.status !== "running") return; + const timer = setInterval(refresh, 2000); + return () => clearInterval(timer); + }, [detail, refresh]); + + const handleCancel = async () => { + try { + await cancelBatchTask(taskId); + refresh(); + } catch (e) { + console.error("[BatchTaskDetail] 取消失败:", e); + } + }; + + if (loading || !detail) { + return 加载中...; + } + + const { batch_task: task, statistics: stats } = detail; + const pct = + stats.total_tasks > 0 + ? ((stats.completed_tasks + stats.failed_tasks) / stats.total_tasks) * 100 + : 0; + + return ( + +
+ + + + {task.name} + {(task.status === "running" || task.status === "pending") && ( + + 取消 + + )} +
+ + + + + + + + {stats.total_tasks} + 总任务 + + + {stats.completed_tasks} + 已完成 + + + {stats.failed_tasks} + 失败 + + + {stats.running_tasks} + 运行中 + + + {stats.pending_tasks} + 等待中 + + + {stats.total_tokens.total_tokens} + 总 Tokens + + + + + {task.results.map((r, i) => ( + + + + 任务 #{i + 1} + + {r.usage.total_tokens} tokens + + + {r.content && {r.content}} + {r.error && ( + + {r.error} + + )} + + ))} + +
+ ); +}; diff --git a/src/components/batch/CreateBatchDialog.tsx b/src/components/batch/CreateBatchDialog.tsx new file mode 100644 index 000000000..15c9621f2 --- /dev/null +++ b/src/components/batch/CreateBatchDialog.tsx @@ -0,0 +1,308 @@ +/** + * 创建批量任务对话框 + */ + +import React, { useState } from "react"; +import styled from "styled-components"; +import { X } from "lucide-react"; +import type { TaskTemplate } from "@/lib/api/batch"; +import { createBatchTask } from "@/lib/api/batch"; + +interface Props { + templates: TaskTemplate[]; + onClose: () => void; + onCreated: () => void; +} + +const Overlay = styled.div` + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +`; + +const Dialog = styled.div` + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 12px; + width: 560px; + max-height: 80vh; + overflow: auto; + padding: 24px; +`; + +const DialogHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +`; + +const DialogTitle = styled.h2` + font-size: 16px; + font-weight: 600; +`; + +const CloseBtn = styled.button` + background: none; + border: none; + cursor: pointer; + color: hsl(var(--muted-foreground)); + padding: 4px; + &:hover { + color: hsl(var(--foreground)); + } + svg { + width: 16px; + height: 16px; + } +`; + +const Field = styled.div` + margin-bottom: 16px; +`; + +const Label = styled.label` + display: block; + font-size: 13px; + font-weight: 500; + margin-bottom: 6px; + color: hsl(var(--foreground)); +`; + +const Input = styled.input` + width: 100%; + padding: 8px 12px; + border: 1px solid hsl(var(--border)); + border-radius: 6px; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font-size: 13px; + box-sizing: border-box; +`; + +const Select = styled.select` + width: 100%; + padding: 8px 12px; + border: 1px solid hsl(var(--border)); + border-radius: 6px; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font-size: 13px; +`; + +const Textarea = styled.textarea` + width: 100%; + padding: 8px 12px; + border: 1px solid hsl(var(--border)); + border-radius: 6px; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font-size: 13px; + font-family: monospace; + min-height: 120px; + resize: vertical; + box-sizing: border-box; +`; + +const HelpText = styled.p` + font-size: 11px; + color: hsl(var(--muted-foreground)); + margin-top: 4px; +`; + +const Footer = styled.div` + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 20px; +`; + +const Btn = styled.button` + padding: 8px 16px; + border-radius: 6px; + border: 1px solid hsl(var(--border)); + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font-size: 13px; + cursor: pointer; + &:hover { + background: hsl(var(--accent)); + } +`; + +const PrimaryBtn = styled(Btn)` + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + border-color: hsl(var(--primary)); + &:hover { + opacity: 0.9; + background: hsl(var(--primary)); + } + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`; + +const ErrorMsg = styled.p` + color: hsl(0 84% 60%); + font-size: 12px; + margin-top: 8px; +`; + +export const CreateBatchDialog: React.FC = ({ + templates, + onClose, + onCreated, +}) => { + const [name, setName] = useState(""); + const [templateId, setTemplateId] = useState(templates[0]?.id || ""); + const [tasksJson, setTasksJson] = useState( + JSON.stringify([{ variables: { content: "示例内容" } }], null, 2), + ); + const [concurrency, setConcurrency] = useState(3); + const [retryCount, setRetryCount] = useState(0); + const [timeoutSecs, setTimeoutSecs] = useState(120); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(""); + + const handleSubmit = async () => { + setError(""); + if (!name.trim()) { + setError("请输入任务名称"); + return; + } + if (!templateId) { + setError("请选择模板"); + return; + } + + let tasks; + try { + tasks = JSON.parse(tasksJson); + if (!Array.isArray(tasks) || tasks.length === 0) { + setError("任务列表必须是非空数组"); + return; + } + } catch { + setError("任务列表 JSON 格式错误"); + return; + } + + setSubmitting(true); + try { + await createBatchTask({ + name: name.trim(), + template_id: templateId, + tasks, + options: { + concurrency, + retry_count: retryCount, + timeout_seconds: timeoutSecs, + continue_on_error: true, + }, + }); + onCreated(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "创建失败"); + } finally { + setSubmitting(false); + } + }; + + return ( + + e.stopPropagation()}> + + 创建批量任务 + + + + + + + + setName(e.target.value)} + placeholder="例如:批量翻译文档" + /> + + + + + + + + + +