feat: 计划任务支持发送邮件(固定内容/大模型生成),设置页新增 SMTP 邮件配置与测试发送;'定时计划'更名为'计划任务'
This commit is contained in:
Generated
+78
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::<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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 倍字符宽折算,可完整显示名称),空间不足时自动收缩,保证右侧「保存 / 运行」按钮不被遮挡。
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user