mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-15 09:21:56 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
420908401c | ||
|
|
b70be55681 | ||
|
|
a0187e40e6 | ||
|
|
d32f20f9b3 | ||
|
|
0b552cbcb5 | ||
|
|
19fd3c8d2b |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "Git Clones",
|
||||
"message": "134,170",
|
||||
"message": "137,874",
|
||||
"color": "green",
|
||||
"namedLogo": "git"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"total_clones": 134170,
|
||||
"last_updated": "2026-06-27T07:07:41Z",
|
||||
"total_clones": 137874,
|
||||
"last_updated": "2026-06-29T07:46:59Z",
|
||||
"daily": {
|
||||
"2026-03-27": 2189,
|
||||
"2026-03-28": 1874,
|
||||
@@ -92,6 +92,9 @@
|
||||
"2026-06-22": 1350,
|
||||
"2026-06-23": 1468,
|
||||
"2026-06-24": 1635,
|
||||
"2026-06-25": 1640
|
||||
"2026-06-25": 1640,
|
||||
"2026-06-26": 1338,
|
||||
"2026-06-27": 1338,
|
||||
"2026-06-28": 1028
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+67
-52
@@ -11,7 +11,7 @@
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-autostart": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
@@ -42,7 +42,7 @@
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
@@ -3720,9 +3720,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
||||
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
|
||||
"integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -3730,9 +3730,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz",
|
||||
"integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
|
||||
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"bin": {
|
||||
@@ -3746,23 +3746,23 @@
|
||||
"url": "https://opencollective.com/tauri"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/cli-darwin-arm64": "2.10.1",
|
||||
"@tauri-apps/cli-darwin-x64": "2.10.1",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.10.1",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.10.1",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.1",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.10.1",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.10.1",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.10.1",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.10.1",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.10.1"
|
||||
"@tauri-apps/cli-darwin-arm64": "2.11.4",
|
||||
"@tauri-apps/cli-darwin-x64": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz",
|
||||
"integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
|
||||
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3777,9 +3777,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz",
|
||||
"integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
|
||||
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3794,9 +3794,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz",
|
||||
"integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
|
||||
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -3811,13 +3811,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz",
|
||||
"integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3828,13 +3831,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz",
|
||||
"integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3845,13 +3851,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz",
|
||||
"integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3862,13 +3871,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz",
|
||||
"integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3879,13 +3891,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz",
|
||||
"integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3896,9 +3911,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz",
|
||||
"integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3913,9 +3928,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz",
|
||||
"integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -3930,9 +3945,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz",
|
||||
"integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-autostart": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
@@ -49,7 +49,7 @@
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
|
||||
+211
-44
@@ -9,7 +9,7 @@ use tokio::sync::Mutex;
|
||||
const OLLAMA_PORT: u16 = 11434;
|
||||
const JARVIS_PORT: u16 = 8000;
|
||||
|
||||
/// Small, fast model pulled at startup so the app opens quickly.
|
||||
/// Small, fast model used when startup needs a default Ollama tag.
|
||||
const STARTUP_MODEL: &str = "qwen3.5:4b";
|
||||
|
||||
/// Tiny fallback model if even the startup model can't be pulled.
|
||||
@@ -104,7 +104,7 @@ fn default_local_model(ram_gb: f64) -> &'static str {
|
||||
struct BootPlan {
|
||||
/// Whether to start and wait for the bundled Ollama.
|
||||
launch_ollama: bool,
|
||||
/// The single Ollama model to pull (None for custom endpoints).
|
||||
/// The preferred Ollama model (None for custom endpoints).
|
||||
model_to_pull: Option<String>,
|
||||
/// Optional `(engine_key, bare_host)` override for a custom endpoint,
|
||||
/// e.g. `("lmstudio", "http://localhost:1234")`. Written into
|
||||
@@ -608,6 +608,69 @@ async fn wait_for_jarvis_health(
|
||||
}
|
||||
|
||||
async fn ollama_has_model(model: &str) -> bool {
|
||||
let models = ollama_model_names().await;
|
||||
matching_installed_model(&models, model).is_some()
|
||||
}
|
||||
|
||||
fn parse_ollama_model_names(body: &serde_json::Value) -> Vec<String> {
|
||||
body.get("models")
|
||||
.and_then(|m| m.as_array())
|
||||
.map(|models| {
|
||||
models
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
m.get("name")
|
||||
.or_else(|| m.get("model"))
|
||||
.and_then(|n| n.as_str())
|
||||
})
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.map(|name| name.to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn model_names_match(installed: &str, requested: &str) -> bool {
|
||||
installed == requested
|
||||
|| installed.strip_suffix(":latest") == Some(requested)
|
||||
|| requested.strip_suffix(":latest") == Some(installed)
|
||||
}
|
||||
|
||||
fn matching_installed_model(models: &[String], requested: &str) -> Option<String> {
|
||||
models
|
||||
.iter()
|
||||
.find(|model| model_names_match(model, requested))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn model_name_looks_embedding_only(model: &str) -> bool {
|
||||
let name = model.to_ascii_lowercase();
|
||||
["embed", "embedding", "rerank", "minilm", "bge-", "bge_", "e5-", "e5_"]
|
||||
.iter()
|
||||
.any(|marker| name.contains(marker))
|
||||
}
|
||||
|
||||
fn preferred_installed_model(models: &[String]) -> Option<String> {
|
||||
models
|
||||
.iter()
|
||||
.find(|model| !model.trim().is_empty() && !model_name_looks_embedding_only(model))
|
||||
.or_else(|| models.iter().find(|model| !model.trim().is_empty()))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn startup_installed_model(requested_model: &str, installed_models: &[String]) -> Option<String> {
|
||||
matching_installed_model(installed_models, requested_model)
|
||||
.or_else(|| preferred_installed_model(installed_models))
|
||||
}
|
||||
|
||||
fn should_persist_resolved_model(cfg: &InferenceConfig) -> bool {
|
||||
cfg.model
|
||||
.as_deref()
|
||||
.map(|model| model.trim().is_empty())
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
async fn ollama_model_names() -> Vec<String> {
|
||||
let url = format!("http://127.0.0.1:{}/api/tags", OLLAMA_PORT);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
@@ -615,21 +678,10 @@ async fn ollama_has_model(model: &str) -> bool {
|
||||
.unwrap();
|
||||
if let Ok(resp) = client.get(&url).send().await {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(models) = body.get("models").and_then(|m| m.as_array()) {
|
||||
return models.iter().any(|m| {
|
||||
m.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.map(|n| {
|
||||
n == model
|
||||
|| n.strip_suffix(":latest") == Some(model)
|
||||
|| model.strip_suffix(":latest") == Some(n)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
});
|
||||
}
|
||||
return parse_ollama_model_names(&body);
|
||||
}
|
||||
}
|
||||
false
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn pull_model(model: &str) -> Result<(), String> {
|
||||
@@ -751,7 +803,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
.into();
|
||||
}
|
||||
|
||||
// For the Ollama path, the model pull may fall back to FALLBACK_MODEL; we
|
||||
// For the Ollama path, model resolution may fall back to FALLBACK_MODEL; we
|
||||
// record what is actually available here so the serve command below uses
|
||||
// it instead of the originally-planned tag. None on the custom path.
|
||||
let mut serve_model_override: Option<String> = None;
|
||||
@@ -798,8 +850,8 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
s.detail = "Inference engine ready.".into();
|
||||
}
|
||||
|
||||
// Phase 2: Pull the single default model (see default_local_model /
|
||||
// boot_plan). We deliberately do NOT pull any others.
|
||||
// Phase 2: Resolve one model to serve. Prefer an installed model on
|
||||
// first run so startup does not depend on a download succeeding.
|
||||
let model = plan
|
||||
.model_to_pull
|
||||
.clone()
|
||||
@@ -810,41 +862,63 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
s.detail = format!("Checking for {}...", model);
|
||||
}
|
||||
|
||||
if !ollama_has_model(&model).await {
|
||||
let installed_models = ollama_model_names().await;
|
||||
let resolved_model = if let Some(installed) = startup_installed_model(&model, &installed_models) {
|
||||
installed
|
||||
} else {
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.detail = format!("Downloading {}... (this may take a minute)", model);
|
||||
}
|
||||
if let Err(e) = pull_model(&model).await {
|
||||
// If the chosen model fails, try the tiny fallback
|
||||
eprintln!("Warning: failed to pull {}: {}", model, e);
|
||||
if !ollama_has_model(FALLBACK_MODEL).await {
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.detail = format!("Downloading {}...", FALLBACK_MODEL);
|
||||
}
|
||||
if let Err(e2) = pull_model(FALLBACK_MODEL).await {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(format!("Failed to download model: {}", e2));
|
||||
return;
|
||||
match pull_model(&model).await {
|
||||
Ok(()) => model.clone(),
|
||||
Err(e) => {
|
||||
eprintln!("Warning: failed to pull {}: {}", model, e);
|
||||
|
||||
// If a local model appeared while pulling, use it instead of
|
||||
// making startup depend on another network pull.
|
||||
if let Some(installed) = preferred_installed_model(&ollama_model_names().await) {
|
||||
installed
|
||||
} else if ollama_has_model(FALLBACK_MODEL).await {
|
||||
FALLBACK_MODEL.to_string()
|
||||
} else {
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.detail = format!("Downloading {}...", FALLBACK_MODEL);
|
||||
}
|
||||
if let Err(e2) = pull_model(FALLBACK_MODEL).await {
|
||||
if let Some(installed) =
|
||||
preferred_installed_model(&ollama_model_names().await)
|
||||
{
|
||||
installed
|
||||
} else {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(format!("Failed to download model: {}", e2));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
FALLBACK_MODEL.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if resolved_model != model {
|
||||
let mut s = status.lock().await;
|
||||
s.detail = format!("Using installed model {}.", resolved_model);
|
||||
}
|
||||
|
||||
// The pull may have fallen back to FALLBACK_MODEL; serve and persist
|
||||
// whatever is actually available now, not the originally-planned tag.
|
||||
let resolved_model = if ollama_has_model(&model).await {
|
||||
model
|
||||
} else {
|
||||
FALLBACK_MODEL.to_string()
|
||||
};
|
||||
serve_model_override = Some(resolved_model.clone());
|
||||
|
||||
// Persist the resolved model so Settings shows it and future boots reuse it.
|
||||
let mut persisted = cfg.clone();
|
||||
persisted.model = Some(resolved_model);
|
||||
let _ = write_inference_config(&persisted);
|
||||
// Persist only first-run/default resolution. If the user explicitly
|
||||
// configured a model, do not overwrite that choice with a temporary
|
||||
// fallback selected just to keep startup nonfatal.
|
||||
if should_persist_resolved_model(&cfg) {
|
||||
let mut persisted = cfg.clone();
|
||||
persisted.model = Some(resolved_model);
|
||||
let _ = write_inference_config(&persisted);
|
||||
}
|
||||
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
@@ -2647,8 +2721,10 @@ pub fn run() {
|
||||
mod tests {
|
||||
use super::{
|
||||
boot_plan, default_local_model, format_uv_sync_failure, format_uv_sync_spawn_error,
|
||||
normalize_host, parse_inference_config, upsert_engine_host, uv_sync_stderr_tail,
|
||||
InferenceConfig, SourceKind,
|
||||
matching_installed_model, model_names_match, normalize_host, parse_inference_config,
|
||||
parse_ollama_model_names, preferred_installed_model, should_persist_resolved_model,
|
||||
startup_installed_model,
|
||||
upsert_engine_host, uv_sync_stderr_tail, InferenceConfig, SourceKind,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
@@ -2730,6 +2806,97 @@ mod tests {
|
||||
assert_eq!(default_local_model(1.0), super::FALLBACK_MODEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ollama_model_names_reads_nonempty_names() {
|
||||
let body = serde_json::json!({
|
||||
"models": [
|
||||
{"name": "llama3.2:latest"},
|
||||
{"name": ""},
|
||||
{"name": "qwen3.5:4b"},
|
||||
{"model": "mistral:latest"}
|
||||
]
|
||||
});
|
||||
assert_eq!(
|
||||
parse_ollama_model_names(&body),
|
||||
vec![
|
||||
"llama3.2:latest".to_string(),
|
||||
"qwen3.5:4b".to_string(),
|
||||
"mistral:latest".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_names_match_treats_latest_as_optional() {
|
||||
assert!(model_names_match("llama3.2:latest", "llama3.2"));
|
||||
assert!(model_names_match("llama3.2", "llama3.2:latest"));
|
||||
assert!(model_names_match("qwen3.5:4b", "qwen3.5:4b"));
|
||||
assert!(!model_names_match("llama3.2:latest", "qwen3.5:4b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_model_helpers_pick_matching_or_first_model() {
|
||||
let models = vec!["llama3.2:latest".to_string(), "qwen3.5:4b".to_string()];
|
||||
assert_eq!(
|
||||
matching_installed_model(&models, "llama3.2"),
|
||||
Some("llama3.2:latest".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
preferred_installed_model(&models),
|
||||
Some("llama3.2:latest".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_installed_model_skips_embedding_names_when_chat_model_exists() {
|
||||
let models = vec![
|
||||
"nomic-embed-text:latest".to_string(),
|
||||
"llama3.2:latest".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
preferred_installed_model(&models),
|
||||
Some("llama3.2:latest".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_installed_model_uses_existing_model_for_defaults() {
|
||||
let models = vec!["llama3.2:latest".to_string()];
|
||||
assert_eq!(
|
||||
startup_installed_model("qwen3.5:4b", &models),
|
||||
Some("llama3.2:latest".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_installed_model_uses_existing_model_when_configured_model_missing() {
|
||||
let models = vec!["llama3.2:latest".to_string()];
|
||||
assert_eq!(
|
||||
startup_installed_model("qwen3.5:4b", &models),
|
||||
Some("llama3.2:latest".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_model_is_only_persisted_when_no_model_was_configured() {
|
||||
let default_cfg = InferenceConfig { kind: SourceKind::Ollama, ..Default::default() };
|
||||
assert!(should_persist_resolved_model(&default_cfg));
|
||||
|
||||
let empty_cfg = InferenceConfig {
|
||||
kind: SourceKind::Ollama,
|
||||
model: Some(" ".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(should_persist_resolved_model(&empty_cfg));
|
||||
|
||||
let user_cfg = InferenceConfig {
|
||||
kind: SourceKind::Ollama,
|
||||
model: Some("qwen3.5:9b".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!should_persist_resolved_model(&user_cfg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_defaults_to_ollama_when_file_missing_or_garbage() {
|
||||
assert!(matches!(parse_inference_config("").kind, SourceKind::Ollama));
|
||||
|
||||
@@ -19,6 +19,7 @@ from openjarvis.agents.prompt_loader import (
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
from openjarvis.core.types import Message, Role, ToolCall, ToolResult
|
||||
from openjarvis.engine._base import estimate_prompt_tokens
|
||||
from openjarvis.engine._stubs import InferenceEngine
|
||||
from openjarvis.tools._stubs import BaseTool, build_tool_descriptions
|
||||
|
||||
@@ -116,8 +117,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
|
||||
max_prompt_tokens: int = 3000,
|
||||
) -> list[Message]:
|
||||
"""Truncate messages if estimated token count exceeds limit."""
|
||||
total_chars = sum(len(m.content) for m in messages)
|
||||
estimated_tokens = total_chars // 4
|
||||
estimated_tokens = estimate_prompt_tokens(messages)
|
||||
if estimated_tokens <= max_prompt_tokens:
|
||||
return messages
|
||||
# Find the last user message and truncate its content
|
||||
@@ -125,7 +125,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
|
||||
if messages[i].role == Role.USER:
|
||||
excess_tokens = estimated_tokens - max_prompt_tokens
|
||||
excess_chars = excess_tokens * 4
|
||||
original = messages[i].content
|
||||
original = messages[i].content or ""
|
||||
if len(original) > excess_chars + 200:
|
||||
truncated = original[: len(original) - excess_chars]
|
||||
messages[i] = Message(
|
||||
@@ -258,7 +258,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
|
||||
# still emitted before re-raising.
|
||||
self._emit_turn_end(turns=1, error=True)
|
||||
raise
|
||||
content = self._strip_think_tags(result.get("content", ""))
|
||||
content = self._strip_think_tags(result.get("content") or "")
|
||||
usage = result.get("usage", {})
|
||||
self._emit_turn_end(turns=1)
|
||||
return AgentResult(
|
||||
@@ -315,7 +315,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
|
||||
for k in total_usage:
|
||||
total_usage[k] += usage.get(k, 0)
|
||||
|
||||
content = result.get("content", "")
|
||||
content = result.get("content") or ""
|
||||
# Strip think tags so they don't interfere with parsing
|
||||
content = self._strip_think_tags(content)
|
||||
last_content = content
|
||||
|
||||
@@ -63,7 +63,7 @@ class Message:
|
||||
"""A single chat message (OpenAI-compatible structure)."""
|
||||
|
||||
role: Role
|
||||
content: str = ""
|
||||
content: str | None = ""
|
||||
name: Optional[str] = None
|
||||
tool_calls: Optional[List[ToolCall]] = None
|
||||
tool_call_id: Optional[str] = None
|
||||
@@ -73,6 +73,11 @@ class Message:
|
||||
# empty for text-only messages (the common case).
|
||||
images: Optional[List[str]] = None
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""Return message content as text, treating ``None`` as empty."""
|
||||
return self.content or ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Conversation:
|
||||
|
||||
@@ -13,6 +13,22 @@ class EngineConnectionError(Exception):
|
||||
"""Raised when an engine is unreachable."""
|
||||
|
||||
|
||||
_REASONING_METADATA_KEYS = ("reasoning_content", "thinking")
|
||||
|
||||
|
||||
def _message_estimated_chars(message: Message) -> int:
|
||||
parts = [message.text]
|
||||
for key in _REASONING_METADATA_KEYS:
|
||||
value = message.metadata.get(key)
|
||||
if isinstance(value, str):
|
||||
parts.append(value)
|
||||
for tc in message.tool_calls or []:
|
||||
parts.extend((tc.id, tc.name, tc.arguments))
|
||||
if message.tool_call_id:
|
||||
parts.append(message.tool_call_id)
|
||||
return sum(len(part) for part in parts)
|
||||
|
||||
|
||||
def messages_to_dicts(messages: Sequence[Message]) -> List[Dict[str, Any]]:
|
||||
"""Convert ``Message`` objects to OpenAI-format dicts."""
|
||||
out: List[Dict[str, Any]] = []
|
||||
@@ -53,9 +69,11 @@ def estimate_prompt_tokens(messages: Sequence[Message]) -> int:
|
||||
provider would charge.
|
||||
|
||||
Uses ~4 characters per token (standard BPE average for English) plus
|
||||
a small per-message overhead for role markers and separators.
|
||||
a small per-message overhead for role markers and separators. Counts
|
||||
content, reasoning metadata, tool-call payloads, and tool result IDs
|
||||
because all are replayed into later prompt turns when present.
|
||||
"""
|
||||
total_chars = sum(len(m.content) for m in messages)
|
||||
total_chars = sum(_message_estimated_chars(m) for m in messages)
|
||||
# ~4 tokens overhead per message for role markers / separators
|
||||
overhead = len(messages) * 4
|
||||
return max(1, total_chars // 4 + overhead)
|
||||
|
||||
@@ -8,7 +8,7 @@ from openjarvis.agents._stubs import AgentContext
|
||||
from openjarvis.agents.native_openhands import NativeOpenHandsAgent
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
from openjarvis.core.types import Conversation, Message, Role, ToolResult
|
||||
from openjarvis.core.types import Conversation, Message, Role, ToolCall, ToolResult
|
||||
from openjarvis.tools._stubs import BaseTool, ToolSpec
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -118,6 +118,51 @@ class TestNativeOpenHandsRegistration:
|
||||
|
||||
|
||||
class TestNativeOpenHandsAgent:
|
||||
def test_truncate_handles_none_content_tool_call_turn(self):
|
||||
"""Tool-call assistant turns may carry content=None."""
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
agent = NativeOpenHandsAgent(engine, "test-model")
|
||||
messages = [
|
||||
Message(role=Role.USER, content="hi"),
|
||||
Message(
|
||||
role=Role.ASSISTANT,
|
||||
content=None, # type: ignore[arg-type]
|
||||
tool_calls=[ToolCall(id="call_1", name="calculator", arguments="{}")],
|
||||
),
|
||||
]
|
||||
|
||||
assert agent._truncate_if_needed(messages) == messages
|
||||
|
||||
def test_native_tool_call_with_none_content_does_not_crash(self):
|
||||
"""Native tool-call responses may omit assistant text content."""
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
engine.generate.side_effect = [
|
||||
_engine_response(
|
||||
None,
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call_1",
|
||||
"name": "calculator",
|
||||
"arguments": '{"expression": "2+2"}',
|
||||
}
|
||||
],
|
||||
),
|
||||
_engine_response("The result is 4."),
|
||||
]
|
||||
agent = NativeOpenHandsAgent(
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub()],
|
||||
)
|
||||
|
||||
result = agent.run("What is 2+2?")
|
||||
|
||||
assert result.content == "The result is 4."
|
||||
assert result.turns == 2
|
||||
assert [tr.content for tr in result.tool_results] == ["4"]
|
||||
|
||||
def test_simple_response(self):
|
||||
"""No code -> direct answer."""
|
||||
engine = MagicMock()
|
||||
|
||||
@@ -35,9 +35,15 @@ class TestMessage:
|
||||
msg = Message(role=Role.USER, content="hello")
|
||||
assert msg.role == Role.USER
|
||||
assert msg.content == "hello"
|
||||
assert msg.text == "hello"
|
||||
assert msg.tool_calls is None
|
||||
assert msg.metadata == {}
|
||||
|
||||
def test_none_content_text_helper(self) -> None:
|
||||
msg = Message(role=Role.ASSISTANT, content=None)
|
||||
assert msg.content is None
|
||||
assert msg.text == ""
|
||||
|
||||
def test_tool_calls(self) -> None:
|
||||
tc = ToolCall(id="1", name="calc", arguments='{"x": 1}')
|
||||
msg = Message(role=Role.ASSISTANT, content="", tool_calls=[tc])
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from openjarvis.core.types import Message, Role, ToolCall
|
||||
from openjarvis.engine._base import estimate_prompt_tokens
|
||||
|
||||
|
||||
def test_estimate_prompt_tokens_handles_none_content_tool_call_turn() -> None:
|
||||
messages = [
|
||||
Message(role=Role.USER, content="hi"),
|
||||
Message(
|
||||
role=Role.ASSISTANT,
|
||||
content=None,
|
||||
tool_calls=[ToolCall(id="call_1", name="lookup", arguments="{}")],
|
||||
),
|
||||
]
|
||||
|
||||
assert estimate_prompt_tokens(messages) == 12
|
||||
|
||||
|
||||
def test_estimate_prompt_tokens_counts_tool_call_arguments() -> None:
|
||||
base = [
|
||||
Message(role=Role.USER, content="hi"),
|
||||
Message(role=Role.ASSISTANT, content=None),
|
||||
]
|
||||
with_tool_call = [
|
||||
Message(role=Role.USER, content="hi"),
|
||||
Message(
|
||||
role=Role.ASSISTANT,
|
||||
content=None,
|
||||
tool_calls=[ToolCall(id="", name="", arguments="abcdefgh")],
|
||||
),
|
||||
]
|
||||
|
||||
assert estimate_prompt_tokens(with_tool_call) - estimate_prompt_tokens(base) == 2
|
||||
|
||||
|
||||
def test_estimate_prompt_tokens_counts_reasoning_metadata() -> None:
|
||||
base = [
|
||||
Message(role=Role.USER, content="hi"),
|
||||
Message(role=Role.ASSISTANT, content=None),
|
||||
]
|
||||
with_reasoning = [
|
||||
Message(role=Role.USER, content="hi"),
|
||||
Message(
|
||||
role=Role.ASSISTANT,
|
||||
content=None,
|
||||
metadata={"reasoning_content": "abcdefgh"},
|
||||
),
|
||||
]
|
||||
|
||||
assert estimate_prompt_tokens(with_reasoning) - estimate_prompt_tokens(base) == 2
|
||||
|
||||
|
||||
def test_estimate_prompt_tokens_counts_tool_result_ids() -> None:
|
||||
messages = [
|
||||
Message(role=Role.USER, content="hi"),
|
||||
Message(role=Role.TOOL, content="ok", tool_call_id="abcdefgh"),
|
||||
]
|
||||
|
||||
assert estimate_prompt_tokens(messages) == 11
|
||||
Reference in New Issue
Block a user