Add an AI by its address in Settings → AI (#137)

Settings → AI gets an Add an AI by its address form; AiHub::add_server asks the server for its models before adding it and picks one that can chat when none is named; ai add --kind openai_compatible --base-url without --model does the same. Fixes #54, #98 and #128.
This commit is contained in:
BFlat
2026-11-02 02:32:11 +07:00
committed by GitHub
parent 8c3532d94c
commit 8ea06bb6fb
11 changed files with 183 additions and 1 deletions
+10
View File
@@ -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) {
+1 -1
View File
@@ -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<String> {
pub(crate) fn chat_model(models: &[String]) -> Option<String> {
let chats = |m: &&String| !NOT_CHAT.iter().any(|w| m.to_ascii_lowercase().contains(w));
models.iter().find(chats).or(models.first()).cloned()
}
+38
View File
@@ -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<String>) -> Result<String, ToolError> {
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<String>) -> Result<(), ToolError> {
if entry.config.name.trim().is_empty() {
+17
View File
@@ -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 },
+12
View File
@@ -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<String>) {
let (hub, name) = (self.hub.clone(), name.to_string());
+10
View File
@@ -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 开头,仅提出问题
Can't render this file because it contains an unexpected character in line 48 and column 52.
+10
View File
@@ -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 開頭,僅提出問題
Can't render this file because it contains an unexpected character in line 48 and column 52.
+18
View File
@@ -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<LogLine>,
pub key_inputs: std::collections::BTreeMap<String, String>,
/// 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();
+35
View File
@@ -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.
+1
View File
@@ -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 <name> --kind --model --base-url --ssh-host --key-env --key-stdin")),
a("add_server", "connect an AI server by its address", Cli("ai add <name> --kind --base-url --key-env --key-stdin")),
a("choose_backend", "pick the AI brain", Cli("chat <question> --backend")),
a("set_role", "choose which AI builds or extracts", Cli("ai extractor <name>")),
+31
View File
@@ -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]