feat: 任务页新增定时计划——指定执行智能体(默认通用助手)与间隔,后台到点自动在智能体会话中执行并记录结果

This commit is contained in:
xianrenge
2026-08-17 12:21:22 +08:00
parent 45ed10f64f
commit 1a13915f72
12 changed files with 1327 additions and 22 deletions
+374 -2
View File
@@ -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>,
+18
View File
@@ -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,
+2
View File
@@ -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;
+290
View File
@@ -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");
}
}
+16
View File
@@ -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
View File
@@ -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 UIui/src
apps/desktop/src/commands.rs ← 所有 Tauri 命令(invoke_handler 注册于 lib.rs
├── crates/coreSQLitemodels / agents / workflows / sessions / settings
├── crates/coreSQLitemodels / agents / workflows / scheduled_tasks / sessions / settings
├── crates/enginellama-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` | 后端→前端 | 定时计划执行后会话列表变化(供智能体会话页刷新) |
---
+14
View File
@@ -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
+7
View File
@@ -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,
+35
View File
@@ -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 }),
+19
View File
@@ -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
View File
@@ -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">
+8
View File
@@ -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() }),