From 1a13915f72c902652b3b46de64b5f629205545b6 Mon Sep 17 00:00:00 2001 From: xianrenge <34586161+xianrenge@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:21:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BB=BB=E5=8A=A1=E9=A1=B5=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=AE=9A=E6=97=B6=E8=AE=A1=E5=88=92=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=8C=87=E5=AE=9A=E6=89=A7=E8=A1=8C=E6=99=BA=E8=83=BD=E4=BD=93?= =?UTF-8?q?=EF=BC=88=E9=BB=98=E8=AE=A4=E9=80=9A=E7=94=A8=E5=8A=A9=E6=89=8B?= =?UTF-8?q?=EF=BC=89=E4=B8=8E=E9=97=B4=E9=9A=94=EF=BC=8C=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E5=88=B0=E7=82=B9=E8=87=AA=E5=8A=A8=E5=9C=A8=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E4=BD=93=E4=BC=9A=E8=AF=9D=E4=B8=AD=E6=89=A7=E8=A1=8C=E5=B9=B6?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/commands.rs | 376 +++++++++++++++++++- apps/desktop/src/lib.rs | 18 + crates/core/src/lib.rs | 2 + crates/core/src/scheduled_tasks.rs | 290 +++++++++++++++ crates/core/src/schema.sql | 16 + docs/ARCHITECTURE.md | 19 +- docs/FEATURES.md | 14 + ui/src/App.tsx | 7 + ui/src/api.ts | 35 ++ ui/src/components/Icon.tsx | 19 + ui/src/pages/TasksPage.tsx | 545 ++++++++++++++++++++++++++++- ui/src/store.ts | 8 + 12 files changed, 1327 insertions(+), 22 deletions(-) create mode 100644 crates/core/src/scheduled_tasks.rs diff --git a/apps/desktop/src/commands.rs b/apps/desktop/src/commands.rs index dd4a51a..246baf5 100644 --- a/apps/desktop/src/commands.rs +++ b/apps/desktop/src/commands.rs @@ -6,12 +6,13 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::time::Instant; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, Emitter, Manager, State}; use tokio::sync::RwLock; use xianren_api::ApiState; use xianren_core::agents as agents_db; use xianren_core::models as models_db; use xianren_core::mcp_servers as mcp_db; +use xianren_core::scheduled_tasks as scheduled_db; use xianren_core::sessions as sessions_db; use xianren_core::settings as settings_db; use xianren_core::skills as skills_db; @@ -863,6 +864,24 @@ pub async fn call_model_text( prompt: &str, temperature: f32, max_tokens: u32, +) -> Result { + call_model_messages( + engine, + model, + vec![ChatMessage::new("user", prompt)], + temperature, + max_tokens, + ) + .await +} + +/// 调用本地引擎或远程 OpenAI 兼容 API 完成一次非流式对话(可携带 system 等多条消息)。 +pub async fn call_model_messages( + engine: &EngineManager, + model: &ModelInfo, + messages: Vec, + temperature: f32, + max_tokens: u32, ) -> Result { let upstream_model = if model.kind == "remote" { model @@ -874,7 +893,7 @@ pub async fn call_model_text( }; let req = ChatRequest { model: upstream_model.clone(), - messages: vec![ChatMessage::new("user", prompt)], + messages, temperature: Some(temperature), top_p: None, max_tokens: Some(max_tokens), @@ -893,6 +912,359 @@ pub async fn call_model_text( result.map_err(|e| e.to_string()) } +// ---------- 定时计划 ---------- + +#[derive(Deserialize)] +pub struct ScheduledTaskInput { + pub name: String, + #[serde(default)] + pub agent_id: Option, + pub prompt: String, + pub interval_minutes: i64, + #[serde(default = "default_true")] + pub enabled: bool, +} + +fn validate_scheduled_task_input(input: &ScheduledTaskInput) -> Result<(), String> { + if input.name.trim().is_empty() { + return Err("计划名称不能为空".into()); + } + if input.prompt.trim().is_empty() { + return Err("执行内容(提示词)不能为空".into()); + } + if input.interval_minutes < 1 { + return Err("执行间隔至少为 1 分钟".into()); + } + Ok(()) +} + +#[tauri::command] +pub fn list_scheduled_tasks( + state: State<'_, App>, +) -> Result, String> { + let db = state.core.db.lock().unwrap(); + scheduled_db::list(&db).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn add_scheduled_task( + state: State<'_, App>, + input: ScheduledTaskInput, +) -> Result { + validate_scheduled_task_input(&input)?; + let db = state.core.db.lock().unwrap(); + let id = uuid::Uuid::new_v4().to_string(); + let task = xianren_core::ScheduledTask { + id, + name: input.name.trim().to_string(), + agent_id: input.agent_id.filter(|s| !s.trim().is_empty()), + prompt: input.prompt.trim().to_string(), + interval_minutes: input.interval_minutes.max(1), + enabled: input.enabled, + next_run_at: None, + last_run_at: None, + last_status: "idle".to_string(), + last_result: None, + last_error: None, + created_at: String::new(), + updated_at: String::new(), + }; + scheduled_db::insert(&db, &task).map_err(|e| e.to_string())?; + scheduled_db::get(&db, &task.id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "scheduled task not found".to_string()) +} + +#[tauri::command] +pub fn update_scheduled_task( + state: State<'_, App>, + id: String, + input: ScheduledTaskInput, +) -> Result<(), String> { + validate_scheduled_task_input(&input)?; + let db = state.core.db.lock().unwrap(); + let existing = scheduled_db::get(&db, &id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "定时计划不存在".to_string())?; + let task = xianren_core::ScheduledTask { + id, + name: input.name.trim().to_string(), + agent_id: input.agent_id.filter(|s| !s.trim().is_empty()), + prompt: input.prompt.trim().to_string(), + interval_minutes: input.interval_minutes.max(1), + enabled: input.enabled, + next_run_at: existing.next_run_at, + last_run_at: existing.last_run_at, + last_status: existing.last_status, + last_result: existing.last_result, + last_error: existing.last_error, + created_at: existing.created_at, + updated_at: String::new(), + }; + scheduled_db::update(&db, &task).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn remove_scheduled_task(state: State<'_, App>, id: String) -> Result<(), String> { + let db = state.core.db.lock().unwrap(); + scheduled_db::delete(&db, &id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn set_scheduled_task_enabled( + state: State<'_, App>, + id: String, + enabled: bool, +) -> Result<(), String> { + let db = state.core.db.lock().unwrap(); + scheduled_db::set_enabled(&db, &id, enabled).map_err(|e| e.to_string()) +} + +/// 立即执行一次定时计划(不改变原有调度节奏)。 +#[tauri::command] +pub async fn run_scheduled_task_now( + app: AppHandle, + state: State<'_, App>, + id: String, +) -> Result<(), String> { + let core = state.core.clone(); + let engine = state.engine.clone(); + let task = { + let db = core.db.lock().unwrap(); + let task = scheduled_db::get(&db, &id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "定时计划不存在".to_string())?; + if task.last_status == "running" { + return Err("该计划正在执行中,请稍候".into()); + } + scheduled_db::mark_running(&db, &id).map_err(|e| e.to_string())?; + task + }; + let _ = app.emit("scheduled://updated", ()); + let app2 = app.clone(); + tokio::spawn(async move { + execute_scheduled_task(app2, core, engine, task).await; + }); + Ok(()) +} + +/// 后台调度入口:由 `lib.rs` 定时调用,取出到点的计划并逐个异步执行。 +pub async fn run_due_scheduled_tasks(app: &AppHandle) -> Result<(), String> { + let state = app.state::(); + let core = state.core.clone(); + let engine = state.engine.clone(); + // 应用重启后清理上次中断的「运行中」任务 + { + let db = core.db.lock().unwrap(); + let _ = scheduled_db::reset_stale_running(&db); + } + let due = { + let db = core.db.lock().unwrap(); + scheduled_db::list_due(&db).map_err(|e| e.to_string())? + }; + for task in due { + { + let db = core.db.lock().unwrap(); + let current = scheduled_db::get(&db, &task.id).map_err(|e| e.to_string())?; + if current.as_ref().map(|t| t.last_status.as_str()) == Some("running") { + continue; + } + scheduled_db::mark_running(&db, &task.id).map_err(|e| e.to_string())?; + } + let _ = app.emit("scheduled://updated", ()); + let app2 = app.clone(); + let core2 = core.clone(); + let engine2 = engine.clone(); + tokio::spawn(async move { + execute_scheduled_task(app2, core2, engine2, task).await; + }); + } + Ok(()) +} + +/// 执行一次定时计划并落库:成功写入助手回答,失败记录错误信息。 +async fn execute_scheduled_task( + app: AppHandle, + core: Arc, + engine: EngineManager, + task: xianren_core::ScheduledTask, +) { + let result = run_scheduled_task_once(&app, &core, &engine, &task).await; + { + let db = core.db.lock().unwrap(); + match result { + Ok(content) => { + let _ = scheduled_db::finish_run(&db, &task.id, true, Some(&content), None); + } + Err(e) => { + tracing::warn!(task = %task.name, error = %e, "scheduled task failed"); + let _ = scheduled_db::finish_run(&db, &task.id, false, None, Some(&e)); + } + } + } + let _ = app.emit("scheduled://updated", ()); +} + +/// 单次执行:解析智能体与模型 → 创建/复用会话 → 写入问答 → 调用大模型。 +async fn run_scheduled_task_once( + app: &AppHandle, + core: &CoreApp, + engine: &EngineManager, + task: &xianren_core::ScheduledTask, +) -> Result { + // 指定智能体已被删除时回退到「通用助手」,仍缺失则按普通对话执行 + let (effective_agent_id, system_prompt, model) = { + let db = core.db.lock().unwrap(); + let mut agent = task + .agent_id + .as_deref() + .and_then(|aid| agents_db::get(&db, aid).ok().flatten()); + if agent.is_none() && task.agent_id.is_some() { + agent = agents_db::get(&db, "preset-general-assistant") + .ok() + .flatten(); + } + let system_prompt = agent + .as_ref() + .map(|a| a.system_prompt.trim().to_string()) + .filter(|s| !s.is_empty()); + let model = resolve_scheduled_model(&db, agent.as_ref())?; + (agent.map(|a| a.id.clone()), system_prompt, model) + }; + + // 在智能体作用域内创建/复用空会话 + let conversation_id = { + let db = core.db.lock().unwrap(); + let title: String = task.name.chars().take(30).collect(); + if let Some(existing) = + sessions_db::find_empty_conversation(&db, effective_agent_id.as_deref()) + .map_err(|e| e.to_string())? + { + let _ = sessions_db::update_conversation_model(&db, &existing.id, &model.id); + existing.id + } else { + let id = uuid::Uuid::new_v4().to_string(); + sessions_db::create_conversation( + &db, + &id, + &title, + Some(&model.id), + system_prompt.as_deref(), + effective_agent_id.as_deref(), + ) + .map_err(|e| e.to_string())?; + id + } + }; + + // 写入用户消息并构建历史(智能体系统提示词注入到最前) + { + let db = core.db.lock().unwrap(); + sessions_db::add_message( + &db, + &conversation_id, + "user", + Some(&model.id), + &task.prompt, + None, + None, + None, + None, + &[], + ) + .map_err(|e| e.to_string())?; + } + let mut messages: Vec = { + let db = core.db.lock().unwrap(); + sessions_db::list_messages(&db, &conversation_id) + .map_err(|e| e.to_string())? + .into_iter() + .map(|m| ChatMessage { + role: m.role, + content: m.content, + images: m.images, + }) + .collect() + }; + if let Some(sp) = &system_prompt { + messages.insert(0, ChatMessage::new("system", sp)); + } + + // 本地模型:未运行时自动拉起引擎 + if model.kind == "local" && !engine.status().await.running { + let bin = { + let db = core.db.lock().unwrap(); + settings_db::get(&db, "engine_bin") + .ok() + .flatten() + .unwrap_or_default() + }; + let bin_path = PathBuf::from(&bin); + if bin.trim().is_empty() || !bin_path.is_file() { + return Err(format!( + "本地引擎未配置({}),请先在设置页配置引擎路径", + bin_path.display() + )); + } + let cfg = EngineConfig { + binary_path: bin_path, + model_path: PathBuf::from(model.file_path.clone()), + host: "127.0.0.1".into(), + ctx_size: 4096, + ngl: 99, + threads: None, + log_file: core.logs_dir.join(format!("engine-{}.log", model.file_name)), + }; + engine + .start(cfg) + .await + .map_err(|e| format!("启动本地引擎失败:{e}"))?; + } + + let content = call_model_messages(engine, &model, messages, 0.7, 4096).await?; + + // 写入助手回答并刷新会话 + { + let db = core.db.lock().unwrap(); + sessions_db::add_message( + &db, + &conversation_id, + "assistant", + Some(&model.id), + &content, + None, + None, + None, + None, + &[], + ) + .map_err(|e| e.to_string())?; + sessions_db::touch_conversation(&db, &conversation_id).map_err(|e| e.to_string())?; + } + let _ = app.emit("conversations://updated", ()); + Ok(content) +} + +/// 解析定时计划使用的大模型:智能体默认模型 → 第一个本地模型 → 第一个已启用在线模型。 +fn resolve_scheduled_model( + db: &rusqlite::Connection, + agent: Option<&xianren_core::Agent>, +) -> Result { + if let Some(mid) = agent.and_then(|a| a.model_id.clone()) { + if let Some(m) = models_db::get(db, &mid).map_err(|e| e.to_string())? { + return Ok(m); + } + } + let all = models_db::list(db).map_err(|e| e.to_string())?; + if let Some(m) = all.iter().find(|m| m.kind == "local") { + return Ok(m.clone()); + } + if let Some(m) = all.iter().find(|m| m.kind == "remote" && m.enabled) { + return Ok(m.clone()); + } + Err("没有可用的大模型:请先在模型管理部署本地模型或启用在线 API 模型".into()) +} + #[tauri::command] pub fn list_conversations( state: State<'_, App>, diff --git a/apps/desktop/src/lib.rs b/apps/desktop/src/lib.rs index a89c2ba..dc4f806 100644 --- a/apps/desktop/src/lib.rs +++ b/apps/desktop/src/lib.rs @@ -134,6 +134,18 @@ pub fn run() { tracing::info!(seeded = seeded_workflows, "preset workflows seeded"); } let _ = app.emit("models://updated", ()); + + // 定时计划后台调度:每 30 秒检查一次到点的计划并执行 + let scheduler_app = app.handle().clone(); + tauri::async_runtime::spawn(async move { + let mut ticker = tokio::time::interval(std::time::Duration::from_secs(30)); + loop { + ticker.tick().await; + if let Err(e) = commands::run_due_scheduled_tasks(&scheduler_app).await { + tracing::warn!(error = %e, "scheduled tasks tick failed"); + } + } + }); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -151,6 +163,12 @@ pub fn run() { commands::set_workflow_enabled, commands::restore_preset_workflows, commands::run_workflow, + commands::list_scheduled_tasks, + commands::add_scheduled_task, + commands::update_scheduled_task, + commands::remove_scheduled_task, + commands::set_scheduled_task_enabled, + commands::run_scheduled_task_now, commands::list_models, commands::import_model, commands::remove_model, diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 96e7f8c..f005d3d 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -3,6 +3,7 @@ pub mod app; pub mod error; pub mod mcp_servers; pub mod models; +pub mod scheduled_tasks; pub mod sessions; pub mod settings; pub mod skills; @@ -12,5 +13,6 @@ pub use app::CoreApp; pub use error::{CoreError, Result}; pub use models::ModelInfo; pub use agents::Agent; +pub use scheduled_tasks::ScheduledTask; pub use sessions::{Conversation, Message}; pub use workflows::Workflow; diff --git a/crates/core/src/scheduled_tasks.rs b/crates/core/src/scheduled_tasks.rs new file mode 100644 index 0000000..6f1eb62 --- /dev/null +++ b/crates/core/src/scheduled_tasks.rs @@ -0,0 +1,290 @@ +use crate::error::Result; +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScheduledTask { + pub id: String, + pub name: String, + pub agent_id: Option, + pub prompt: String, + pub interval_minutes: i64, + pub enabled: bool, + pub next_run_at: Option, + pub last_run_at: Option, + /// idle(从未执行)/ running / success / error + pub last_status: String, + pub last_result: Option, + pub last_error: Option, + pub created_at: String, + pub updated_at: String, +} + +const SELECT_COLUMNS: &str = "id, name, agent_id, prompt, interval_minutes, enabled, next_run_at, last_run_at, last_status, last_result, last_error, created_at, updated_at"; + +/// 把分钟数转成 SQLite datetime 修饰符字符串(如 `+30 minutes`)。 +fn interval_modifier(minutes: i64) -> String { + format!("+{} minutes", minutes.max(1)) +} + +pub fn list(db: &Connection) -> Result> { + let mut stmt = db.prepare(&format!( + "SELECT {SELECT_COLUMNS} FROM scheduled_tasks ORDER BY updated_at DESC, created_at DESC" + ))?; + let rows = stmt.query_map([], row_to_task)?; + let mut out = Vec::new(); + for row in rows { + out.push(row?); + } + Ok(out) +} + +pub fn get(db: &Connection, id: &str) -> Result> { + let mut stmt = db.prepare(&format!( + "SELECT {SELECT_COLUMNS} FROM scheduled_tasks WHERE id = ?1" + ))?; + let mut rows = stmt.query_map(params![id], row_to_task)?; + match rows.next() { + Some(row) => Ok(Some(row?)), + None => Ok(None), + } +} + +pub fn insert(db: &Connection, task: &ScheduledTask) -> Result<()> { + db.execute( + "INSERT INTO scheduled_tasks + (id, name, agent_id, prompt, interval_minutes, enabled, next_run_at, last_status) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, datetime('now', ?7), 'idle')", + params![ + task.id, + task.name, + task.agent_id, + task.prompt, + task.interval_minutes.max(1), + task.enabled as i32, + interval_modifier(task.interval_minutes), + ], + )?; + Ok(()) +} + +pub fn update(db: &Connection, task: &ScheduledTask) -> Result<()> { + db.execute( + "UPDATE scheduled_tasks SET name = ?1, agent_id = ?2, prompt = ?3, + interval_minutes = ?4, enabled = ?5, + next_run_at = CASE WHEN ?6 = 1 THEN datetime('now', ?7) ELSE NULL END, + updated_at = datetime('now') + WHERE id = ?8", + params![ + task.name, + task.agent_id, + task.prompt, + task.interval_minutes.max(1), + task.enabled as i32, + task.enabled as i32, + interval_modifier(task.interval_minutes), + task.id, + ], + )?; + Ok(()) +} + +pub fn delete(db: &Connection, id: &str) -> Result<()> { + db.execute("DELETE FROM scheduled_tasks WHERE id = ?1", params![id])?; + Ok(()) +} + +pub fn set_enabled(db: &Connection, id: &str, enabled: bool) -> Result<()> { + db.execute( + "UPDATE scheduled_tasks SET enabled = ?1, + next_run_at = CASE WHEN ?1 = 1 THEN datetime('now', ?2) ELSE NULL END, + updated_at = datetime('now') + WHERE id = ?3", + params![ + enabled as i32, + interval_modifier( + db.query_row( + "SELECT interval_minutes FROM scheduled_tasks WHERE id = ?1", + params![id], + |row| row.get::<_, i64>(0), + ) + .unwrap_or(60), + ), + id, + ], + )?; + Ok(()) +} + +/// 标记任务开始执行(后台调度与「立即执行」共用)。 +pub fn mark_running(db: &Connection, id: &str) -> Result<()> { + db.execute( + "UPDATE scheduled_tasks SET last_status = 'running', updated_at = datetime('now') + WHERE id = ?1", + params![id], + )?; + Ok(()) +} + +/// 写入一次执行结果,并推进下一次执行时间。 +pub fn finish_run( + db: &Connection, + id: &str, + success: bool, + result: Option<&str>, + error: Option<&str>, +) -> Result<()> { + let interval: i64 = db.query_row( + "SELECT interval_minutes FROM scheduled_tasks WHERE id = ?1", + params![id], + |row| row.get(0), + )?; + db.execute( + "UPDATE scheduled_tasks SET last_status = ?1, last_run_at = datetime('now'), + last_result = ?2, last_error = ?3, next_run_at = datetime('now', ?4), + updated_at = datetime('now') + WHERE id = ?5", + params![ + if success { "success" } else { "error" }, + result, + error, + interval_modifier(interval), + id, + ], + )?; + Ok(()) +} + +/// 列出到点可执行且未在运行中的已启用任务。 +pub fn list_due(db: &Connection) -> Result> { + let mut stmt = db.prepare(&format!( + "SELECT {SELECT_COLUMNS} FROM scheduled_tasks + WHERE enabled = 1 AND last_status != 'running' + AND (next_run_at IS NULL OR next_run_at <= datetime('now')) + ORDER BY next_run_at ASC" + ))?; + let rows = stmt.query_map([], row_to_task)?; + let mut out = Vec::new(); + for row in rows { + out.push(row?); + } + Ok(out) +} + +/// 应用重启后把上次中断的「运行中」任务标记为失败,避免永远卡住。 +pub fn reset_stale_running(db: &Connection) -> Result { + let n = db.execute( + "UPDATE scheduled_tasks SET last_status = 'error', + last_error = '应用重启,上次运行被中断', updated_at = datetime('now') + WHERE last_status = 'running'", + [], + )?; + Ok(n) +} + +fn row_to_task(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let enabled: i32 = row.get(5)?; + Ok(ScheduledTask { + id: row.get(0)?, + name: row.get(1)?, + agent_id: row.get(2)?, + prompt: row.get(3)?, + interval_minutes: row.get(4)?, + enabled: enabled != 0, + next_run_at: row.get(6)?, + last_run_at: row.get(7)?, + last_status: row.get(8)?, + last_result: row.get(9)?, + last_error: row.get(10)?, + created_at: row.get(11)?, + updated_at: row.get(12)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + fn test_conn() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(include_str!("schema.sql")).unwrap(); + conn + } + + fn sample(id: &str) -> ScheduledTask { + ScheduledTask { + id: id.to_string(), + name: "测试计划".to_string(), + agent_id: None, + prompt: "请总结今天的进展".to_string(), + interval_minutes: 30, + enabled: true, + next_run_at: None, + last_run_at: None, + last_status: "idle".to_string(), + last_result: None, + last_error: None, + created_at: String::new(), + updated_at: String::new(), + } + } + + #[test] + fn insert_sets_future_next_run_and_not_due() { + let conn = test_conn(); + insert(&conn, &sample("t1")).unwrap(); + let t = get(&conn, "t1").unwrap().unwrap(); + assert!(t.next_run_at.is_some()); + // 下次执行在未来,不应出现在到点列表 + assert!(list_due(&conn).unwrap().is_empty()); + } + + #[test] + fn due_then_running_then_finish_advances_schedule() { + let conn = test_conn(); + insert(&conn, &sample("t1")).unwrap(); + conn.execute( + "UPDATE scheduled_tasks SET next_run_at = datetime('now', '-1 minutes')", + [], + ) + .unwrap(); + assert_eq!(list_due(&conn).unwrap().len(), 1); + + mark_running(&conn, "t1").unwrap(); + // 运行中的计划不会被重复调度 + assert!(list_due(&conn).unwrap().is_empty()); + + finish_run(&conn, "t1", true, Some("已完成"), None).unwrap(); + let t = get(&conn, "t1").unwrap().unwrap(); + assert_eq!(t.last_status, "success"); + assert_eq!(t.last_result.as_deref(), Some("已完成")); + assert!(t.last_run_at.is_some()); + assert!(t.next_run_at.is_some()); + } + + #[test] + fn disable_clears_schedule_and_enable_recomputes() { + let conn = test_conn(); + insert(&conn, &sample("t1")).unwrap(); + set_enabled(&conn, "t1", false).unwrap(); + let t = get(&conn, "t1").unwrap().unwrap(); + assert!(!t.enabled); + assert!(t.next_run_at.is_none()); + + set_enabled(&conn, "t1", true).unwrap(); + let t = get(&conn, "t1").unwrap().unwrap(); + assert!(t.enabled); + assert!(t.next_run_at.is_some()); + } + + #[test] + fn reset_stale_running_marks_interrupted_as_error() { + let conn = test_conn(); + insert(&conn, &sample("t1")).unwrap(); + mark_running(&conn, "t1").unwrap(); + assert_eq!(reset_stale_running(&conn).unwrap(), 1); + let t = get(&conn, "t1").unwrap().unwrap(); + assert_eq!(t.last_status, "error"); + } +} diff --git a/crates/core/src/schema.sql b/crates/core/src/schema.sql index b4ab62d..1a6f26d 100644 --- a/crates/core/src/schema.sql +++ b/crates/core/src/schema.sql @@ -106,3 +106,19 @@ CREATE TABLE IF NOT EXISTS mcp_servers ( enabled INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); + +CREATE TABLE IF NOT EXISTS scheduled_tasks ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + agent_id TEXT, + prompt TEXT NOT NULL DEFAULT '', + interval_minutes INTEGER NOT NULL DEFAULT 60, + enabled INTEGER NOT NULL DEFAULT 1, + next_run_at TEXT, + last_run_at TEXT, + last_status TEXT NOT NULL DEFAULT 'idle', + last_result TEXT, + last_error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c1f79ad..11a9740 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -13,7 +13,7 @@ ``` xianren_studio/ ├── apps/desktop/ Tauri 桌面壳(Rust 命令层、tauri.conf.json、推荐模型/预制智能体/预制工作流默认 JSON、工作流执行引擎 workflow.rs) -├── crates/core/ 领域核心:SQLite(模型注册表、智能体、工作流、会话、消息、设置) +├── crates/core/ 领域核心:SQLite(模型注册表、智能体、工作流、定时计划、会话、消息、设置) ├── crates/engine/ llama-server 生命周期 + 流式/非流式聊天(本地与远程 OpenAI 兼容) ├── crates/download/ 分片断点续传下载器 ├── crates/api/ OpenAI 兼容本地 API 服务(axum) @@ -33,7 +33,7 @@ React UI(ui/src) ▼ apps/desktop/src/commands.rs ← 所有 Tauri 命令(invoke_handler 注册于 lib.rs) │ - ├── crates/core(SQLite:models / agents / workflows / sessions / settings) + ├── crates/core(SQLite:models / agents / workflows / scheduled_tasks / sessions / settings) ├── crates/engine(llama-server 子进程 / 远程 OpenAI API) ├── crates/download(下载任务) └── crates/api(本地 API 服务) @@ -48,7 +48,7 @@ apps/desktop/src/commands.rs ← 所有 Tauri 命令(invoke_handler 注册于 ``` ui/src/ ├── main.tsx 入口 -├── App.tsx 导航栏、路由(9 个页面)、全局事件订阅(引擎/下载/模型/建议) +├── App.tsx 导航栏、路由(9 个页面)、全局事件订阅(引擎/下载/模型/建议/定时计划/会话) ├── api.ts Tauri invoke 封装 + 全部请求/事件 TypeScript 类型 ├── store.ts zustand 全局状态(见下) ├── styles.css 全局样式 @@ -76,6 +76,7 @@ ui/src/ | `agents` / `agentsLoaded` | 智能体列表(预制 + 自定义)与加载标记 | | `agentConversations` | 智能体 id → 会话列表(各智能体会话隔离,key 为智能体 id) | | `workflows` / `workflowsLoaded` | 工作流列表(预制 + 自定义)与加载标记 | +| `scheduledTasks` | 定时计划列表(任务页) | | `models` | 模型列表(含 `enabled`、`kind`、`file_name` 等) | | `engine` | 引擎状态(running / model / port) | | `deployStates` / `deployProgress` | 各模型部署状态(loading/ready/error)与进度 | @@ -95,6 +96,7 @@ ui/src/ ### 4.1 `lib.rs` - `run()`:初始化数据目录(`%APPDATA%\XianrenStudio`)、日志、panic 钩子;构建 Tauri 应用并注册 `invoke_handler`。 +- `setup` 末尾启动**定时计划后台调度循环**(tokio interval 每 30 秒调用 `run_due_scheduled_tasks`)。 - 数据目录:`logs/`、`models/`、`engines/`、`recommendations/`。 ### 4.2 `commands.rs`(命令分组) @@ -103,6 +105,7 @@ ui/src/ | --- | --- | | 智能体 | `list_agents`、`add_agent`、`update_agent`、`remove_agent`、`set_agent_enabled`、`restore_preset_agents` | | 工作流 | `list_workflows`、`add_workflow`、`update_workflow`、`remove_workflow`、`set_workflow_enabled`、`restore_preset_workflows`、`run_workflow` | +| 定时计划 | `list_scheduled_tasks`、`add_scheduled_task`、`update_scheduled_task`、`remove_scheduled_task`、`set_scheduled_task_enabled`、`run_scheduled_task_now` | | 应用/设置 | `app_info`、`settings_get`、`settings_set` | | 模型 | `list_models`、`import_model`、`remove_model`、`set_model_enabled`、`scan_models`、`add_remote_model` | | 模型广场 | `search_models`、`list_model_files`、`list_recommended_models`、`import_recommendations`、`fetch_model_page` | @@ -125,6 +128,9 @@ ui/src/ - `seed_preset_agents`:启动时首次写入 `default_agents.json` 中的预制智能体(`settings.preset_agents_seeded` 标记只执行一次);`restore_preset_agents` 命令可随时补齐缺失预制体。 - `run_workflow`:加载工作流节点/连线 → 交给 `workflow.rs` 执行引擎按拓扑顺序运行;LLM 节点经 `resolve_workflow_node_model` 解析模型(节点 → 工作流默认 → 运行参数 → 自动回退),`call_model_text` 非流式调用本地引擎或远程 API,逐节点推送 `workflow://node-status` 事件。 - `seed_preset_workflows`:启动时首次写入 `default_workflows.json` 中的预制工作流(`settings.preset_workflows_seeded` 标记只执行一次);`restore_preset_workflows` 命令可随时补齐缺失预制体。 +- `run_due_scheduled_tasks`:后台调度入口,由 `lib.rs` 启动的 tokio 循环每 30 秒调用一次;先清理重启前中断的「运行中」任务,再取出到点的启用计划逐个标记并异步执行。 +- `execute_scheduled_task` / `run_scheduled_task_once`:定时计划单次执行——解析执行智能体(指定智能体已删除时回退「通用助手」)与模型(`resolve_scheduled_model`:智能体默认模型 → 第一个本地模型 → 第一个已启用在线模型),在该智能体作用域内创建/复用空会话,写入用户消息并注入智能体系统提示词,本地模型未运行则自动拉起引擎,最后经 `call_model_messages` 调用模型并把助手回答写回数据库。 +- `call_model_messages`:`call_model_text` 的通用版,可携带 system + 多轮消息调用本地引擎或远程 OpenAI 兼容 API(定时计划复用)。 **工作流执行引擎(`apps/desktop/src/workflow.rs`):** @@ -157,6 +163,7 @@ ui/src/ | `conversations` | `title`、`model_id`、`system_prompt`、`agent_id`、`pinned`、`favorite`、`tools_json` | 会话;`agent_id` 为空表示普通对话,否则属于对应智能体 | | `agents` | `kind`(preset/custom)、`name`、`icon`、`description`、`system_prompt`、`model_id`、`enabled` | 智能体(预制 + 自定义) | | `workflows` | `kind`(preset/custom)、`name`、`icon`、`description`、`nodes_json`、`edges_json`、`model_id`、`enabled` | 工作流(节点/连线以 JSON 存储,节点含 type/label/position/data) | +| `scheduled_tasks` | `name`、`agent_id`、`prompt`、`interval_minutes`、`enabled`、`next_run_at`、`last_run_at`、`last_status`(idle/running/success/error)、`last_result`、`last_error` | 定时计划;`next_run_at` 由 SQLite `datetime('now', '+N minutes')` 计算(UTC),每次执行完成后推进 | | `messages` | `role`、`model_id`、`content`、`tokens_in/out`、`elapsed_ms`、`first_token_ms`、`images_json` | 消息;`model_id` 记录该回答所用模型(回答下方展示模型名) | | `message_versions` | `content`、`tokens_out`、`seq` | 重新生成前的旧版本 | | `skills` | `name`、`description`、`content`、`enabled` | 技能工具库 | @@ -184,6 +191,10 @@ ui/src/ 工作流 CRUD(`list/get/insert/update/delete/set_enabled`)与预制体批量写入 `insert_presets_if_missing`(按 id 忽略已存在);节点 `WorkflowNode`(type=start/llm/text/end、position、data)与连线 `WorkflowEdge` 以 JSON 列存储,序列化/反序列化在读写时完成。 +### 5.8 `scheduled_tasks.rs` + +定时计划 CRUD 与运行状态落库:`list/get/insert/update/delete/set_enabled`(停用时清空 `next_run_at`,启用时重新计算)、`mark_running` / `finish_run`(写执行结果并推进下次执行时间)、`list_due`(到点且未运行中的启用计划)、`reset_stale_running`(应用重启后把中断的「运行中」标记为失败)。 + --- ## 6. 引擎与远程调用(`crates/engine`) @@ -227,6 +238,8 @@ ui/src/ | `download://started/progress/done/error` | 后端→前端 | 下载任务进度 | | `server://status` | 后端→前端 | 本地 API 服务状态 | | `workflow://node-status` | 后端→前端 | 工作流节点运行状态(running/done/error)与输出文本 | +| `scheduled://updated` | 后端→前端 | 定时计划状态变化(创建/编辑/启停/执行完成),前端刷新列表 | +| `conversations://updated` | 后端→前端 | 定时计划执行后会话列表变化(供智能体会话页刷新) | --- diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 90c71b2..bd3cd63 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -147,6 +147,19 @@ ## 7. 任务页(`/tasks`) +### 7.1 定时计划 + +- 页面顶部新增「定时计划」区块,支持新建 / 编辑 / 删除 / 启用停用 / 立即执行。 +- **执行智能体**:每条计划指定一个智能体执行(下拉选择,默认「通用助手」),执行时使用该智能体的人设与默认模型;若指定智能体已被删除,自动回退到「通用助手」。 +- **执行内容**:计划名称 + 提示词(每次到点发送给智能体的任务内容)。 +- **执行间隔**:以分钟 / 小时 / 天为单位,提供快捷预设(30 分钟 / 1 小时 / 6 小时 / 1 天)与自定义;后端每 30 秒检查一次到点的计划。 +- **执行方式**:到点后自动在对应智能体名下创建(或复用空)会话,写入用户消息 → 调用大模型(本地模型未启动时自动拉起引擎,在线 API 直接调用)→ 写入助手回答;结果可在该智能体的聊天页查看。 +- 每条计划展示:执行智能体、下次执行时间、上次执行时间、状态(待执行 / 执行中 / 成功 / 失败)与结果 / 错误摘要。 +- 没有可用大模型时该次执行标记失败(提示先部署本地模型或启用在线模型),并按间隔继续安排下次执行。 +- 应用重启会清理上次中断的「执行中」状态并标记为失败,避免计划永远卡住。 + +### 7.2 下载 / 部署任务 + - 展示下载任务与部署任务的实时进度、状态(进行中 / 完成 / 失败)。 - 空状态提示:暂无任务时引导去模型广场下载或部署本地模型。 @@ -193,6 +206,7 @@ ### 2026-08-17 +- 任务页新增「定时计划」:可指定执行智能体(默认通用助手)、执行内容与间隔(分钟 / 小时 / 天),后台到点自动在智能体会话中执行并记录结果;支持立即执行 / 编辑 / 删除 / 启用停用。 - 工作流画布顶栏微调:名称输入框宽度改为按文本实际宽度计算(中文按 2 倍字符宽折算,可完整显示名称),空间不足时自动收缩,保证右侧「保存 / 运行」按钮不被遮挡。 ### 2026-08-16 diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 49dd8eb..073f2b6 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -43,6 +43,7 @@ export default function App() { const refreshConversations = useStore((s) => s.refreshConversations); const refreshAgents = useStore((s) => s.refreshAgents); const refreshWorkflows = useStore((s) => s.refreshWorkflows); + const refreshScheduledTasks = useStore((s) => s.refreshScheduledTasks); const setDeployState = useStore((s) => s.setDeployState); const setDeployProgress = useStore((s) => s.setDeployProgress); const setChatSuggestions = useStore((s) => s.setChatSuggestions); @@ -65,6 +66,7 @@ export default function App() { refreshConversations(); refreshAgents(); refreshWorkflows(); + refreshScheduledTasks(); const un1 = onEvent("engine://status", () => refreshEngine()); const un2 = onEvent("server://status", () => refreshServer()); const un3start = onEvent("download://started", (e) => { @@ -152,6 +154,8 @@ export default function App() { const un7 = onEvent("chat://suggestions", (e) => { setChatSuggestions(e.message_id, e.suggestions); }); + const un8 = onEvent("scheduled://updated", () => refreshScheduledTasks()); + const un9 = onEvent("conversations://updated", () => refreshConversations()); return () => { un1.then((f) => f()); un2.then((f) => f()); @@ -163,6 +167,8 @@ export default function App() { un5.then((f) => f()); un6.then((f) => f()); un7.then((f) => f()); + un8.then((f) => f()); + un9.then((f) => f()); }; }, [ refreshModels, @@ -171,6 +177,7 @@ export default function App() { refreshConversations, refreshAgents, refreshWorkflows, + refreshScheduledTasks, setDeployState, setDeployProgress, setChatSuggestions, diff --git a/ui/src/api.ts b/ui/src/api.ts index afffcdd..5b6517a 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -160,6 +160,30 @@ export interface WorkflowNodeStatusEvent { text: string | null; } +export interface ScheduledTask { + id: string; + name: string; + agent_id: string | null; + prompt: string; + interval_minutes: number; + enabled: boolean; + next_run_at: string | null; + last_run_at: string | null; + last_status: string; + last_result: string | null; + last_error: string | null; + created_at: string; + updated_at: string; +} + +export interface ScheduledTaskInput { + name: string; + agent_id: string | null; + prompt: string; + interval_minutes: number; + enabled: boolean; +} + export interface McpServer { id: string; name: string; @@ -363,6 +387,17 @@ export const api = { input: string; model_id: string | null; }) => invoke("run_workflow", { payload }), + listScheduledTasks: () => invoke("list_scheduled_tasks"), + addScheduledTask: (input: ScheduledTaskInput) => + invoke("add_scheduled_task", { input }), + updateScheduledTask: (id: string, input: ScheduledTaskInput) => + invoke("update_scheduled_task", { id, input }), + removeScheduledTask: (id: string) => + invoke("remove_scheduled_task", { id }), + setScheduledTaskEnabled: (id: string, enabled: boolean) => + invoke("set_scheduled_task_enabled", { id, enabled }), + runScheduledTaskNow: (id: string) => + invoke("run_scheduled_task_now", { id }), listModels: () => invoke("list_models"), importModel: (path: string) => invoke("import_model", { path }), removeModel: (id: string) => invoke("remove_model", { id }), diff --git a/ui/src/components/Icon.tsx b/ui/src/components/Icon.tsx index b55cc83..dce0938 100644 --- a/ui/src/components/Icon.tsx +++ b/ui/src/components/Icon.tsx @@ -103,6 +103,25 @@ const paths: Record = { paperclip: ( ), + clock: ( + <> + + + + ), + calendar: ( + <> + + + + ), + refresh: ( + <> + + + + ), + play: , }; export default function Icon({ diff --git a/ui/src/pages/TasksPage.tsx b/ui/src/pages/TasksPage.tsx index fcfaf1a..3ccbd09 100644 --- a/ui/src/pages/TasksPage.tsx +++ b/ui/src/pages/TasksPage.tsx @@ -1,41 +1,552 @@ +import { useEffect, useMemo, useState } from "react"; +import { api, ScheduledTask, ScheduledTaskInput } from "../api"; import { useStore, TaskItem } from "../store"; import Icon from "../components/Icon"; +const PRESET_GENERAL_ASSISTANT = "preset-general-assistant"; + +const INTERVAL_PRESETS: { label: string; value: number; unit: IntervalUnit }[] = [ + { label: "30 分钟", value: 30, unit: "min" }, + { label: "1 小时", value: 1, unit: "hour" }, + { label: "6 小时", value: 6, unit: "hour" }, + { label: "1 天", value: 1, unit: "day" }, +]; + +type IntervalUnit = "min" | "hour" | "day"; + +interface Draft { + name: string; + agentId: string; + prompt: string; + intervalValue: number; + intervalUnit: IntervalUnit; + enabled: boolean; +} + +function intervalToMinutes(value: number, unit: IntervalUnit) { + const factor = unit === "hour" ? 60 : unit === "day" ? 1440 : 1; + return Math.max(1, Math.round(value * factor)); +} + +function fmtInterval(minutes: number) { + if (minutes % 1440 === 0) { + const days = minutes / 1440; + return days === 1 ? "每天" : `每 ${days} 天`; + } + if (minutes % 60 === 0) { + const hours = minutes / 60; + return hours === 1 ? "每小时" : `每 ${hours} 小时`; + } + return `每 ${minutes} 分钟`; +} + +function fmtDbTime(s: string | null) { + if (!s) return "—"; + const d = new Date(s.replace(" ", "T") + "Z"); + if (Number.isNaN(d.getTime())) return s; + return d.toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +} + export default function TasksPage() { const tasks = useStore((s) => s.tasks); const clearFinishedTasks = useStore((s) => s.clearFinishedTasks); - const sorted = [...tasks].sort((a, b) => b.updated_at - a.updated_at); + const scheduledTasks = useStore((s) => s.scheduledTasks); + const refreshScheduledTasks = useStore((s) => s.refreshScheduledTasks); + const agents = useStore((s) => s.agents); + const refreshAgents = useStore((s) => s.refreshAgents); + const [msg, setMsg] = useState(null); + const [busy, setBusy] = useState(false); + const [editing, setEditing] = useState(null); + const [showModal, setShowModal] = useState(false); + + useEffect(() => { + refreshScheduledTasks(); + refreshAgents(); + }, [refreshScheduledTasks, refreshAgents]); + + const sorted = useMemo( + () => [...tasks].sort((a, b) => b.updated_at - a.updated_at), + [tasks], + ); const hasFinished = tasks.some((t) => t.status !== "running"); + const agentNameById = useMemo(() => { + const map = new Map(); + for (const a of agents) map.set(a.id, { name: a.name, icon: a.icon }); + return map; + }, [agents]); + + const defaultAgentId = useMemo(() => { + if (agents.some((a) => a.id === PRESET_GENERAL_ASSISTANT)) { + return PRESET_GENERAL_ASSISTANT; + } + return agents[0]?.id ?? ""; + }, [agents]); + + function openNew() { + setEditing(null); + setShowModal(true); + } + + function openEdit(task: ScheduledTask) { + setEditing(task); + setShowModal(true); + } + + async function handleSave(draft: Draft) { + const intervalMinutes = intervalToMinutes(draft.intervalValue, draft.intervalUnit); + const input: ScheduledTaskInput = { + name: draft.name.trim(), + agent_id: draft.agentId || null, + prompt: draft.prompt.trim(), + interval_minutes: intervalMinutes, + enabled: draft.enabled, + }; + setBusy(true); + try { + if (editing) { + await api.updateScheduledTask(editing.id, input); + setMsg("定时计划已更新"); + } else { + await api.addScheduledTask(input); + setMsg("定时计划已创建"); + } + await refreshScheduledTasks(); + setShowModal(false); + } catch (e) { + setMsg(`保存失败:${String(e)}`); + } finally { + setBusy(false); + } + } + + async function handleRunNow(task: ScheduledTask) { + setBusy(true); + try { + await api.runScheduledTaskNow(task.id); + setMsg(`「${task.name}」已开始执行`); + } catch (e) { + setMsg(`执行失败:${String(e)}`); + } finally { + setBusy(false); + } + } + + async function handleToggle(task: ScheduledTask) { + try { + await api.setScheduledTaskEnabled(task.id, !task.enabled); + await refreshScheduledTasks(); + } catch (e) { + setMsg(`操作失败:${String(e)}`); + } + } + + async function handleDelete(task: ScheduledTask) { + if (!confirm(`确定删除定时计划「${task.name}」吗?`)) return; + try { + await api.removeScheduledTask(task.id); + setMsg("定时计划已删除"); + await refreshScheduledTasks(); + } catch (e) { + setMsg(`删除失败:${String(e)}`); + } + } return (
-
+

任务

-

正在进行的下载与模型部署状态

+

+ 定时计划由指定智能体到点自动执行;下方展示下载与模型部署进度 +

- {hasFinished ? ( - + ) : null} + +
+
+ + {msg ? ( +
+ {msg} + +
+ ) : null} + +
+
+ 定时计划 + + {scheduledTasks.length} + +
+ {scheduledTasks.length === 0 ? ( +
+ 还没有定时计划,点击右上角「新建定时计划」,选择智能体(默认通用助手)与执行内容,到点自动运行 +
+ ) : ( +
+ {scheduledTasks.map((task) => ( + handleRunNow(task)} + onEdit={() => openEdit(task)} + onToggle={() => handleToggle(task)} + onDelete={() => handleDelete(task)} + /> + ))} +
+ )} +
+ +
+
下载 / 部署任务
+ {sorted.length === 0 ? ( +
+ 暂无任务,去模型广场下载模型或部署本地模型后会自动出现在这里 +
+ ) : ( +
+ {sorted.map((t) => ( + + ))} +
+ )} +
+ + {showModal ? ( + setShowModal(false)} + /> + ) : null} +
+ ); +} + +function ScheduledTaskCard({ + task, + agentNameById, + onRun, + onEdit, + onToggle, + onDelete, +}: { + task: ScheduledTask; + agentNameById: Map; + onRun: () => void; + onEdit: () => void; + onToggle: () => void; + onDelete: () => void; +}) { + const agent = task.agent_id ? agentNameById.get(task.agent_id) : undefined; + const agentName = agent?.name ?? (task.agent_id ? "通用助手" : "普通对话"); + const agentIcon = agent?.icon ?? (task.agent_id ? "🤖" : "🗒️"); + return ( +
+
+
+ +
+
+
+ {task.name} + +
+
+ {agentIcon} + + 执行智能体:{agentName} + {!task.enabled ? " · 已停用" : ""} + +
+
+ +
+ +
{task.prompt}
+ +
+
+ + + {fmtInterval(task.interval_minutes)} + {task.enabled ? ` · 下次执行 ${fmtDbTime(task.next_run_at)}` : ""} + +
+ {task.last_run_at ? ( +
+ + 上次执行 {fmtDbTime(task.last_run_at)} +
+ ) : null} + {task.last_error ? ( +
失败原因:{task.last_error}
+ ) : task.last_result ? ( +
结果:{task.last_result}
) : null}
- {sorted.length === 0 ? ( -
- 暂无任务,去模型广场下载模型或部署本地模型后会自动出现在这里 -
- ) : ( -
- {sorted.map((t) => ( - - ))} -
- )} +
+ + + +
); } +function ScheduleStatusChip({ status, enabled }: { status: string; enabled: boolean }) { + if (!enabled) { + return ( + + 已停用 + + ); + } + const map: Record = { + idle: { cls: "bg-slate-500/20 text-slate-300", label: "待执行" }, + running: { cls: "bg-sky-500/20 text-sky-300", label: "执行中" }, + success: { cls: "bg-emerald-500/20 text-emerald-300", label: "成功" }, + error: { cls: "bg-red-500/20 text-red-300", label: "失败" }, + }; + const s = map[status] ?? map.idle; + return {s.label}; +} + +function ScheduleModal({ + task, + agents, + defaultAgentId, + busy, + onSave, + onClose, +}: { + task: ScheduledTask | null; + agents: { id: string; name: string; icon: string; enabled: boolean }[]; + defaultAgentId: string; + busy: boolean; + onSave: (draft: Draft) => void; + onClose: () => void; +}) { + const [draft, setDraft] = useState(() => ({ + name: task?.name ?? "", + agentId: task?.agent_id ?? defaultAgentId, + prompt: task?.prompt ?? "", + intervalValue: task ? splitInterval(task.interval_minutes).value : 1, + intervalUnit: task ? splitInterval(task.interval_minutes).unit : "hour", + enabled: task?.enabled ?? true, + })); + + const valid = draft.name.trim() && draft.prompt.trim() && draft.intervalValue >= 1; + + return ( +
+
e.stopPropagation()} + > +
+ {task ? "编辑定时计划" : "新建定时计划"} + +
+ +
+
+ + setDraft({ ...draft, name: e.target.value })} + /> +
+ +
+ + +

+ 默认使用「通用助手」;执行时会使用该智能体的人设与默认模型 +

+
+ +
+ +