diff --git a/apps/craft-anything-cli/src/ai_cmd.rs b/apps/craft-anything-cli/src/ai_cmd.rs index 6f61e150..59c43e65 100644 --- a/apps/craft-anything-cli/src/ai_cmd.rs +++ b/apps/craft-anything-cli/src/ai_cmd.rs @@ -215,6 +215,16 @@ pub fn ai(cli: &Cli, c: &AiCommand, f: Format) -> u8 { Ok(k) => k, Err(m) => return crate::usage_error(&m), }; + // A server by its address with no model named: asked for its models, and one that can chat is used. + if let (BackendKind::OpenaiCompatible, Some(address), None) = (kind, base_url, model) { + return match hub.add_server(name, address, None, key) { + Ok(model) => { + emit(&json!({"added": {"name": name, "kind": kind, "model": model, "base_url": address}}), f); + 0 + } + Err(e) => fail(&e, f), + }; + } let model = model.clone().or_else(|| kind.suggested_models().first().map(|m| m.to_string())).unwrap_or_default(); let cfg = BackendConfig { name: name.clone(), kind, model, base_url: base_url.clone(), ssh_host: ssh_host.clone(), enabled: true }; match hub.add_backend(cfg.clone(), key) { diff --git a/crates/ai/src/detect.rs b/crates/ai/src/detect.rs index 95f772c6..98662223 100644 --- a/crates/ai/src/detect.rs +++ b/crates/ai/src/detect.rs @@ -60,7 +60,7 @@ pub fn port_open(base_url: &str, timeout: Duration) -> bool { const NOT_CHAT: &[&str] = &["embed", "minilm", "bge-", "rerank", "whisper", "tts"]; /// The first model a server lists that can chat (else its first, so something is chosen). -fn chat_model(models: &[String]) -> Option { +pub(crate) fn chat_model(models: &[String]) -> Option { let chats = |m: &&String| !NOT_CHAT.iter().any(|w| m.to_ascii_lowercase().contains(w)); models.iter().find(chats).or(models.first()).cloned() } diff --git a/crates/ai/src/lib.rs b/crates/ai/src/lib.rs index 60035706..7a99604c 100644 --- a/crates/ai/src/lib.rs +++ b/crates/ai/src/lib.rs @@ -337,6 +337,44 @@ impl AiHub { self.add_backend_with(BackendEntry { config: cfg, options: BackendOptions::default() }, key) } + /// Add an OpenAI-compatible server by its address: Ollama or LM Studio on another computer, or + /// a hosted service with its key. It's added only when it answers with its models; without a + /// model, the first one it lists that can chat is used. Returns the model. + pub fn add_server(&self, name: &str, address: &str, model: Option<&str>, key: Option) -> Result { + let name = name.trim(); + let address = address.trim().trim_end_matches('/'); + if name.is_empty() { + return Err(ToolError::invalid("A backend needs a name.")); + } + if !(address.starts_with("http://") || address.starts_with("https://")) { + return Err(ToolError::invalid(format!("{address:?} isn't a web address.")) + .with_remediation("Give the server's address, like http://192.168.1.20:11434/v1.")); + } + let key = key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()); + let models = openai::OpenAi::compatible(name, address, key.clone(), "").list_models(Duration::from_secs(10)).map_err(|e| { + let why = e.message.clone(); + e.with_remediation(format!( + "Check the address (it usually ends in /v1), that the server is running and can be reached from this computer, and its key if it needs one. ({why})" + )) + })?; + let model = match model.map(str::trim).filter(|m| !m.is_empty()) { + Some(m) => m.to_string(), + None => detect::chat_model(&models).ok_or_else(|| { + ToolError::new(ErrorCode::NotFound, format!("{address} has no models yet.")).with_remediation("Add a model on the server first, or name one.") + })?, + }; + let config = BackendConfig { + name: name.to_string(), + kind: BackendKind::OpenaiCompatible, + model: model.clone(), + base_url: Some(address.to_string()), + ssh_host: None, + enabled: true, + }; + self.add_backend(config, key)?; + Ok(model) + } + /// Like [`AiHub::add_backend`], with options. pub fn add_backend_with(&self, entry: BackendEntry, key: Option) -> Result<(), ToolError> { if entry.config.name.trim().is_empty() { diff --git a/crates/ai/src/tests.rs b/crates/ai/src/tests.rs index 11115b0e..23950c89 100644 --- a/crates/ai/src/tests.rs +++ b/crates/ai/src/tests.rs @@ -172,6 +172,23 @@ fn an_agent_apps_program_is_set_and_cleared() { assert!(h.commands().is_empty(), "blank finds it by its usual name again"); } +/// #54, #98: a server on another computer is added by its address, with a model that can chat +/// when none is named; one that doesn't answer with its models isn't added. +#[test] +fn a_server_is_added_by_its_address() { + let (h, _d) = hub(); + let url = serve("200 OK", r#"{"object":"list","data":[{"id":"nomic-embed-text"},{"id":"qwen3:8b"}]}"#); + assert_eq!(h.add_server(" Studio box ", &format!("{url}/"), None, Some(" sk-test ".into())).unwrap(), "qwen3:8b"); + let b = h.backends().into_iter().find(|b| b.name == "Studio box").unwrap(); + assert_eq!((b.kind, b.model.as_str(), b.base_url.as_deref()), (BackendKind::OpenaiCompatible, "qwen3:8b", Some(url.as_str()))); + assert_eq!(h.add_server("Named", &url, Some("llama3"), None).unwrap(), "llama3", "a named model is kept"); + assert!(h.add_server("Web page", &serve("404 Not Found", "nope"), None, None).is_err(), "not a model server"); + assert!(h.add_server("Empty", &serve("200 OK", r#"{"object":"list","data":[]}"#), None, None).is_err(), "no models to use"); + assert!(h.add_server("No scheme", "192.168.1.20:11434/v1", None, None).is_err()); + assert!(h.add_server(" ", &url, None, None).is_err()); + assert_eq!(h.backends().len(), 2, "only the ones that answered"); +} + fn add(h: &AiHub, name: &str, kind: BackendKind, base: &str, key: Option<&str>) { h.add_backend( BackendConfig { name: name.into(), kind, model: String::new(), base_url: Some(base.into()), ssh_host: None, enabled: true }, diff --git a/crates/ui-egui/src/builders.rs b/crates/ui-egui/src/builders.rs index da685f0b..0448f29e 100644 --- a/crates/ui-egui/src/builders.rs +++ b/crates/ui-egui/src/builders.rs @@ -83,6 +83,18 @@ impl CraftApp { }); } + /// Connect the server in Settings → AI's address form. Its key goes to the keychain; the form + /// keeps what was typed until it's added, so a mistake can be fixed. + pub fn add_server(&mut self) { + let hub = self.hub.clone(); + let f = &self.server_form; + let (name, address, model, key) = (f.name.trim().to_string(), f.address.clone(), f.model.trim().to_string(), Some(f.key.clone())); + self.jobs.spawn(trf("Connect {name}", &[("name", &name)]), true, move |_| { + let result = hub.add_server(&name, &address, Some(model.as_str()).filter(|m| !m.is_empty()), key).map(|_| ()); + Ok(crate::Done::BackendAdded { name, result }) + }); + } + /// Run an agent app from this program (`None`: find it by its usual name again). pub fn set_agent_program(&mut self, name: &str, program: Option) { let (hub, name) = (self.hub.clone(), name.to_string()); diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index f0bb7fe6..3804ef3b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -69,9 +69,12 @@ AI cost AI 费用 AI cost (estimate) AI 费用 (估算) AI cost (part estimate) AI 费用 (部分为估算) AI model file AI 模型文件 +AI name AI 名称 AI use this session 本次会话的 AI 用量 API boundary API 边界 +API key API 密钥 API key (stored in your system keychain) API 密钥 (存储在系统钥匙串中) +API key, if it needs one API 密钥 (如需要) About 10 GB. Free for personal use with a Unity account. 约 10 GB。个人使用免费,需要 Unity 账户。 About 60 GB, an Epic Games account, and Visual Studio's C++ tools. 约 60 GB,需要 Epic Games 账户和 Visual Studio 的 C++ 工具。 Add 添加 @@ -81,6 +84,7 @@ Add a fal.ai key 添加 fal.ai 密钥 Add a folder… 添加文件夹… Add a plugin 添加插件 Add an AI brain and I'll read this evidence for you, answer follow-up questions and point at the proof. 添加 AI 助手后,我就能为你解读这些证据、回答后续问题,并指出依据。 +Add an AI by its address 按地址添加 AI Add more info. The builder reads it at its next step. 补充更多信息。构建助手会在下一步读取。 Add pictures 添加图片 Add pictures of the original 添加原作图片 @@ -649,6 +653,7 @@ Medium 中 Memory for Ghidra Ghidra 内存用量 Minimise 最小化 Mode 模式 +Model 模型 Modules 模块 More detail 更多详情 More makes a big program's first look faster. 分配更多内存可加快大型程序的首次分析。 @@ -712,6 +717,7 @@ Objective-C protocols Objective-C 协议 Observe a JS runtime 观察 JS 运行时 Of this, {cost} was charged to an API key. 其中 {cost} 通过 API 密钥计费。 Off 关闭 +Ollama or LM Studio on another computer, or any service that speaks the OpenAI API. 另一台电脑上的 Ollama 或 LM Studio,或任何兼容 OpenAI API 的服务。 On 开启 One line, in plain words. It stays on this computer and is never sent anywhere. 用一句通俗的话说明。内容只保留在本机,绝不会发送到其他地方。 One-shot instrumentation 单次插桩 @@ -953,6 +959,7 @@ Selection 所选内容 Send 发送 Send (Enter) 发送 (Enter) Send now 立即发送 +Server address 服务器地址 Session status 会话状态 Set a project's asset budget 设置项目素材预算 Set an AI's program 设置 AI 的程序 @@ -1238,6 +1245,7 @@ Your website is ready 你的网站已就绪 a new folder in {dir} {dir} 中的新文件夹 a picture 每张图片 about ${n} 约 ${n} +address, e.g. http://192.168.1.20:11434/v1 地址,例如 http://192.168.1.20:11434/v1 and {n} more 以及另外 {n} 项 arguments (optional) 参数 (可选) call graph 调用图 @@ -1258,8 +1266,10 @@ job result 任务结果 job {title} panicked: {what} 任务 {title} 发生异常:{what} just now 刚刚 managed 由应用管理 +model (optional) 模型 (可选) must be a number 必须是数字 must be a whole number 必须是整数 +name, e.g. Studio box 名称,例如 工作室电脑 number 数字 objdump output objdump 输出 or start with /btw to just ask 或以 /btw 开头,仅提出问题 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 348ad64b..81102d48 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -69,9 +69,12 @@ AI cost AI 費用 AI cost (estimate) AI 費用 (估算) AI cost (part estimate) AI 費用 (部分為估算) AI model file AI 模型檔案 +AI name AI 名稱 AI use this session 本次工作階段的 AI 用量 API boundary API 邊界 +API key API 金鑰 API key (stored in your system keychain) API 金鑰 (儲存在系統鑰匙圈中) +API key, if it needs one API 金鑰 (如需要) About 10 GB. Free for personal use with a Unity account. 約 10 GB。個人使用免費,需要 Unity 帳戶。 About 60 GB, an Epic Games account, and Visual Studio's C++ tools. 約 60 GB,需要 Epic Games 帳戶和 Visual Studio 的 C++ 工具。 Add 新增 @@ -81,6 +84,7 @@ Add a fal.ai key 新增 fal.ai 金鑰 Add a folder… 新增資料夾… Add a plugin 新增外掛 Add an AI brain and I'll read this evidence for you, answer follow-up questions and point at the proof. 新增 AI 助手後,我就能為你解讀這些證據、回答後續問題,並指出依據。 +Add an AI by its address 依位址新增 AI Add more info. The builder reads it at its next step. 補充更多資訊。建置助手會在下一步讀取。 Add pictures 新增圖片 Add pictures of the original 新增原作圖片 @@ -649,6 +653,7 @@ Medium 中 Memory for Ghidra Ghidra 記憶體用量 Minimise 最小化 Mode 模式 +Model 模型 Modules 模組 More detail 更多詳細資訊 More makes a big program's first look faster. 分配更多記憶體可加快大型程式的首次分析。 @@ -712,6 +717,7 @@ Objective-C protocols Objective-C 協定 Observe a JS runtime 觀察 JS 執行環境 Of this, {cost} was charged to an API key. 其中 {cost} 透過 API 金鑰計費。 Off 關閉 +Ollama or LM Studio on another computer, or any service that speaks the OpenAI API. 另一台電腦上的 Ollama 或 LM Studio,或任何相容 OpenAI API 的服務。 On 開啟 One line, in plain words. It stays on this computer and is never sent anywhere. 用一句淺白的話說明。內容只保留在本機,絕不會傳送到其他地方。 One-shot instrumentation 單次插樁 @@ -953,6 +959,7 @@ Selection 所選內容 Send 傳送 Send (Enter) 傳送 (Enter) Send now 立即傳送 +Server address 伺服器位址 Session status 工作階段狀態 Set a project's asset budget 設定專案素材預算 Set an AI's program 設定 AI 的程式 @@ -1238,6 +1245,7 @@ Your website is ready 你的網站已就緒 a new folder in {dir} {dir} 中的新資料夾 a picture 每張圖片 about ${n} 約 ${n} +address, e.g. http://192.168.1.20:11434/v1 位址,例如 http://192.168.1.20:11434/v1 and {n} more 以及另外 {n} 項 arguments (optional) 引數 (選填) call graph 呼叫圖 @@ -1258,8 +1266,10 @@ job result 工作結果 job {title} panicked: {what} 工作 {title} 發生異常:{what} just now 剛剛 managed 由應用程式管理 +model (optional) 模型 (選填) must be a number 必須是數字 must be a whole number 必須是整數 +name, e.g. Studio box 名稱,例如 工作室電腦 number 數字 objdump output objdump 輸出 or start with /btw to just ask 或以 /btw 開頭,僅提出問題 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 30f47d44..c852e58f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -220,6 +220,17 @@ pub enum Done { Nothing, } +/// An AI server added by its address (Settings → AI): another computer's Ollama or LM Studio, or a +/// service that speaks the OpenAI API. +#[derive(Debug, Clone, Default)] +pub struct ServerForm { + pub name: String, + pub address: String, + /// Empty: the first model it lists that can chat. + pub model: String, + pub key: String, +} + /// What a file dialog was for. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Pick { @@ -341,6 +352,9 @@ pub struct CraftApp { pub detecting: bool, pub log: Vec, pub key_inputs: std::collections::BTreeMap, + /// Settings → AI's "Add an AI by its address" form. Not kept with the UI state: its key goes + /// only to the keychain. + pub server_form: ServerForm, /// Every project's rebuild in this window: working, waiting their turn, or finished. pub runs: craft_anything_ai::rebuild::RunSet, /// What was typed for each note still waiting for a builder, by note id, to put back in the @@ -431,6 +445,7 @@ impl CraftApp { detecting: false, log: Vec::new(), key_inputs: Default::default(), + server_form: ServerForm::default(), runs: craft_anything_ai::rebuild::RunSet::new(craft_anything_engine::settings::BuildSettings::default().parallel), drafts: Default::default(), usage: Default::default(), @@ -1273,6 +1288,9 @@ impl CraftApp { } Done::BackendAdded { name, result } => match result { Ok(()) => { + if self.server_form.name.trim() == name { + self.server_form = ServerForm::default(); + } self.toast(i18n::trf("{name} is ready", &[("name", &name)]), false); self.choose_backend(Some(name)); self.refresh_backends(); diff --git a/crates/ui-egui/src/settings_ui.rs b/crates/ui-egui/src/settings_ui.rs index bc523689..f7f85581 100644 --- a/crates/ui-egui/src/settings_ui.rs +++ b/crates/ui-egui/src/settings_ui.rs @@ -482,6 +482,41 @@ fn ai(app: &mut CraftApp, ui: &mut Ui) { }); ui.add_space(6.0); } + server_form(app, ui); +} + +/// An AI that "Look for AI" can't find: a server on another computer or a hosted service, added by +/// its address (#54, #98). The server is asked for its models before it's added. +fn server_form(app: &mut CraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + ui.add_space(14.0); + widgets::caption(ui, "Add an AI by its address"); + ui.label(RichText::new(tr("Ollama or LM Studio on another computer, or any service that speaks the OpenAI API.")).color(t.text_faint).size(theme::CAPTION)); + ui.add_space(4.0); + let form = &mut app.server_form; + // `width` is the text's; the compact frame adds 8 points a side and its border. + const FRAME: f32 = 18.0; + let field = |ui: &mut Ui, text: &mut String, hint: &str, width: f32, name: &str, secret: bool| { + let r = ui.add(egui::TextEdit::singleline(text).password(secret).hint_text(tr(hint)).desired_width(width).frame(widgets::text_field_frame_compact(&t))); + widgets::named(r, &tr(name)); + }; + ui.horizontal(|ui| { + field(ui, &mut form.name, "name, e.g. Studio box", 150.0, "AI name", false); + let w = ui.available_width() - FRAME; + field(ui, &mut form.address, "address, e.g. http://192.168.1.20:11434/v1", w, "Server address", false); + }); + let ready = !form.name.trim().is_empty() && !form.address.trim().is_empty(); + let mut add = false; + ui.horizontal(|ui| { + field(ui, &mut form.model, "model (optional)", 150.0, "Model", false); + // Room for the Add button after it. + let w = (ui.available_width() - FRAME - 90.0).max(120.0); + field(ui, &mut form.key, "API key, if it needs one", w, "API key", true); + add = widgets::primary_icon(ui, Some("plus"), "Add", ready).clicked() && ready; + }); + if add { + app.add_server(); + } } /// A number field; true when it changed. diff --git a/crates/ui-egui/tests/headless_parity.rs b/crates/ui-egui/tests/headless_parity.rs index 23925d3f..1e5e9b3e 100644 --- a/crates/ui-egui/tests/headless_parity.rs +++ b/crates/ui-egui/tests/headless_parity.rs @@ -106,6 +106,7 @@ const ACTIONS: &[Action] = &[ a("refresh_backends", "the connected AI brains", Cli("ai list")), a("detect_backends", "find AI on this computer", Cli("ai detect")), a("add_backend", "connect an AI brain", Cli("ai add --kind --model --base-url --ssh-host --key-env --key-stdin")), + a("add_server", "connect an AI server by its address", Cli("ai add --kind --base-url --key-env --key-stdin")), a("choose_backend", "pick the AI brain", Cli("chat --backend")), a("set_role", "choose which AI builds or extracts", Cli("ai extractor ")), diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index 3096785c..a8889f71 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -918,6 +918,37 @@ fn command_palette_opens_and_lists_tools() { assert!(!h.state().state.palette.open); } +/// #54, #98: an AI server on another computer is added by its address in Settings → AI, with a +/// model that can chat, and the form empties once it's added. +#[test] +fn an_ai_server_is_added_by_its_address() { + // A stand-in model server: every request gets the same model list. + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let url = format!("http://{}/v1", listener.local_addr().unwrap()); + std::thread::spawn(move || { + use std::io::{Read, Write}; + for mut s in listener.incoming().flatten() { + let mut buf = [0u8; 4096]; + let _ = s.read(&mut buf); + let body = r#"{"object":"list","data":[{"id":"nomic-embed-text"},{"id":"qwen3:8b"}]}"#; + let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()); + } + }); + let mut h = harness(|| { + let mut a = demo::app(demo::engine(Mode::Experienced, true)); + a.state.settings_open = true; + a.state.settings_tab = craft_anything_ui_egui::state::SettingsTab::Ai; + a + }); + h.run_steps(2); + h.get_by_label("Add an AI by its address"); + h.state_mut().server_form = craft_anything_ui_egui::ServerForm { name: "Studio box".into(), address: url.clone(), ..Default::default() }; + h.run_steps(1); + h.get_by_label("Add").click(); + assert!(wait(&mut h, |a| a.backends.iter().any(|b| b.name == "Studio box" && b.model == "qwen3:8b")), "added with a model that can chat"); + assert!(h.state().server_form.name.is_empty(), "the form empties once it's added"); +} + /// #53: an agent app installed where it isn't looked for gets its program chosen in Settings → AI, /// and goes back to being found by its usual name. #[test]