feat: 任务页新增定时计划——指定执行智能体(默认通用助手)与间隔,后台到点自动在智能体会话中执行并记录结果
This commit is contained in:
@@ -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<String, String> {
|
||||
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<ChatMessage>,
|
||||
temperature: f32,
|
||||
max_tokens: u32,
|
||||
) -> Result<String, String> {
|
||||
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<String>,
|
||||
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<Vec<xianren_core::ScheduledTask>, 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<xianren_core::ScheduledTask, String> {
|
||||
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::<App>();
|
||||
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<CoreApp>,
|
||||
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<String, String> {
|
||||
// 指定智能体已被删除时回退到「通用助手」,仍缺失则按普通对话执行
|
||||
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<ChatMessage> = {
|
||||
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<ModelInfo, String> {
|
||||
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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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<String>,
|
||||
pub prompt: String,
|
||||
pub interval_minutes: i64,
|
||||
pub enabled: bool,
|
||||
pub next_run_at: Option<String>,
|
||||
pub last_run_at: Option<String>,
|
||||
/// idle(从未执行)/ running / success / error
|
||||
pub last_status: String,
|
||||
pub last_result: Option<String>,
|
||||
pub last_error: Option<String>,
|
||||
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<Vec<ScheduledTask>> {
|
||||
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<Option<ScheduledTask>> {
|
||||
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<Vec<ScheduledTask>> {
|
||||
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<usize> {
|
||||
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<ScheduledTask> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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'))
|
||||
);
|
||||
+16
-3
@@ -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` | 后端→前端 | 定时计划执行后会话列表变化(供智能体会话页刷新) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<DownloadProgressEvent>("download://started", (e) => {
|
||||
@@ -152,6 +154,8 @@ export default function App() {
|
||||
const un7 = onEvent<ChatSuggestionsEvent>("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,
|
||||
|
||||
@@ -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<WorkflowRunResult>("run_workflow", { payload }),
|
||||
listScheduledTasks: () => invoke<ScheduledTask[]>("list_scheduled_tasks"),
|
||||
addScheduledTask: (input: ScheduledTaskInput) =>
|
||||
invoke<ScheduledTask>("add_scheduled_task", { input }),
|
||||
updateScheduledTask: (id: string, input: ScheduledTaskInput) =>
|
||||
invoke<void>("update_scheduled_task", { id, input }),
|
||||
removeScheduledTask: (id: string) =>
|
||||
invoke<void>("remove_scheduled_task", { id }),
|
||||
setScheduledTaskEnabled: (id: string, enabled: boolean) =>
|
||||
invoke<void>("set_scheduled_task_enabled", { id, enabled }),
|
||||
runScheduledTaskNow: (id: string) =>
|
||||
invoke<void>("run_scheduled_task_now", { id }),
|
||||
listModels: () => invoke<ModelInfo[]>("list_models"),
|
||||
importModel: (path: string) => invoke<ModelInfo>("import_model", { path }),
|
||||
removeModel: (id: string) => invoke<void>("remove_model", { id }),
|
||||
|
||||
@@ -103,6 +103,25 @@ const paths: Record<string, ReactNode> = {
|
||||
paperclip: (
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||
),
|
||||
clock: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" />
|
||||
</>
|
||||
),
|
||||
calendar: (
|
||||
<>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<path d="M16 2v4M8 2v4M3 10h18" />
|
||||
</>
|
||||
),
|
||||
refresh: (
|
||||
<>
|
||||
<path d="M23 4v6h-6M1 20v-6h6" />
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
||||
</>
|
||||
),
|
||||
play: <polygon points="5 3 19 12 5 21 5 3" />,
|
||||
};
|
||||
|
||||
export default function Icon({
|
||||
|
||||
+528
-17
@@ -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<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [editing, setEditing] = useState<ScheduledTask | null>(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<string, { name: string; icon: string }>();
|
||||
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 (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">任务</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">正在进行的下载与模型部署状态</p>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
定时计划由指定智能体到点自动执行;下方展示下载与模型部署进度
|
||||
</p>
|
||||
</div>
|
||||
{hasFinished ? (
|
||||
<button className="btn-secondary" onClick={clearFinishedTasks}>
|
||||
清除已完成
|
||||
<div className="flex shrink-0 gap-2">
|
||||
{hasFinished ? (
|
||||
<button className="btn-secondary" onClick={clearFinishedTasks}>
|
||||
清除已完成
|
||||
</button>
|
||||
) : null}
|
||||
<button className="btn-primary" onClick={openNew} disabled={busy}>
|
||||
+ 新建定时计划
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{msg ? (
|
||||
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-panel px-4 py-2 text-xs text-slate-300">
|
||||
<span>{msg}</span>
|
||||
<button className="text-slate-500 hover:text-slate-200" onClick={() => setMsg(null)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="mb-8">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs uppercase text-slate-500">
|
||||
定时计划
|
||||
<span className="rounded bg-panel-2 px-1.5 py-0.5 text-[10px] text-slate-400">
|
||||
{scheduledTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
{scheduledTasks.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border bg-panel/50 px-4 py-8 text-center text-sm text-slate-500">
|
||||
还没有定时计划,点击右上角「新建定时计划」,选择智能体(默认通用助手)与执行内容,到点自动运行
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
{scheduledTasks.map((task) => (
|
||||
<ScheduledTaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
agentNameById={agentNameById}
|
||||
onRun={() => handleRunNow(task)}
|
||||
onEdit={() => openEdit(task)}
|
||||
onToggle={() => handleToggle(task)}
|
||||
onDelete={() => handleDelete(task)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-2 text-xs uppercase text-slate-500">下载 / 部署任务</div>
|
||||
{sorted.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border bg-panel/50 px-4 py-8 text-center text-sm text-slate-500">
|
||||
暂无任务,去模型广场下载模型或部署本地模型后会自动出现在这里
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sorted.map((t) => (
|
||||
<TaskRow key={t.id} task={t} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{showModal ? (
|
||||
<ScheduleModal
|
||||
task={editing}
|
||||
agents={agents}
|
||||
defaultAgentId={defaultAgentId}
|
||||
busy={busy}
|
||||
onSave={handleSave}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduledTaskCard({
|
||||
task,
|
||||
agentNameById,
|
||||
onRun,
|
||||
onEdit,
|
||||
onToggle,
|
||||
onDelete,
|
||||
}: {
|
||||
task: ScheduledTask;
|
||||
agentNameById: Map<string, { name: string; icon: string }>;
|
||||
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 (
|
||||
<div
|
||||
className={`flex flex-col rounded-xl border border-border bg-panel p-4 ${
|
||||
task.enabled ? "" : "opacity-60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-panel-2 text-xl">
|
||||
<Icon name="clock" className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold">{task.name}</span>
|
||||
<ScheduleStatusChip status={task.last_status} enabled={task.enabled} />
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1 text-xs text-slate-400">
|
||||
<span>{agentIcon}</span>
|
||||
<span className="truncate">
|
||||
执行智能体:{agentName}
|
||||
{!task.enabled ? " · 已停用" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Toggle checked={task.enabled} onChange={onToggle} />
|
||||
</div>
|
||||
|
||||
<div className="mt-2 line-clamp-2 text-xs text-slate-400">{task.prompt}</div>
|
||||
|
||||
<div className="mt-3 space-y-1 text-[11px] text-slate-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon name="calendar" className="h-3 w-3" />
|
||||
<span>
|
||||
{fmtInterval(task.interval_minutes)}
|
||||
{task.enabled ? ` · 下次执行 ${fmtDbTime(task.next_run_at)}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
{task.last_run_at ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon name="refresh" className="h-3 w-3" />
|
||||
<span>上次执行 {fmtDbTime(task.last_run_at)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{task.last_error ? (
|
||||
<div className="line-clamp-2 text-red-400/90">失败原因:{task.last_error}</div>
|
||||
) : task.last_result ? (
|
||||
<div className="line-clamp-2 text-emerald-400/80">结果:{task.last_result}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{sorted.length === 0 ? (
|
||||
<div className="mt-16 text-center text-sm text-slate-500">
|
||||
暂无任务,去模型广场下载模型或部署本地模型后会自动出现在这里
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sorted.map((t) => (
|
||||
<TaskRow key={t.id} task={t} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button className="btn-primary flex-1 px-2 py-1.5 text-xs" onClick={onRun}>
|
||||
<Icon name="play" className="mr-1 inline h-3.5 w-3.5" />
|
||||
立即执行
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg border border-border bg-panel-2 px-2.5 py-1.5 text-xs text-slate-400 hover:text-slate-200"
|
||||
onClick={onEdit}
|
||||
title="编辑"
|
||||
>
|
||||
<Icon name="edit" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg border border-border bg-panel-2 px-2.5 py-1.5 text-xs text-slate-400 hover:text-red-400"
|
||||
onClick={onDelete}
|
||||
title="删除"
|
||||
>
|
||||
<Icon name="trash" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleStatusChip({ status, enabled }: { status: string; enabled: boolean }) {
|
||||
if (!enabled) {
|
||||
return (
|
||||
<span className="shrink-0 rounded bg-panel-2 px-2 py-0.5 text-xs text-slate-400">
|
||||
已停用
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const map: Record<string, { cls: string; label: string }> = {
|
||||
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 <span className={`shrink-0 rounded px-2 py-0.5 text-xs ${s.cls}`}>{s.label}</span>;
|
||||
}
|
||||
|
||||
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<Draft>(() => ({
|
||||
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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-border bg-panel"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-3">
|
||||
<span className="text-sm font-medium">{task ? "编辑定时计划" : "新建定时计划"}</span>
|
||||
<button className="text-slate-500 hover:text-slate-200" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4 overflow-auto px-5 py-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-400">计划名称</label>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.name}
|
||||
placeholder="例如:每日工作总结"
|
||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-400">执行智能体</label>
|
||||
<select
|
||||
className="input"
|
||||
value={draft.agentId}
|
||||
onChange={(e) => setDraft({ ...draft, agentId: e.target.value })}
|
||||
>
|
||||
<option value="">普通对话(不指定智能体)</option>
|
||||
{agents.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.icon} {a.name}
|
||||
{a.enabled ? "" : "(已停用)"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-[11px] text-slate-500">
|
||||
默认使用「通用助手」;执行时会使用该智能体的人设与默认模型
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-400">执行内容</label>
|
||||
<textarea
|
||||
className="input min-h-[96px] resize-y"
|
||||
value={draft.prompt}
|
||||
placeholder="每次到点发送给智能体的任务内容,例如:把最近一周的会话按主题整理成报告"
|
||||
onChange={(e) => setDraft({ ...draft, prompt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-400">执行间隔</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{INTERVAL_PRESETS.map((p) => {
|
||||
const active =
|
||||
draft.intervalValue === p.value && draft.intervalUnit === p.unit;
|
||||
return (
|
||||
<button
|
||||
key={p.label}
|
||||
type="button"
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs transition-colors ${
|
||||
active
|
||||
? "border-accent bg-accent/20 text-white"
|
||||
: "border-border bg-panel-2 text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
onClick={() =>
|
||||
setDraft({ ...draft, intervalValue: p.value, intervalUnit: p.unit })
|
||||
}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="input w-24"
|
||||
value={draft.intervalValue}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, intervalValue: Number(e.target.value) || 1 })
|
||||
}
|
||||
/>
|
||||
<select
|
||||
className="input w-28"
|
||||
value={draft.intervalUnit}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, intervalUnit: e.target.value as IntervalUnit })
|
||||
}
|
||||
>
|
||||
<option value="min">分钟</option>
|
||||
<option value="hour">小时</option>
|
||||
<option value="day">天</option>
|
||||
</select>
|
||||
<span className="text-xs text-slate-500">
|
||||
{fmtInterval(intervalToMinutes(draft.intervalValue, draft.intervalUnit))}执行一次
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Toggle
|
||||
checked={draft.enabled}
|
||||
onChange={(v) => setDraft({ ...draft, enabled: v })}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">
|
||||
{draft.enabled ? "启用后到点自动执行" : "已停用,不会自动执行"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
||||
<button className="btn-secondary" onClick={onClose} disabled={busy}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => onSave(draft)}
|
||||
disabled={busy || !valid}
|
||||
>
|
||||
{busy ? "保存中…" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function splitInterval(minutes: number): { value: number; unit: IntervalUnit } {
|
||||
if (minutes % 1440 === 0) return { value: minutes / 1440, unit: "day" };
|
||||
if (minutes % 60 === 0) return { value: minutes / 60, unit: "hour" };
|
||||
return { value: minutes, unit: "min" };
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${
|
||||
checked ? "bg-accent" : "bg-panel-2"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-all ${
|
||||
checked ? "left-[22px]" : "left-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskRow({ task }: { task: TaskItem }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-panel p-4">
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Conversation,
|
||||
EngineStatus,
|
||||
ModelInfo,
|
||||
ScheduledTask,
|
||||
ServerStatus,
|
||||
Workflow,
|
||||
} from "./api";
|
||||
@@ -15,6 +16,7 @@ interface Store {
|
||||
agentConversations: Record<string, Conversation[]>;
|
||||
workflows: Workflow[];
|
||||
workflowsLoaded: boolean;
|
||||
scheduledTasks: ScheduledTask[];
|
||||
models: ModelInfo[];
|
||||
engine: EngineStatus | null;
|
||||
server: ServerStatus | null;
|
||||
@@ -30,6 +32,7 @@ interface Store {
|
||||
clearFinishedTasks: () => void;
|
||||
refreshAgents: () => Promise<void>;
|
||||
refreshWorkflows: () => Promise<void>;
|
||||
refreshScheduledTasks: () => Promise<void>;
|
||||
refreshModels: () => Promise<void>;
|
||||
refreshEngine: () => Promise<void>;
|
||||
refreshServer: () => Promise<void>;
|
||||
@@ -53,6 +56,7 @@ export const useStore = create<Store>((set) => ({
|
||||
agentConversations: {},
|
||||
workflows: [],
|
||||
workflowsLoaded: false,
|
||||
scheduledTasks: [],
|
||||
models: [],
|
||||
engine: null,
|
||||
server: null,
|
||||
@@ -88,6 +92,10 @@ export const useStore = create<Store>((set) => ({
|
||||
const workflows = await api.listWorkflows();
|
||||
set({ workflows, workflowsLoaded: true });
|
||||
},
|
||||
refreshScheduledTasks: async () => {
|
||||
const scheduledTasks = await api.listScheduledTasks();
|
||||
set({ scheduledTasks });
|
||||
},
|
||||
refreshModels: async () => set({ models: await api.listModels() }),
|
||||
refreshEngine: async () => set({ engine: await api.engineStatus() }),
|
||||
refreshServer: async () => set({ server: await api.serverStatus() }),
|
||||
|
||||
Reference in New Issue
Block a user