mirror of
https://github.com/storytold/craft-anything.git
synced 2026-11-05 20:47:45 +01:00
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:
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>")),
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user