feat: 计划任务支持发送邮件(固定内容/大模型生成),设置页新增 SMTP 邮件配置与测试发送;'定时计划'更名为'计划任务'

This commit is contained in:
xianrenge
2026-08-17 13:13:37 +08:00
parent 6eb197a91a
commit 0f942182be
14 changed files with 673 additions and 30 deletions
Generated
+78
View File
@@ -322,6 +322,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bit-set"
version = "0.8.0"
@@ -1031,6 +1037,22 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "email-encoding"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd"
dependencies = [
"base64 0.23.1",
"memchr",
]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
[[package]]
name = "embed-resource"
version = "3.0.11"
@@ -1677,6 +1699,17 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hostname"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
dependencies = [
"cfg-if",
"libc",
"windows-link 0.2.1",
]
[[package]]
name = "html5ever"
version = "0.38.0"
@@ -2168,6 +2201,34 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "lettre"
version = "0.11.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae"
dependencies = [
"async-trait",
"base64 0.23.1",
"email-encoding",
"email_address",
"fastrand",
"futures-io",
"futures-util",
"hostname",
"httpdate",
"idna",
"mime",
"nom",
"percent-encoding",
"quoted_printable",
"rustls",
"socket2",
"tokio",
"tokio-rustls",
"url",
"webpki-roots",
]
[[package]]
name = "libappindicator"
version = "0.9.0"
@@ -2389,6 +2450,15 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -3024,6 +3094,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "quoted_printable"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
[[package]]
name = "r-efi"
version = "5.3.0"
@@ -3285,6 +3361,7 @@ version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
@@ -5597,6 +5674,7 @@ version = "0.1.0"
dependencies = [
"dirs 5.0.1",
"futures",
"lettre",
"reqwest 0.12.28",
"rusqlite",
"serde",
+1
View File
@@ -28,6 +28,7 @@ serde_json.workspace = true
tokio.workspace = true
reqwest.workspace = true
futures.workspace = true
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "builder", "tokio1", "tokio1-rustls-tls", "hostname"] }
uuid.workspace = true
dirs.workspace = true
rusqlite.workspace = true
+168 -12
View File
@@ -943,6 +943,20 @@ pub struct ScheduledTaskInput {
pub interval_minutes: i64,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub email_enabled: bool,
#[serde(default)]
pub email_to: String,
#[serde(default = "default_email_mode")]
pub email_mode: String,
#[serde(default)]
pub email_subject: String,
#[serde(default)]
pub email_body: String,
}
fn default_email_mode() -> String {
"fixed".to_string()
}
fn validate_scheduled_task_input(input: &ScheduledTaskInput) -> Result<(), String> {
@@ -955,6 +969,9 @@ fn validate_scheduled_task_input(input: &ScheduledTaskInput) -> Result<(), Strin
if input.interval_minutes < 1 {
return Err("执行间隔至少为 1 分钟".into());
}
if input.email_enabled && input.email_to.trim().is_empty() {
return Err("开启邮件发送后请填写收件人邮箱".into());
}
Ok(())
}
@@ -986,6 +1003,15 @@ pub fn add_scheduled_task(
last_status: "idle".to_string(),
last_result: None,
last_error: None,
email_enabled: input.email_enabled,
email_to: input.email_to.trim().to_string(),
email_mode: if input.email_mode == "llm" {
"llm".to_string()
} else {
"fixed".to_string()
},
email_subject: input.email_subject.trim().to_string(),
email_body: input.email_body.trim().to_string(),
created_at: String::new(),
updated_at: String::new(),
};
@@ -1018,6 +1044,15 @@ pub fn update_scheduled_task(
last_status: existing.last_status,
last_result: existing.last_result,
last_error: existing.last_error,
email_enabled: input.email_enabled,
email_to: input.email_to.trim().to_string(),
email_mode: if input.email_mode == "llm" {
"llm".to_string()
} else {
"fixed".to_string()
},
email_subject: input.email_subject.trim().to_string(),
email_body: input.email_body.trim().to_string(),
created_at: existing.created_at,
updated_at: String::new(),
};
@@ -1102,35 +1137,80 @@ pub async fn run_due_scheduled_tasks(app: &AppHandle) -> Result<(), String> {
Ok(())
}
/// 执行一次定时计划并落库:成功写入助手回答,失败记录错误信息。
struct ScheduledRunOutcome {
success: bool,
content: Option<String>,
error: Option<String>,
}
/// 执行一次计划任务并落库:成功写入助手回答,失败记录错误信息。
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 outcome = 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));
}
if !outcome.success {
tracing::warn!(
task = %task.name,
error = outcome.error.as_deref().unwrap_or_default(),
"scheduled task failed"
);
}
let _ = scheduled_db::finish_run(
&db,
&task.id,
outcome.success,
outcome.content.as_deref(),
outcome.error.as_deref(),
);
}
let _ = app.emit("scheduled://updated", ());
}
/// 单次执行:解析智能体与模型 → 创建/复用会话 → 写入问答 → 调用大模型
/// 单次执行:跑完大模型后,按任务配置发送邮件(固定内容 / 大模型生成)
async fn run_scheduled_task_once(
app: &AppHandle,
core: &CoreApp,
engine: &EngineManager,
task: &xianren_core::ScheduledTask,
) -> ScheduledRunOutcome {
let content = match run_scheduled_task_inner(app, core, engine, task).await {
Ok(c) => c,
Err(e) => {
return ScheduledRunOutcome {
success: false,
content: None,
error: Some(e),
};
}
};
if task.email_enabled {
if let Err(e) = send_task_email(core, task, &content).await {
return ScheduledRunOutcome {
success: false,
content: Some(content),
error: Some(format!("任务已执行,但邮件发送失败:{e}")),
};
}
}
ScheduledRunOutcome {
success: true,
content: Some(content),
error: None,
}
}
/// 单次执行主体:解析智能体与模型 → 创建/复用会话 → 写入问答 → 调用大模型。
async fn run_scheduled_task_inner(
app: &AppHandle,
core: &CoreApp,
engine: &EngineManager,
task: &xianren_core::ScheduledTask,
) -> Result<String, String> {
// 指定智能体已被删除时回退到「通用助手」,仍缺失则按普通对话执行
let (effective_agent_id, system_prompt, model) = {
@@ -1265,7 +1345,83 @@ async fn run_scheduled_task_once(
Ok(content)
}
/// 解析定时计划使用的大模型:智能体默认模型 → 第一个本地模型 → 第一个已启用在线模型
/// 按任务邮件配置发送:固定模式用填写的主题/正文,LLM 模式用智能体回答(主题留空时取回答首行)
async fn send_task_email(
core: &CoreApp,
task: &xianren_core::ScheduledTask,
content: &str,
) -> Result<(), String> {
let recipients = split_recipients(&task.email_to);
if recipients.is_empty() {
return Err("收件人为空".into());
}
let cfg = load_smtp_config(core)?;
let (subject, body) = if task.email_mode == "llm" {
let subject = if task.email_subject.trim().is_empty() {
content
.lines()
.find(|l| !l.trim().is_empty())
.map(|l| l.trim().chars().take(78).collect::<String>())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| task.name.clone())
} else {
task.email_subject.trim().to_string()
};
(subject, content.to_string())
} else {
(
task.email_subject.trim().to_string(),
task.email_body.trim().to_string(),
)
};
crate::mail::send_email(&cfg, &recipients, &subject, &body).await
}
/// 读取设置页「邮件」区块的 SMTP 配置。
fn load_smtp_config(core: &CoreApp) -> Result<crate::mail::SmtpConfig, String> {
let db = core.db.lock().unwrap();
let get = |key: &str| settings_db::get(&db, key).ok().flatten().unwrap_or_default();
Ok(crate::mail::SmtpConfig {
host: get("smtp_host"),
port: get("smtp_port").parse().unwrap_or(465),
tls: get("smtp_tls"),
user: get("smtp_user"),
password: get("smtp_password"),
from: get("smtp_from"),
from_name: get("smtp_from_name"),
})
}
/// 把收件人字符串按逗号/分号拆成多个邮箱地址。
fn split_recipients(raw: &str) -> Vec<String> {
let mut out = Vec::new();
for part in raw.split([',', ';', '', '']) {
let t = part.trim();
if !t.is_empty() && !out.iter().any(|x| x == t) {
out.push(t.to_string());
}
}
out
}
/// 发送一封测试邮件(用当前设置里的 SMTP 配置)。
#[tauri::command]
pub async fn mail_test(state: State<'_, App>, to: String) -> Result<(), String> {
let recipients = split_recipients(&to);
if recipients.is_empty() {
return Err("请填写测试收件人".into());
}
let cfg = load_smtp_config(&state.core)?;
crate::mail::send_email(
&cfg,
&recipients,
"仙人工作室测试邮件",
"这是一封来自仙人工作室的测试邮件,SMTP 配置验证成功。\n\n——仙人工作室",
)
.await
}
/// 解析计划任务使用的大模型:智能体默认模型 → 第一个本地模型 → 第一个已启用在线模型。
fn resolve_scheduled_model(
db: &rusqlite::Connection,
agent: Option<&xianren_core::Agent>,
+2
View File
@@ -1,4 +1,5 @@
mod commands;
mod mail;
mod mcp_client;
mod tools;
mod workflow;
@@ -182,6 +183,7 @@ pub fn run() {
commands::run_scheduled_task_now,
commands::autostart_status,
commands::autostart_set,
commands::mail_test,
commands::list_models,
commands::import_model,
commands::remove_model,
+86
View File
@@ -0,0 +1,86 @@
use lettre::message::{header::ContentType, Mailbox, Message};
use lettre::transport::smtp::authentication::Credentials;
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
use std::time::Duration;
/// SMTP 发件配置(来自设置页「邮件」区块)。
pub struct SmtpConfig {
pub host: String,
pub port: u16,
/// none(无加密)/ starttlsSTARTTLS/ wrapper(隐式 SSL
pub tls: String,
pub user: String,
pub password: String,
pub from: String,
pub from_name: String,
}
/// 通过 SMTP 发送一封纯文本邮件;`to_list` 支持多个收件人。
pub async fn send_email(
cfg: &SmtpConfig,
to_list: &[String],
subject: &str,
body: &str,
) -> Result<(), String> {
if cfg.host.trim().is_empty() {
return Err("未配置 SMTP 服务器,请先在 设置 → 邮件 中填写".into());
}
let from_addr = if cfg.from.trim().is_empty() {
cfg.user.trim().to_string()
} else {
cfg.from.trim().to_string()
};
if from_addr.is_empty() {
return Err("未配置发件人邮箱(SMTP 用户名或发件人地址)".into());
}
let from_mailbox: Mailbox = if cfg.from_name.trim().is_empty() {
from_addr
.parse()
.map_err(|e| format!("发件人地址无效:{e}"))?
} else {
format!("{} <{}>", cfg.from_name.trim(), from_addr)
.parse()
.map_err(|e| format!("发件人地址无效:{e}"))?
};
let mut builder = Message::builder()
.from(from_mailbox)
.subject(subject)
.header(ContentType::TEXT_PLAIN);
for to in to_list {
let t = to.trim();
if t.is_empty() {
continue;
}
let mailbox: Mailbox = t
.parse()
.map_err(|e| format!("收件人地址「{t}」无效:{e}"))?;
builder = builder.to(mailbox);
}
let email = builder
.body(body.to_string())
.map_err(|e| format!("构造邮件失败:{e}"))?;
let creds = Credentials::new(cfg.user.clone(), cfg.password.clone());
let host = cfg.host.trim();
let mailer = match cfg.tls.as_str() {
// STARTTLS:先明文连接再升级加密(587)
"starttls" => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(host)
.map_err(|e| format!("SMTP 配置无效:{e}"))?,
// 无加密:本地/内网中继(25
"none" => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host),
// 隐式 SSL:连接即加密(465),默认模式
_ => AsyncSmtpTransport::<Tokio1Executor>::relay(host)
.map_err(|e| format!("SMTP 配置无效:{e}"))?,
};
let mailer = mailer
.port(cfg.port)
.credentials(creds)
.timeout(Some(Duration::from_secs(30)))
.build();
mailer
.send(email)
.await
.map_err(|e| format!("邮件发送失败:{e}"))?;
Ok(())
}
+12
View File
@@ -77,6 +77,13 @@ impl CoreApp {
),
("tool_web_search_enabled", "true".to_string()),
("auto_load_models", "[]".to_string()),
("smtp_host", String::new()),
("smtp_port", "465".to_string()),
("smtp_tls", "wrapper".to_string()),
("smtp_user", String::new()),
("smtp_password", String::new()),
("smtp_from", String::new()),
("smtp_from_name", "仙人工作室".to_string()),
(
"recommend_dir",
self.data_dir
@@ -111,6 +118,11 @@ fn open_db(path: &Path) -> Result<Connection> {
ensure_column(&conn, "conversations", "favorite", "INTEGER NOT NULL DEFAULT 0")?;
ensure_column(&conn, "conversations", "tools_json", "TEXT NOT NULL DEFAULT '[]'")?;
ensure_column(&conn, "conversations", "agent_id", "TEXT")?;
ensure_column(&conn, "scheduled_tasks", "email_enabled", "INTEGER NOT NULL DEFAULT 0")?;
ensure_column(&conn, "scheduled_tasks", "email_to", "TEXT NOT NULL DEFAULT ''")?;
ensure_column(&conn, "scheduled_tasks", "email_mode", "TEXT NOT NULL DEFAULT 'fixed'")?;
ensure_column(&conn, "scheduled_tasks", "email_subject", "TEXT NOT NULL DEFAULT ''")?;
ensure_column(&conn, "scheduled_tasks", "email_body", "TEXT NOT NULL DEFAULT ''")?;
Ok(conn)
}
+37 -6
View File
@@ -16,11 +16,17 @@ pub struct ScheduledTask {
pub last_status: String,
pub last_result: Option<String>,
pub last_error: Option<String>,
pub email_enabled: bool,
pub email_to: String,
/// fixed(固定内容)/ llm(大模型生成)
pub email_mode: String,
pub email_subject: String,
pub email_body: 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";
const SELECT_COLUMNS: &str = "id, name, agent_id, prompt, interval_minutes, enabled, next_run_at, last_run_at, last_status, last_result, last_error, email_enabled, email_to, email_mode, email_subject, email_body, created_at, updated_at";
/// 把分钟数转成 SQLite datetime 修饰符字符串(如 `+30 minutes`)。
fn interval_modifier(minutes: i64) -> String {
@@ -53,8 +59,10 @@ pub fn get(db: &Connection, id: &str) -> Result<Option<ScheduledTask>> {
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')",
(id, name, agent_id, prompt, interval_minutes, enabled, next_run_at, last_status,
email_enabled, email_to, email_mode, email_subject, email_body)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, datetime('now', ?7), 'idle',
?8, ?9, ?10, ?11, ?12)",
params![
task.id,
task.name,
@@ -63,6 +71,11 @@ pub fn insert(db: &Connection, task: &ScheduledTask) -> Result<()> {
task.interval_minutes.max(1),
task.enabled as i32,
interval_modifier(task.interval_minutes),
task.email_enabled as i32,
task.email_to,
task.email_mode,
task.email_subject,
task.email_body,
],
)?;
Ok(())
@@ -73,8 +86,10 @@ pub fn update(db: &Connection, task: &ScheduledTask) -> Result<()> {
"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,
email_enabled = ?8, email_to = ?9, email_mode = ?10,
email_subject = ?11, email_body = ?12,
updated_at = datetime('now')
WHERE id = ?8",
WHERE id = ?13",
params![
task.name,
task.agent_id,
@@ -83,6 +98,11 @@ pub fn update(db: &Connection, task: &ScheduledTask) -> Result<()> {
task.enabled as i32,
task.enabled as i32,
interval_modifier(task.interval_minutes),
task.email_enabled as i32,
task.email_to,
task.email_mode,
task.email_subject,
task.email_body,
task.id,
],
)?;
@@ -184,6 +204,7 @@ pub fn reset_stale_running(db: &Connection) -> Result<usize> {
fn row_to_task(row: &rusqlite::Row<'_>) -> rusqlite::Result<ScheduledTask> {
let enabled: i32 = row.get(5)?;
let email_enabled: i32 = row.get(11)?;
Ok(ScheduledTask {
id: row.get(0)?,
name: row.get(1)?,
@@ -196,8 +217,13 @@ fn row_to_task(row: &rusqlite::Row<'_>) -> rusqlite::Result<ScheduledTask> {
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)?,
email_enabled: email_enabled != 0,
email_to: row.get(12)?,
email_mode: row.get(13)?,
email_subject: row.get(14)?,
email_body: row.get(15)?,
created_at: row.get(16)?,
updated_at: row.get(17)?,
})
}
@@ -225,6 +251,11 @@ mod tests {
last_status: "idle".to_string(),
last_result: None,
last_error: None,
email_enabled: false,
email_to: String::new(),
email_mode: "fixed".to_string(),
email_subject: String::new(),
email_body: String::new(),
created_at: String::new(),
updated_at: String::new(),
}
+5
View File
@@ -119,6 +119,11 @@ CREATE TABLE IF NOT EXISTS scheduled_tasks (
last_status TEXT NOT NULL DEFAULT 'idle',
last_result TEXT,
last_error TEXT,
email_enabled INTEGER NOT NULL DEFAULT 0,
email_to TEXT NOT NULL DEFAULT '',
email_mode TEXT NOT NULL DEFAULT 'fixed',
email_subject TEXT NOT NULL DEFAULT '',
email_body TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
+15 -3
View File
@@ -12,7 +12,7 @@
```
xianren_studio/
├── apps/desktop/ Tauri 桌面壳(Rust 命令层、tauri.conf.json、推荐模型/预制智能体/预制工作流默认 JSON、工作流执行引擎 workflow.rs
├── apps/desktop/ Tauri 桌面壳(Rust 命令层、tauri.conf.json、推荐模型/预制智能体/预制工作流默认 JSON、工作流执行引擎 workflow.rs、邮件发送 mail.rs
├── crates/core/ 领域核心:SQLite(模型注册表、智能体、工作流、定时计划、会话、消息、设置)
├── crates/engine/ llama-server 生命周期 + 流式/非流式聊天(本地与远程 OpenAI 兼容)
├── crates/download/ 分片断点续传下载器
@@ -108,6 +108,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` |
| 邮件 | `mail_test`(用当前 SMTP 配置发送测试邮件) |
| 应用/设置 | `app_info``autostart_status``autostart_set``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` |
@@ -136,6 +137,12 @@ ui/src/
- `auto_load_models`:应用启动时读取设置键 `auto_load_models`(JSON 数组,本地模型 id 按序排列),逐个调用 `start_engine_and_wait` 部署,非最后一个模型加载完即停止,最后一个保持运行。
- `start_engine_and_wait`:本地模型引擎启动的公共实现(构造 `EngineConfig`、推送 `engine://deploy` / `engine://deploy-progress` 事件、轮询加载进度、更新 `engine_base`);`deploy_model` 命令与 `auto_load_models` 共用。
- `autostart_status` / `autostart_set`:查询 / 设置开机启动(`tauri-plugin-autostart``ManagerExt`)。
- `run_scheduled_task_once`:大模型执行完成后,若任务开启邮件,按内容模式发送——固定模式用填写的主题/正文,LLM 模式用智能体回答作正文(主题留空取回答首行);邮件发送失败时任务标记失败但保留回答内容。
- `send_task_email` / `load_smtp_config` / `split_recipients`:读取设置页「邮件」SMTP 配置、拆分多收件人并调用 `mail.rs` 发送。
**邮件发送模块(`apps/desktop/src/mail.rs`):**
- `send_email`:用 lettre 通过 SMTP 发送纯文本邮件;加密方式按设置分支——隐式 SSL 用 `relay`465)、STARTTLS 用 `starttls_relay`587)、无加密用 `builder_dangerous`25),均可自定义端口与超时。
**工作流执行引擎(`apps/desktop/src/workflow.rs`):**
@@ -168,7 +175,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),每次执行完成后推进 |
| `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``email_enabled``email_to``email_mode`fixed/llm)、`email_subject``email_body` | 计划任务`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` | 技能工具库 |
@@ -176,6 +183,8 @@ ui/src/
迁移清单(`ensure_column`):`models.kind/base_url/api_key/api_model/enabled``messages.elapsed_ms/first_token_ms/images_json/model_id``conversations.pinned/favorite/tools_json/agent_id`
新增列迁移:`scheduled_tasks.email_enabled / email_to / email_mode / email_subject / email_body``ensure_column` 幂等补齐)。
### 5.3 `models.rs`
模型 CRUD、`set_enabled`(启用/停用)、`scan_directory`(扫描 GGUF 目录)、量化猜测 `guess_quant`
@@ -198,7 +207,7 @@ ui/src/
### 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`(应用重启后把中断的「运行中」标记为失败)。
计划任务 CRUD 与运行状态落库:`list/get/insert/update/delete/set_enabled`(停用时清空 `next_run_at`,启用时重新计算)、`mark_running` / `finish_run`(写执行结果并推进下次执行时间)、`list_due`(到点且未运行中的启用计划)、`reset_stale_running`(应用重启后把中断的「运行中」标记为失败);任务行含邮件配置字段(`email_enabled/email_to/email_mode/email_subject/email_body`
---
@@ -266,6 +275,9 @@ ui/src/
| `tavily_api_key` | (内置演示值) | 联网搜索 API Key |
| `tool_web_search_enabled` | true | 联网搜索工具开关 |
| `auto_load_models` | `[]` | 启动时自动加载的本地模型 id 列表(JSON 数组,顺序即加载顺序) |
| `smtp_host` / `smtp_port` / `smtp_tls` | 空 / 465 / wrapper | SMTP 发件服务器、端口、加密方式(wrapper/starttls/none |
| `smtp_user` / `smtp_password` | 空 / 空 | SMTP 用户名(通常为发件邮箱)与授权码/密码 |
| `smtp_from` / `smtp_from_name` | 空 / 仙人工作室 | 发件人地址(留空用用户名)与显示名称 |
| `recommend_dir` | 数据目录/recommendations | 推荐列表目录 |
| `preset_agents_seeded` | 首次启动后为 `1` | 预制智能体是否已写入(内部标记,避免覆盖用户删除) |
| `preset_workflows_seeded` | 首次启动后为 `1` | 预制工作流是否已写入(内部标记,避免覆盖用户删除) |
+8 -2
View File
@@ -147,13 +147,17 @@
## 7. 任务页(`/tasks`
### 7.1 定时计划
### 7.1 计划任务
- 页面顶部新增「定时计划」区块,支持新建 / 编辑 / 删除 / 启用停用 / 立即执行。
- 页面顶部「计划任务」区块,支持新建 / 编辑 / 删除 / 启用停用 / 立即执行。
- **执行智能体**:每条计划指定一个智能体执行(下拉选择,默认「通用助手」),执行时使用该智能体的人设与默认模型;若指定智能体已被删除,自动回退到「通用助手」。
- **执行内容**:计划名称 + 提示词(每次到点发送给智能体的任务内容)。
- **执行间隔**:以分钟 / 小时 / 天为单位,提供快捷预设(30 分钟 / 1 小时 / 6 小时 / 1 天)与自定义;后端每 30 秒检查一次到点的计划。
- **执行方式**:到点后自动在对应智能体名下创建(或复用空)会话,写入用户消息 → 调用大模型(本地模型未启动时自动拉起引擎,在线 API 直接调用)→ 写入助手回答;结果可在该智能体的聊天页查看。
- **发送邮件**:每条计划可开启「发送邮件」,填写收件人(支持多个,逗号分隔),内容支持两种模式:
- **固定内容**:邮件主题与正文由用户填写,每次执行原样发送;
- **大模型生成**:邮件正文使用智能体本次生成的回答(主题可自定义,留空自动取回答第一行)。
- 发件 SMTP 配置在「设置 → 邮件」统一管理(服务器 / 端口 / 加密方式 / 用户名 / 授权码 / 发件人),支持一键发送测试邮件;未配置 SMTP 时任务标记失败并提示。
- 每条计划展示:执行智能体、下次执行时间、上次执行时间、状态(待执行 / 执行中 / 成功 / 失败)与结果 / 错误摘要。
- 没有可用大模型时该次执行标记失败(提示先部署本地模型或启用在线模型),并按间隔继续安排下次执行。
- 应用重启会清理上次中断的「执行中」状态并标记为失败,避免计划永远卡住。
@@ -192,6 +196,7 @@
- **启动**
- **开机启动**:登录 Windows 后自动启动应用(经 `tauri-plugin-autostart` 写入注册表启动项,可随时开关)。
- **自动加载模型列表**:维护一个**有序**的本地模型列表(支持添加 / 移除 / 上下调整顺序);应用启动后按列表顺序**逐个部署加载**,由于同一时间只能运行一个本地模型,全部加载完成后保留列表最后一个模型在运行(加载参数使用默认值 ctx 4096 / GPU 层数 99)。
- **邮件**:SMTP 发件配置(服务器、端口、加密方式:隐式 SSL / STARTTLS / 无加密、用户名、授权码、发件人地址与名称),供计划任务发送结果邮件;可填写测试收件人一键验证配置。
- **对话**
- 自动生成对话标题(默认开):首次回复后自动生成并覆盖标题。
- **自动预测用户接下来说的话**(默认开):见 2.4。
@@ -209,6 +214,7 @@
### 2026-08-17
- 计划任务支持发送邮件:任务设置中可开启邮件并填写收件人,内容支持固定内容与大模型生成两种模式;设置页新增「邮件」SMTP 配置与测试发送。
- 设置页新增「启动」:开机启动开关(写入 Windows 注册表启动项);「自动加载模型」列表(多个本地模型按顺序在启动时逐个加载,最后一个保持运行)。
- 任务页新增「定时计划」:可指定执行智能体(默认通用助手)、执行内容与间隔(分钟 / 小时 / 天),后台到点自动在智能体会话中执行并记录结果;支持立即执行 / 编辑 / 删除 / 启用停用。
- 工作流画布顶栏微调:名称输入框宽度改为按文本实际宽度计算(中文按 2 倍字符宽折算,可完整显示名称),空间不足时自动收缩,保证右侧「保存 / 运行」按钮不被遮挡。
+11
View File
@@ -167,6 +167,11 @@ export interface ScheduledTask {
prompt: string;
interval_minutes: number;
enabled: boolean;
email_enabled: boolean;
email_to: string;
email_mode: string;
email_subject: string;
email_body: string;
next_run_at: string | null;
last_run_at: string | null;
last_status: string;
@@ -182,6 +187,11 @@ export interface ScheduledTaskInput {
prompt: string;
interval_minutes: number;
enabled: boolean;
email_enabled: boolean;
email_to: string;
email_mode: string;
email_subject: string;
email_body: string;
}
export interface McpServer {
@@ -400,6 +410,7 @@ export const api = {
invoke<void>("set_scheduled_task_enabled", { id, enabled }),
runScheduledTaskNow: (id: string) =>
invoke<void>("run_scheduled_task_now", { id }),
mailTest: (to: string) => invoke<void>("mail_test", { to }),
listModels: () => invoke<ModelInfo[]>("list_models"),
importModel: (path: string) => invoke<ModelInfo>("import_model", { path }),
removeModel: (id: string) => invoke<void>("remove_model", { id }),
+6
View File
@@ -122,6 +122,12 @@ const paths: Record<string, ReactNode> = {
</>
),
play: <polygon points="5 3 19 12 5 21 5 3" />,
mail: (
<>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M22 7l-10 6L2 7" />
</>
),
};
export default function Icon({
+113 -1
View File
@@ -8,6 +8,9 @@ export default function SettingsPage() {
const [recommendDirDraft, setRecommendDirDraft] = useState("");
const [uploadMsg, setUploadMsg] = useState<string | null>(null);
const [startupMsg, setStartupMsg] = useState<string | null>(null);
const [smtpMsg, setSmtpMsg] = useState<string | null>(null);
const [smtpTestTo, setSmtpTestTo] = useState("");
const [smtpBusy, setSmtpBusy] = useState(false);
const [autostart, setAutostart] = useState<boolean | null>(null);
const [autoLoadDraft, setAutoLoadDraft] = useState<string[]>([]);
const [pickId, setPickId] = useState("");
@@ -83,6 +86,23 @@ export default function SettingsPage() {
await handleSave("auto_load_models", JSON.stringify(draftRows.map((m) => m.id)));
}
async function handleMailTest() {
if (!smtpTestTo.trim()) {
setSmtpMsg("请先填写测试收件人");
return;
}
setSmtpBusy(true);
setSmtpMsg(null);
try {
await api.mailTest(smtpTestTo.trim());
setSmtpMsg("测试邮件发送成功,请检查收件箱");
} catch (e) {
setSmtpMsg(`发送失败:${String(e)}`);
} finally {
setSmtpBusy(false);
}
}
function modelDisplayName(m: ModelInfo) {
return m.file_name.replace(/\.gguf$/i, "");
}
@@ -282,6 +302,91 @@ export default function SettingsPage() {
</div>
</div>
<div className="rounded-xl border border-border bg-panel p-5">
<div className="text-xs uppercase text-slate-400"></div>
<p className="mt-1 text-xs text-slate-400">
/
</p>
<div className="mt-3 space-y-3">
<SettingInput
label="SMTP 服务器(如 smtp.qq.com"
value={settings.smtp_host ?? ""}
onSave={(v) => handleSave("smtp_host", v)}
/>
<div className="flex gap-3">
<div className="flex-1">
<SettingInput
label="端口(465=隐式SSL / 587=STARTTLS / 25=无加密)"
value={settings.smtp_port ?? "465"}
onSave={(v) => handleSave("smtp_port", v)}
/>
</div>
<div className="w-40">
<span className="text-xs text-slate-400"></span>
<select
className="input mt-1"
value={settings.smtp_tls ?? "wrapper"}
onChange={(e) => handleSave("smtp_tls", e.target.value)}
>
<option value="wrapper"> SSL</option>
<option value="starttls">STARTTLS</option>
<option value="none"></option>
</select>
</div>
</div>
<SettingInput
label="SMTP 用户名(通常是发件邮箱)"
value={settings.smtp_user ?? ""}
onSave={(v) => handleSave("smtp_user", v)}
/>
<SettingInput
type="password"
label="授权码 / 密码"
value={settings.smtp_password ?? ""}
onSave={(v) => handleSave("smtp_password", v)}
/>
<SettingInput
label="发件人地址(留空则用 SMTP 用户名)"
value={settings.smtp_from ?? ""}
onSave={(v) => handleSave("smtp_from", v)}
/>
<SettingInput
label="发件人名称"
value={settings.smtp_from_name ?? "仙人工作室"}
onSave={(v) => handleSave("smtp_from_name", v)}
/>
<div className="flex items-end gap-2">
<label className="block flex-1 text-sm">
<span className="text-xs text-slate-400"></span>
<input
className="input mt-1"
value={smtpTestTo}
placeholder="填写一个收件邮箱验证配置"
onChange={(e) => setSmtpTestTo(e.target.value)}
/>
</label>
<button
className="btn-secondary shrink-0"
onClick={handleMailTest}
disabled={smtpBusy}
>
{smtpBusy ? "发送中…" : "发送测试邮件"}
</button>
</div>
{smtpMsg ? (
<div
className={`text-xs ${
smtpMsg.startsWith("发送失败") || smtpMsg.startsWith("请先")
? "text-red-400"
: "text-emerald-400"
}`}
>
{smtpMsg}
</div>
) : null}
</div>
</div>
<div className="rounded-xl border border-border bg-panel p-5">
<div className="text-xs uppercase text-slate-400"></div>
<div className="mt-3 flex items-center justify-between rounded-lg border border-border bg-panel-2 px-3 py-2.5">
@@ -449,10 +554,12 @@ function SettingInput({
label,
value,
onSave,
type = "text",
}: {
label: string;
value: string;
onSave: (v: string) => void;
type?: string;
}) {
const [draft, setDraft] = useState(value);
useEffect(() => setDraft(value), [value]);
@@ -460,7 +567,12 @@ function SettingInput({
<label className="block text-sm">
<span className="text-xs text-slate-400">{label}</span>
<div className="mt-1 flex gap-2">
<input className="input flex-1" value={draft} onChange={(e) => setDraft(e.target.value)} />
<input
type={type}
className="input flex-1"
value={draft}
onChange={(e) => setDraft(e.target.value)}
/>
<button
className="btn-secondary"
onClick={() => onSave(draft)}
+131 -6
View File
@@ -21,6 +21,11 @@ interface Draft {
intervalValue: number;
intervalUnit: IntervalUnit;
enabled: boolean;
emailEnabled: boolean;
emailTo: string;
emailMode: "fixed" | "llm";
emailSubject: string;
emailBody: string;
}
function intervalToMinutes(value: number, unit: IntervalUnit) {
@@ -106,6 +111,11 @@ export default function TasksPage() {
prompt: draft.prompt.trim(),
interval_minutes: intervalMinutes,
enabled: draft.enabled,
email_enabled: draft.emailEnabled,
email_to: draft.emailTo.trim(),
email_mode: draft.emailMode,
email_subject: draft.emailSubject.trim(),
email_body: draft.emailBody.trim(),
};
setBusy(true);
try {
@@ -163,7 +173,7 @@ export default function TasksPage() {
<div>
<h1 className="text-xl font-semibold"></h1>
<p className="mt-1 text-sm text-slate-400">
</p>
</div>
<div className="flex shrink-0 gap-2">
@@ -173,7 +183,7 @@ export default function TasksPage() {
</button>
) : null}
<button className="btn-primary" onClick={openNew} disabled={busy}>
+
+
</button>
</div>
</div>
@@ -189,14 +199,14 @@ export default function TasksPage() {
<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">
@@ -298,6 +308,14 @@ function ScheduledTaskCard({
{task.enabled ? ` · 下次执行 ${fmtDbTime(task.next_run_at)}` : ""}
</span>
</div>
{task.email_enabled ? (
<div className="flex items-center gap-1">
<Icon name="mail" className="h-3 w-3" />
<span className="truncate">
{task.email_to} · {task.email_mode === "llm" ? "大模型生成" : "固定内容"}
</span>
</div>
) : null}
{task.last_run_at ? (
<div className="flex items-center gap-1">
<Icon name="refresh" className="h-3 w-3" />
@@ -375,9 +393,18 @@ function ScheduleModal({
intervalValue: task ? splitInterval(task.interval_minutes).value : 1,
intervalUnit: task ? splitInterval(task.interval_minutes).unit : "hour",
enabled: task?.enabled ?? true,
emailEnabled: task?.email_enabled ?? false,
emailTo: task?.email_to ?? "",
emailMode: (task?.email_mode === "llm" ? "llm" : "fixed") as Draft["emailMode"],
emailSubject: task?.email_subject ?? "",
emailBody: task?.email_body ?? "",
}));
const valid = draft.name.trim() && draft.prompt.trim() && draft.intervalValue >= 1;
const valid =
draft.name.trim() &&
draft.prompt.trim() &&
draft.intervalValue >= 1 &&
(!draft.emailEnabled || draft.emailTo.trim());
return (
<div
@@ -389,7 +416,7 @@ function ScheduleModal({
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>
<span className="text-sm font-medium">{task ? "编辑计划任务" : "新建计划任务"}</span>
<button className="text-slate-500 hover:text-slate-200" onClick={onClose}>
×
</button>
@@ -487,6 +514,104 @@ function ScheduleModal({
</div>
</div>
<div className="rounded-lg border border-border bg-panel-2/60 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm"></div>
<div className="mt-0.5 text-xs text-slate-400">
</div>
</div>
<Toggle
checked={draft.emailEnabled}
onChange={(v) => setDraft({ ...draft, emailEnabled: v })}
/>
</div>
{draft.emailEnabled ? (
<div className="mt-3 space-y-3">
<div>
<label className="mb-1 block text-xs text-slate-400"></label>
<input
className="input"
value={draft.emailTo}
placeholder="多个收件人用逗号分隔,例如 a@qq.com, b@163.com"
onChange={(e) => setDraft({ ...draft, emailTo: e.target.value })}
/>
</div>
<div>
<label className="mb-1 block text-xs text-slate-400"></label>
<div className="flex gap-2">
<button
type="button"
className={`rounded-lg border px-3 py-1.5 text-xs transition-colors ${
draft.emailMode === "fixed"
? "border-accent bg-accent/20 text-white"
: "border-border bg-panel-2 text-slate-400 hover:text-slate-200"
}`}
onClick={() => setDraft({ ...draft, emailMode: "fixed" })}
>
</button>
<button
type="button"
className={`rounded-lg border px-3 py-1.5 text-xs transition-colors ${
draft.emailMode === "llm"
? "border-accent bg-accent/20 text-white"
: "border-border bg-panel-2 text-slate-400 hover:text-slate-200"
}`}
onClick={() => setDraft({ ...draft, emailMode: "llm" })}
>
</button>
</div>
</div>
{draft.emailMode === "fixed" ? (
<>
<div>
<label className="mb-1 block text-xs text-slate-400"></label>
<input
className="input"
value={draft.emailSubject}
placeholder="例如:每日工作总结"
onChange={(e) =>
setDraft({ ...draft, emailSubject: e.target.value })
}
/>
</div>
<div>
<label className="mb-1 block text-xs text-slate-400"></label>
<textarea
className="input min-h-[80px] resize-y"
value={draft.emailBody}
placeholder="每次执行都会原样发送的固定内容"
onChange={(e) => setDraft({ ...draft, emailBody: e.target.value })}
/>
</div>
</>
) : (
<>
<div>
<label className="mb-1 block text-xs text-slate-400">
</label>
<input
className="input"
value={draft.emailSubject}
placeholder="留空时自动取智能体回答的第一行"
onChange={(e) =>
setDraft({ ...draft, emailSubject: e.target.value })
}
/>
</div>
<p className="text-[11px] text-slate-500">
使
</p>
</>
)}
</div>
) : null}
</div>
<div className="flex items-center gap-2">
<Toggle
checked={draft.enabled}