From 0f942182be64a596d924bcac7c13c9644ab7a9c1 Mon Sep 17 00:00:00 2001 From: xianrenge <34586161+xianrenge@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:13:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AE=A1=E5=88=92=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=8F=91=E9=80=81=E9=82=AE=E4=BB=B6=EF=BC=88?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E5=86=85=E5=AE=B9/=E5=A4=A7=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E7=94=9F=E6=88=90=EF=BC=89=EF=BC=8C=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E6=96=B0=E5=A2=9E=20SMTP=20=E9=82=AE=E4=BB=B6?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=B8=8E=E6=B5=8B=E8=AF=95=E5=8F=91=E9=80=81?= =?UTF-8?q?=EF=BC=9B'=E5=AE=9A=E6=97=B6=E8=AE=A1=E5=88=92'=E6=9B=B4?= =?UTF-8?q?=E5=90=8D=E4=B8=BA'=E8=AE=A1=E5=88=92=E4=BB=BB=E5=8A=A1'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 78 +++++++++++++ apps/desktop/Cargo.toml | 1 + apps/desktop/src/commands.rs | 180 +++++++++++++++++++++++++++-- apps/desktop/src/lib.rs | 2 + apps/desktop/src/mail.rs | 86 ++++++++++++++ crates/core/src/app.rs | 12 ++ crates/core/src/scheduled_tasks.rs | 43 ++++++- crates/core/src/schema.sql | 5 + docs/ARCHITECTURE.md | 18 ++- docs/FEATURES.md | 10 +- ui/src/api.ts | 11 ++ ui/src/components/Icon.tsx | 6 + ui/src/pages/SettingsPage.tsx | 114 +++++++++++++++++- ui/src/pages/TasksPage.tsx | 137 +++++++++++++++++++++- 14 files changed, 673 insertions(+), 30 deletions(-) create mode 100644 apps/desktop/src/mail.rs diff --git a/Cargo.lock b/Cargo.lock index 188f94e..a00c3e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/apps/desktop/Cargo.toml b/apps/desktop/Cargo.toml index f1e7f24..e114229 100644 --- a/apps/desktop/Cargo.toml +++ b/apps/desktop/Cargo.toml @@ -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 diff --git a/apps/desktop/src/commands.rs b/apps/desktop/src/commands.rs index 721ecbf..ce94e7b 100644 --- a/apps/desktop/src/commands.rs +++ b/apps/desktop/src/commands.rs @@ -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, + error: Option, +} + +/// 执行一次计划任务并落库:成功写入助手回答,失败记录错误信息。 async fn execute_scheduled_task( app: AppHandle, core: Arc, engine: EngineManager, task: xianren_core::ScheduledTask, ) { - let result = run_scheduled_task_once(&app, &core, &engine, &task).await; + let 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 { // 指定智能体已被删除时回退到「通用助手」,仍缺失则按普通对话执行 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::()) + .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 { + 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 { + 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>, diff --git a/apps/desktop/src/lib.rs b/apps/desktop/src/lib.rs index 43b650d..7c45776 100644 --- a/apps/desktop/src/lib.rs +++ b/apps/desktop/src/lib.rs @@ -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, diff --git a/apps/desktop/src/mail.rs b/apps/desktop/src/mail.rs new file mode 100644 index 0000000..192905f --- /dev/null +++ b/apps/desktop/src/mail.rs @@ -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(无加密)/ starttls(STARTTLS)/ 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::::starttls_relay(host) + .map_err(|e| format!("SMTP 配置无效:{e}"))?, + // 无加密:本地/内网中继(25) + "none" => AsyncSmtpTransport::::builder_dangerous(host), + // 隐式 SSL:连接即加密(465),默认模式 + _ => AsyncSmtpTransport::::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(()) +} diff --git a/crates/core/src/app.rs b/crates/core/src/app.rs index 3cc6284..5191cca 100644 --- a/crates/core/src/app.rs +++ b/crates/core/src/app.rs @@ -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 { 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) } diff --git a/crates/core/src/scheduled_tasks.rs b/crates/core/src/scheduled_tasks.rs index 6f1eb62..1b00ca0 100644 --- a/crates/core/src/scheduled_tasks.rs +++ b/crates/core/src/scheduled_tasks.rs @@ -16,11 +16,17 @@ pub struct ScheduledTask { pub last_status: String, pub last_result: Option, pub last_error: Option, + 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> { 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 { fn row_to_task(row: &rusqlite::Row<'_>) -> rusqlite::Result { 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 { 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(), } diff --git a/crates/core/src/schema.sql b/crates/core/src/schema.sql index 1a6f26d..6d508de 100644 --- a/crates/core/src/schema.sql +++ b/crates/core/src/schema.sql @@ -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')) ); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 058ac02..76fabdf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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` | 预制工作流是否已写入(内部标记,避免覆盖用户删除) | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index a54434e..306f341 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -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 倍字符宽折算,可完整显示名称),空间不足时自动收缩,保证右侧「保存 / 运行」按钮不被遮挡。 diff --git a/ui/src/api.ts b/ui/src/api.ts index 3c29caf..e73657b 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -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("set_scheduled_task_enabled", { id, enabled }), runScheduledTaskNow: (id: string) => invoke("run_scheduled_task_now", { id }), + mailTest: (to: string) => invoke("mail_test", { to }), listModels: () => invoke("list_models"), importModel: (path: string) => invoke("import_model", { path }), removeModel: (id: string) => invoke("remove_model", { id }), diff --git a/ui/src/components/Icon.tsx b/ui/src/components/Icon.tsx index dce0938..7e4c92b 100644 --- a/ui/src/components/Icon.tsx +++ b/ui/src/components/Icon.tsx @@ -122,6 +122,12 @@ const paths: Record = { ), play: , + mail: ( + <> + + + + ), }; export default function Icon({ diff --git a/ui/src/pages/SettingsPage.tsx b/ui/src/pages/SettingsPage.tsx index b5d3e50..fdaa4eb 100644 --- a/ui/src/pages/SettingsPage.tsx +++ b/ui/src/pages/SettingsPage.tsx @@ -8,6 +8,9 @@ export default function SettingsPage() { const [recommendDirDraft, setRecommendDirDraft] = useState(""); const [uploadMsg, setUploadMsg] = useState(null); const [startupMsg, setStartupMsg] = useState(null); + const [smtpMsg, setSmtpMsg] = useState(null); + const [smtpTestTo, setSmtpTestTo] = useState(""); + const [smtpBusy, setSmtpBusy] = useState(false); const [autostart, setAutostart] = useState(null); const [autoLoadDraft, setAutoLoadDraft] = useState([]); 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() { +
+
邮件
+

+ 用于计划任务发送结果邮件(固定内容 / 大模型生成) +

+
+ handleSave("smtp_host", v)} + /> +
+
+ handleSave("smtp_port", v)} + /> +
+
+ 加密方式 + +
+
+ handleSave("smtp_user", v)} + /> + handleSave("smtp_password", v)} + /> + handleSave("smtp_from", v)} + /> + handleSave("smtp_from_name", v)} + /> +
+ + +
+ {smtpMsg ? ( +
+ {smtpMsg} +
+ ) : null} +
+
+
对话
@@ -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({
@@ -189,14 +199,14 @@ export default function TasksPage() {
- 定时计划 + 计划任务 {scheduledTasks.length}
{scheduledTasks.length === 0 ? (
- 还没有定时计划,点击右上角「新建定时计划」,选择智能体(默认通用助手)与执行内容,到点自动运行 + 还没有计划任务,点击右上角「新建计划任务」,选择智能体(默认通用助手)与执行内容,到点自动运行;可配置执行结果发送到邮箱
) : (
@@ -298,6 +308,14 @@ function ScheduledTaskCard({ {task.enabled ? ` · 下次执行 ${fmtDbTime(task.next_run_at)}` : ""}
+ {task.email_enabled ? ( +
+ + + 发送至 {task.email_to} · {task.email_mode === "llm" ? "大模型生成" : "固定内容"} + +
+ ) : null} {task.last_run_at ? (
@@ -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 (
e.stopPropagation()} >
- {task ? "编辑定时计划" : "新建定时计划"} + {task ? "编辑计划任务" : "新建计划任务"} @@ -487,6 +514,104 @@ function ScheduleModal({
+
+
+
+
发送邮件
+
+ 执行完成后发送到指定邮箱(发件邮箱在 设置 → 邮件 配置) +
+
+ setDraft({ ...draft, emailEnabled: v })} + /> +
+ {draft.emailEnabled ? ( +
+
+ + setDraft({ ...draft, emailTo: e.target.value })} + /> +
+
+ +
+ + +
+
+ {draft.emailMode === "fixed" ? ( + <> +
+ + + setDraft({ ...draft, emailSubject: e.target.value }) + } + /> +
+
+ +