From 6eb197a91a2942bd169fce071db791be9a2bd954 Mon Sep 17 00:00:00 2001 From: xianrenge <34586161+xianrenge@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:54:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AE=BE=E7=BD=AE=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=BC=80=E6=9C=BA=E5=90=AF=E5=8A=A8=E4=B8=8E=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=8A=A0=E8=BD=BD=E5=88=97=E8=A1=A8=EF=BC=88?= =?UTF-8?q?=E5=A4=9A=E4=B8=AA=E6=9C=AC=E5=9C=B0=E6=A8=A1=E5=9E=8B=E6=8C=89?= =?UTF-8?q?=E9=A1=BA=E5=BA=8F=E9=80=90=E4=B8=AA=E5=8A=A0=E8=BD=BD=EF=BC=8C?= =?UTF-8?q?=E6=9C=80=E5=90=8E=E4=B8=80=E4=B8=AA=E4=BF=9D=E6=8C=81=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 57 ++++++- apps/desktop/Cargo.toml | 1 + apps/desktop/src/commands.rs | 286 ++++++++++++++++++++++------------ apps/desktop/src/lib.rs | 13 ++ crates/core/src/app.rs | 1 + docs/ARCHITECTURE.md | 8 +- docs/FEATURES.md | 4 + ui/src/api.ts | 2 + ui/src/pages/SettingsPage.tsx | 186 +++++++++++++++++++++- 9 files changed, 454 insertions(+), 104 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b836125..188f94e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -241,6 +241,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto-launch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +dependencies = [ + "dirs 4.0.0", + "thiserror 1.0.69", + "winreg 0.10.1", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -846,6 +857,15 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + [[package]] name = "dirs" version = "5.0.1" @@ -864,6 +884,17 @@ dependencies = [ "dirs-sys 0.5.0", ] +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + [[package]] name = "dirs-sys" version = "0.4.1" @@ -1011,7 +1042,7 @@ dependencies = [ "rustc_version", "toml 1.1.4+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -4000,6 +4031,20 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-autostart" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.4" @@ -5423,6 +5468,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" @@ -5549,6 +5603,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-autostart", "tauri-plugin-opener", "tokio", "tracing", diff --git a/apps/desktop/Cargo.toml b/apps/desktop/Cargo.toml index 1d79a2b..f1e7f24 100644 --- a/apps/desktop/Cargo.toml +++ b/apps/desktop/Cargo.toml @@ -19,6 +19,7 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } tauri-plugin-opener = "2" +tauri-plugin-autostart = "2" tracing-subscriber.workspace = true tracing.workspace = true tracing-appender.workspace = true diff --git a/apps/desktop/src/commands.rs b/apps/desktop/src/commands.rs index 246baf5..721ecbf 100644 --- a/apps/desktop/src/commands.rs +++ b/apps/desktop/src/commands.rs @@ -231,6 +231,26 @@ pub fn app_info(state: State<'_, App>) -> AppInfo { } } +/// 查询是否已启用开机启动(登录 Windows 后自动运行)。 +#[tauri::command] +pub async fn autostart_status(app: AppHandle) -> Result { + use tauri_plugin_autostart::ManagerExt; + app.autolaunch().is_enabled().map_err(|e| e.to_string()) +} + +/// 设置 / 取消开机启动,返回设置后的状态。 +#[tauri::command] +pub async fn autostart_set(app: AppHandle, enabled: bool) -> Result { + use tauri_plugin_autostart::ManagerExt; + let autostart = app.autolaunch(); + if enabled { + autostart.enable().map_err(|e| e.to_string())?; + } else { + autostart.disable().map_err(|e| e.to_string())?; + } + autostart.is_enabled().map_err(|e| e.to_string()) +} + #[tauri::command] pub fn list_models(state: State<'_, App>) -> Result, String> { let db = state.core.db.lock().unwrap(); @@ -1503,6 +1523,59 @@ pub async fn deploy_model( (model, bin) }; + let current = engine.status().await; + if current.running { + if current.model.as_deref() == Some(model.file_name.as_str()) { + let _ = app.emit( + "engine://deploy", + EngineDeployEvent { + model_id, + file_name: model.file_name.clone(), + state: "ready".into(), + message: None, + }, + ); + return Ok(()); + } + // 部署另一个模型前,先停掉当前引擎 + let _ = engine.stop().await; + *base.write().await = None; + let _ = app.emit("engine://status", engine.status().await); + } + + let bin = PathBuf::from(engine_bin); + let app2 = app.clone(); + let core2 = core.clone(); + let engine2 = engine.clone(); + let base2 = base.clone(); + tokio::spawn(async move { + start_engine_and_wait( + &app2, + &core2, + &engine2, + &base2, + &model, + bin, + params.ctx_size, + params.ngl, + ) + .await; + }); + Ok(()) +} + +/// 启动本地模型引擎并等待就绪,全程推送部署进度事件;返回是否加载成功。 +async fn start_engine_and_wait( + app: &AppHandle, + core: &CoreApp, + engine: &EngineManager, + base: &Arc>>, + model: &ModelInfo, + bin: PathBuf, + ctx_size: usize, + ngl: i32, +) -> bool { + let model_id = model.id.clone(); let file_name = model.file_name.clone(); let emit_deploy = |state_name: &str, message: Option| { let _ = app.emit( @@ -1515,21 +1588,7 @@ pub async fn deploy_model( }, ); }; - - let current = engine.status().await; - if current.running { - if current.model.as_deref() == Some(file_name.as_str()) { - emit_deploy("ready", None); - return Ok(()); - } - // 部署另一个模型前,先停掉当前引擎 - let _ = engine.stop().await; - *base.write().await = None; - let _ = app.emit("engine://status", engine.status().await); - } - - let bin = PathBuf::from(engine_bin); - if !bin.exists() { + if !bin.is_file() { emit_deploy( "error", Some(format!( @@ -1537,97 +1596,126 @@ pub async fn deploy_model( bin.display() )), ); - return Ok(()); + let _ = app.emit("engine://status", engine.status().await); + return false; } - emit_deploy("loading", None); - let app2 = app.clone(); - let core2 = core.clone(); - let engine2 = engine.clone(); - let base2 = base.clone(); - let model_id2 = model_id.clone(); - let file_name2 = file_name.clone(); - - tokio::spawn(async move { - let cfg = EngineConfig { - binary_path: bin, - model_path: PathBuf::from(model.file_path.clone()), - host: "127.0.0.1".into(), - ctx_size: params.ctx_size, - ngl: params.ngl, - threads: None, - log_file: core2.logs_dir.join(format!("engine-{}.log", model.file_name)), - }; - // 后台轮询引擎日志,解析模型加载进度并广播 - let log_path = cfg.log_file.clone(); - let progress_app = app2.clone(); - let progress_model = model_id2.clone(); - let progress_name = file_name2.clone(); - let progress_task = tokio::spawn(async move { - let mut last_size = 0u64; - loop { - if let Ok(meta) = tokio::fs::metadata(&log_path).await { - let size = meta.len(); - if size != last_size { - last_size = size; - if let Ok(bytes) = tokio::fs::read(&log_path).await { - let text = String::from_utf8_lossy(&bytes); - let (percent, stage) = estimate_load_progress(&text); - let _ = progress_app.emit( - "engine://deploy-progress", - EngineDeployProgressEvent { - model_id: progress_model.clone(), - file_name: progress_name.clone(), - percent, - stage: stage.to_string(), - }, - ); - } + let cfg = EngineConfig { + binary_path: bin, + model_path: PathBuf::from(model.file_path.clone()), + host: "127.0.0.1".into(), + ctx_size, + ngl, + threads: None, + log_file: core.logs_dir.join(format!("engine-{}.log", model.file_name)), + }; + // 后台轮询引擎日志,解析模型加载进度并广播 + let log_path = cfg.log_file.clone(); + let progress_app = app.clone(); + let progress_model = model_id.clone(); + let progress_name = file_name.clone(); + let progress_task = tokio::spawn(async move { + let mut last_size = 0u64; + loop { + if let Ok(meta) = tokio::fs::metadata(&log_path).await { + let size = meta.len(); + if size != last_size { + last_size = size; + if let Ok(bytes) = tokio::fs::read(&log_path).await { + let text = String::from_utf8_lossy(&bytes); + let (percent, stage) = estimate_load_progress(&text); + let _ = progress_app.emit( + "engine://deploy-progress", + EngineDeployProgressEvent { + model_id: progress_model.clone(), + file_name: progress_name.clone(), + percent, + stage: stage.to_string(), + }, + ); } } - tokio::time::sleep(std::time::Duration::from_millis(250)).await; - } - }); - - match engine2.start(cfg).await { - Ok(()) => { - progress_task.abort(); - *base2.write().await = engine2.base_url().await; - let _ = app2.emit( - "engine://deploy", - EngineDeployEvent { - model_id: model_id2.clone(), - file_name: file_name2.clone(), - state: "ready".into(), - message: None, - }, - ); - let _ = app2.emit( - "engine://deploy-progress", - EngineDeployProgressEvent { - model_id: model_id2.clone(), - file_name: file_name2.clone(), - percent: 100, - stage: "服务已就绪".into(), - }, - ); - } - Err(e) => { - progress_task.abort(); - let _ = app2.emit( - "engine://deploy", - EngineDeployEvent { - model_id: model_id2, - file_name: file_name2, - state: "error".into(), - message: Some(e.to_string()), - }, - ); } + tokio::time::sleep(std::time::Duration::from_millis(250)).await; } - let _ = app2.emit("engine://status", engine2.status().await); }); - Ok(()) + + let result = engine.start(cfg).await; + progress_task.abort(); + match result { + Ok(()) => { + *base.write().await = engine.base_url().await; + emit_deploy("ready", None); + let _ = app.emit( + "engine://deploy-progress", + EngineDeployProgressEvent { + model_id: model_id.clone(), + file_name: file_name.clone(), + percent: 100, + stage: "服务已就绪".into(), + }, + ); + let _ = app.emit("engine://status", engine.status().await); + true + } + Err(e) => { + emit_deploy("error", Some(e.to_string())); + let _ = app.emit("engine://status", engine.status().await); + false + } + } +} + +/// 启动时按设置中保存的顺序自动加载本地模型:逐个部署,最后一个保持运行。 +pub async fn auto_load_models(app: &AppHandle) { + let state = app.state::(); + let core = state.core.clone(); + let engine = state.engine.clone(); + let base = state.engine_base.clone(); + + let (models, engine_bin) = { + let db = core.db.lock().unwrap(); + let raw = settings_db::get(&db, "auto_load_models") + .ok() + .flatten() + .unwrap_or_else(|| "[]".to_string()); + let ids: Vec = serde_json::from_str(&raw).unwrap_or_default(); + let models: Vec = ids + .iter() + .filter_map(|id| models_db::get(&db, id).ok().flatten()) + .filter(|m| m.kind == "local") + .collect(); + let bin = settings_db::get(&db, "engine_bin") + .ok() + .flatten() + .unwrap_or_default(); + (models, bin) + }; + if models.is_empty() { + return; + } + tracing::info!(count = models.len(), "auto-load models: start"); + let bin = PathBuf::from(engine_bin); + let total = models.len(); + for (i, model) in models.iter().enumerate() { + // 切换模型前先停掉当前引擎(start_engine_and_wait 只负责启动) + let current = engine.status().await; + if current.running { + let _ = engine.stop().await; + *base.write().await = None; + let _ = app.emit("engine://status", engine.status().await); + } + let ok = start_engine_and_wait(app, &core, &engine, &base, model, bin.clone(), 4096, 99) + .await; + tracing::info!(model = %model.file_name, ok, "auto-load model: finished"); + // 非最后一个模型:加载完即停止,继续下一个;最后一个保持运行 + if i + 1 < total { + let _ = engine.stop().await; + *base.write().await = None; + let _ = app.emit("engine://status", engine.status().await); + } + } + tracing::info!("auto-load models: done"); } #[tauri::command] diff --git a/apps/desktop/src/lib.rs b/apps/desktop/src/lib.rs index dc4f806..43b650d 100644 --- a/apps/desktop/src/lib.rs +++ b/apps/desktop/src/lib.rs @@ -7,6 +7,7 @@ use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; use tauri::{Emitter, Manager}; +use tauri_plugin_autostart::MacosLauncher; use xianren_core::CoreApp; use xianren_core::models as models_db; use xianren_core::settings as settings_db; @@ -62,6 +63,10 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_autostart::init( + MacosLauncher::LaunchAgent, + None, + )) .setup(|app| { let core = CoreApp::init(None) .map_err(|e| -> Box { e.to_string().into() })?; @@ -135,6 +140,12 @@ pub fn run() { } let _ = app.emit("models://updated", ()); + // 自动加载模型:按设置列表顺序逐个部署,最后一个保持运行 + let auto_load_app = app.handle().clone(); + tauri::async_runtime::spawn(async move { + commands::auto_load_models(&auto_load_app).await; + }); + // 定时计划后台调度:每 30 秒检查一次到点的计划并执行 let scheduler_app = app.handle().clone(); tauri::async_runtime::spawn(async move { @@ -169,6 +180,8 @@ pub fn run() { commands::remove_scheduled_task, commands::set_scheduled_task_enabled, commands::run_scheduled_task_now, + commands::autostart_status, + commands::autostart_set, commands::list_models, commands::import_model, commands::remove_model, diff --git a/crates/core/src/app.rs b/crates/core/src/app.rs index 4ab728f..3cc6284 100644 --- a/crates/core/src/app.rs +++ b/crates/core/src/app.rs @@ -76,6 +76,7 @@ impl CoreApp { "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".to_string(), ), ("tool_web_search_enabled", "true".to_string()), + ("auto_load_models", "[]".to_string()), ( "recommend_dir", self.data_dir diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 11a9740..058ac02 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -96,6 +96,8 @@ ui/src/ ### 4.1 `lib.rs` - `run()`:初始化数据目录(`%APPDATA%\XianrenStudio`)、日志、panic 钩子;构建 Tauri 应用并注册 `invoke_handler`。 +- 注册插件:`tauri-plugin-opener`、`tauri-plugin-autostart`(开机启动,Windows 写注册表启动项)。 +- `setup` 末尾启动**自动加载模型**任务(`auto_load_models`:按设置列表顺序逐个部署,最后一个保持运行)。 - `setup` 末尾启动**定时计划后台调度循环**(tokio interval 每 30 秒调用 `run_due_scheduled_tasks`)。 - 数据目录:`logs/`、`models/`、`engines/`、`recommendations/`。 @@ -106,7 +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` | -| 应用/设置 | `app_info`、`settings_get`、`settings_set` | +| 应用/设置 | `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` | | 会话 | `list_conversations`(可按 `agent_id` 过滤)、`create_conversation`(可选 `agent_id`,自动继承智能体系统提示词与默认模型)、`rename_conversation`、`set_conversation_pinned`、`set_conversation_favorite`、`import_conversation`、`set_conversation_tools`、`delete_conversation`、`get_messages` | @@ -131,6 +133,9 @@ ui/src/ - `run_due_scheduled_tasks`:后台调度入口,由 `lib.rs` 启动的 tokio 循环每 30 秒调用一次;先清理重启前中断的「运行中」任务,再取出到点的启用计划逐个标记并异步执行。 - `execute_scheduled_task` / `run_scheduled_task_once`:定时计划单次执行——解析执行智能体(指定智能体已删除时回退「通用助手」)与模型(`resolve_scheduled_model`:智能体默认模型 → 第一个本地模型 → 第一个已启用在线模型),在该智能体作用域内创建/复用空会话,写入用户消息并注入智能体系统提示词,本地模型未运行则自动拉起引擎,最后经 `call_model_messages` 调用模型并把助手回答写回数据库。 - `call_model_messages`:`call_model_text` 的通用版,可携带 system + 多轮消息调用本地引擎或远程 OpenAI 兼容 API(定时计划复用)。 +- `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`)。 **工作流执行引擎(`apps/desktop/src/workflow.rs`):** @@ -260,6 +265,7 @@ ui/src/ | `suggest_count` | 3 | 预测条数(1–5) | | `tavily_api_key` | (内置演示值) | 联网搜索 API Key | | `tool_web_search_enabled` | true | 联网搜索工具开关 | +| `auto_load_models` | `[]` | 启动时自动加载的本地模型 id 列表(JSON 数组,顺序即加载顺序) | | `recommend_dir` | 数据目录/recommendations | 推荐列表目录 | | `preset_agents_seeded` | 首次启动后为 `1` | 预制智能体是否已写入(内部标记,避免覆盖用户删除) | | `preset_workflows_seeded` | 首次启动后为 `1` | 预制工作流是否已写入(内部标记,避免覆盖用户删除) | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index bd3cd63..a54434e 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -189,6 +189,9 @@ ## 10. 设置页(`/settings`) - **引擎与路径**:模型目录、llama-server 路径、模型下载源(hf-mirror / huggingface.co / modelscope.cn)、默认后端(auto/cpu/cuda/vulkan)、上传大小上限。 +- **启动**: + - **开机启动**:登录 Windows 后自动启动应用(经 `tauri-plugin-autostart` 写入注册表启动项,可随时开关)。 + - **自动加载模型列表**:维护一个**有序**的本地模型列表(支持添加 / 移除 / 上下调整顺序);应用启动后按列表顺序**逐个部署加载**,由于同一时间只能运行一个本地模型,全部加载完成后保留列表最后一个模型在运行(加载参数使用默认值 ctx 4096 / GPU 层数 99)。 - **对话**: - 自动生成对话标题(默认开):首次回复后自动生成并覆盖标题。 - **自动预测用户接下来说的话**(默认开):见 2.4。 @@ -206,6 +209,7 @@ ### 2026-08-17 +- 设置页新增「启动」:开机启动开关(写入 Windows 注册表启动项);「自动加载模型」列表(多个本地模型按顺序在启动时逐个加载,最后一个保持运行)。 - 任务页新增「定时计划」:可指定执行智能体(默认通用助手)、执行内容与间隔(分钟 / 小时 / 天),后台到点自动在智能体会话中执行并记录结果;支持立即执行 / 编辑 / 删除 / 启用停用。 - 工作流画布顶栏微调:名称输入框宽度改为按文本实际宽度计算(中文按 2 倍字符宽折算,可完整显示名称),空间不足时自动收缩,保证右侧「保存 / 运行」按钮不被遮挡。 diff --git a/ui/src/api.ts b/ui/src/api.ts index 5b6517a..3c29caf 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -366,6 +366,8 @@ export interface ServerStatus { export const api = { appInfo: () => invoke("app_info"), + autostartStatus: () => invoke("autostart_status"), + autostartSet: (enabled: boolean) => invoke("autostart_set", { enabled }), listAgents: () => invoke("list_agents"), addAgent: (input: AgentInput) => invoke("add_agent", { input }), updateAgent: (id: string, input: AgentInput) => diff --git a/ui/src/pages/SettingsPage.tsx b/ui/src/pages/SettingsPage.tsx index 18d594a..b5d3e50 100644 --- a/ui/src/pages/SettingsPage.tsx +++ b/ui/src/pages/SettingsPage.tsx @@ -1,27 +1,92 @@ -import { useEffect, useRef, useState } from "react"; -import { api, AppInfo } from "../api"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { api, AppInfo, ModelInfo } from "../api"; +import { useStore } from "../store"; export default function SettingsPage() { const [info, setInfo] = useState(null); const [settings, setSettings] = useState>({}); const [recommendDirDraft, setRecommendDirDraft] = useState(""); const [uploadMsg, setUploadMsg] = useState(null); + const [startupMsg, setStartupMsg] = useState(null); + const [autostart, setAutostart] = useState(null); + const [autoLoadDraft, setAutoLoadDraft] = useState([]); + const [pickId, setPickId] = useState(""); const uploadInputRef = useRef(null); + const models = useStore((s) => s.models); + const refreshModels = useStore((s) => s.refreshModels); useEffect(() => { api.appInfo().then(setInfo); api.settingsGet().then(setSettings); + api.autostartStatus().then(setAutostart).catch(() => setAutostart(false)); + refreshModels(); }, []); useEffect(() => { setRecommendDirDraft(settings.recommend_dir ?? ""); }, [settings.recommend_dir]); + useEffect(() => { + try { + const parsed = JSON.parse(settings.auto_load_models ?? "[]"); + setAutoLoadDraft(Array.isArray(parsed) ? parsed : []); + } catch { + setAutoLoadDraft([]); + } + }, [settings.auto_load_models]); + async function handleSave(key: string, value: string) { await api.settingsSet(key, value); setSettings((prev) => ({ ...prev, [key]: value })); } + async function handleAutostartToggle(checked: boolean) { + try { + setAutostart(await api.autostartSet(checked)); + setStartupMsg(checked ? "已开启开机启动" : "已关闭开机启动"); + } catch (e) { + setStartupMsg(`开机启动设置失败:${String(e)}`); + } + } + + const localModels = useMemo( + () => models.filter((m) => m.kind === "local"), + [models], + ); + const draftRows = useMemo( + () => + autoLoadDraft + .map((id) => localModels.find((m) => m.id === id)) + .filter((m): m is ModelInfo => Boolean(m)), + [autoLoadDraft, localModels], + ); + const available = useMemo( + () => localModels.filter((m) => !autoLoadDraft.includes(m.id)), + [localModels, autoLoadDraft], + ); + + function moveRow(index: number, dir: -1 | 1) { + setAutoLoadDraft((prev) => { + const next = [...prev]; + const target = index + dir; + if (target < 0 || target >= next.length) return prev; + [next[index], next[target]] = [next[target], next[index]]; + return next; + }); + } + + function removeRow(index: number) { + setAutoLoadDraft((prev) => prev.filter((_, i) => i !== index)); + } + + async function handleSaveAutoLoad() { + await handleSave("auto_load_models", JSON.stringify(draftRows.map((m) => m.id))); + } + + function modelDisplayName(m: ModelInfo) { + return m.file_name.replace(/\.gguf$/i, ""); + } + async function handleUpload(file: File) { setUploadMsg(null); try { @@ -105,6 +170,118 @@ export default function SettingsPage() { +
+
启动
+ {startupMsg ? ( +
{startupMsg}
+ ) : null} +
+
+
开机启动
+
+ 登录 Windows 后自动启动仙人工作室 +
+
+ +
+ +
+
自动加载模型
+
+ 应用启动后按列表顺序逐个加载本地模型;由于同一时间只能运行一个模型,全部加载完成后保留列表最后一个在运行 +
+
+ {draftRows.length === 0 ? ( +
+ 还没有添加模型,从下方下拉框选择要自动加载的本地模型 +
+ ) : ( + draftRows.map((m, i) => ( +
+ {i + 1} + + {modelDisplayName(m)} + + + + +
+ )) + )} +
+
+ + +
+
+ + 列表顺序即加载顺序,可拖动上下按钮调整 + + +
+
+
+
对话
@@ -242,19 +419,22 @@ export default function SettingsPage() { function Toggle({ checked, onChange, + disabled = false, }: { checked: boolean; onChange: (checked: boolean) => void; + disabled?: boolean; }) { return (