diff --git a/crates/agent/src/cli.rs b/crates/agent/src/cli.rs index d4ba00b..6052868 100644 --- a/crates/agent/src/cli.rs +++ b/crates/agent/src/cli.rs @@ -65,8 +65,8 @@ pub async fn run(command: ClientCommand, socket: PathBuf) -> anyhow::Result<()> } ClientCommand::Discover => { let discovery: Discovery = client::get(&socket, "/v1/discovery").await?; - if discovery.agents.is_empty() { - println!("No agents discovered"); + if discovery.agents.is_empty() && discovery.model_runtimes.is_empty() { + println!("No agents or models discovered"); } for agent in discovery.agents { let version = agent.version.as_deref().unwrap_or("unknown version"); @@ -77,6 +77,11 @@ pub async fn run(command: ClientCommand, socket: PathBuf) -> anyhow::Result<()> agent.executable.display() ); } + for runtime in discovery.model_runtimes { + for model in runtime.models { + println!("{}\tmodel\t{}", runtime.kind, model.name); + } + } } ClientCommand::Config => { let config: DaemonConfig = client::get(&socket, "/v1/config").await?; diff --git a/crates/agent/src/daemon.rs b/crates/agent/src/daemon.rs index d23f151..79ca845 100644 --- a/crates/agent/src/daemon.rs +++ b/crates/agent/src/daemon.rs @@ -324,6 +324,13 @@ where "discovered program" ); } + for runtime in &discovery.model_runtimes { + tracing::info!( + kind = %runtime.kind, + models = runtime.models.len(), + "discovered model runtime" + ); + } let (telemetry_sender, telemetry_receiver) = mpsc::channel(256); let telemetry = config.controller.as_ref().map(|_| telemetry_sender.clone()); let (logout_sender, logout_receiver) = mpsc::channel(1); diff --git a/crates/agent/src/discovery/mod.rs b/crates/agent/src/discovery/mod.rs index b6ed8f2..a7fe093 100644 --- a/crates/agent/src/discovery/mod.rs +++ b/crates/agent/src/discovery/mod.rs @@ -2,12 +2,14 @@ mod claude_code; mod claude_desktop; mod codex; mod metadata; +mod ollama; mod opencode; mod vscode; use agentdesktop_core::model::Discovery; pub async fn discover() -> Discovery { + let ollama = ollama::discover().await; let (codex, opencode, claude_code, claude_desktop, vscode) = ( codex::discover(), opencode::discover(), @@ -21,5 +23,6 @@ pub async fn discover() -> Discovery { .into_iter() .flatten() .collect(), + model_runtimes: ollama.into_iter().collect(), } } diff --git a/crates/agent/src/discovery/ollama.rs b/crates/agent/src/discovery/ollama.rs new file mode 100644 index 0000000..4a6c2e3 --- /dev/null +++ b/crates/agent/src/discovery/ollama.rs @@ -0,0 +1,96 @@ +use std::time::Duration; + +use agentdesktop_core::model::{LocalModel, ModelRuntime}; +use serde::Deserialize; + +const ENDPOINT: &str = "http://127.0.0.1:11434"; + +#[derive(Deserialize)] +struct TagsResponse { + models: Vec, +} + +#[derive(Deserialize)] +struct OllamaModel { + name: String, +} + +pub(super) async fn discover() -> Option { + discover_at(ENDPOINT).await +} + +async fn discover_at(endpoint: &str) -> Option { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(1)) + .build() + .ok()?; + let response = client + .get(format!("{endpoint}/api/tags")) + .send() + .await + .ok()? + .error_for_status() + .ok()? + .json::() + .await + .ok()?; + let mut models = response + .models + .into_iter() + .map(|model| LocalModel { name: model.name }) + .collect::>(); + models.sort_by(|left, right| left.name.cmp(&right.name)); + models.dedup_by(|left, right| left.name == right.name); + Some(ModelRuntime { + kind: "ollama".to_owned(), + models, + }) +} + +#[cfg(test)] +mod tests { + use axum::{Json, Router, routing::get}; + use serde_json::json; + + use super::discover_at; + + #[tokio::test] + async fn discovers_and_sorts_models() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve( + listener, + Router::new().route( + "/api/tags", + get(|| async { + Json(json!({ + "models": [ + { "name": "qwen3:8b" }, + { "name": "gemma3:4b" }, + { "name": "qwen3:8b" } + ] + })) + }), + ), + ) + .await + .unwrap(); + }); + + let runtime = discover_at(&format!("http://{address}")) + .await + .expect("discover Ollama"); + assert_eq!(runtime.kind, "ollama"); + assert_eq!( + runtime + .models + .iter() + .map(|model| model.name.as_str()) + .collect::>(), + ["gemma3:4b", "qwen3:8b"] + ); + + server.abort(); + } +} diff --git a/crates/agent/src/remote.rs b/crates/agent/src/remote.rs index 1ec8443..2b18cbc 100644 --- a/crates/agent/src/remote.rs +++ b/crates/agent/src/remote.rs @@ -336,11 +336,26 @@ async fn connect( .collect(), }) .collect(), + model_runtimes: discovered + .model_runtimes + .iter() + .map(|runtime| agentdesktop_proto::fleet::ModelRuntime { + kind: runtime.kind.clone(), + models: runtime + .models + .iter() + .map(|model| agentdesktop_proto::fleet::LocalModel { + name: model.name.clone(), + }) + .collect(), + }) + .collect(), }), ) .await?; info!( discoveries = discovered.agents.len(), + model_runtimes = discovered.model_runtimes.len(), "reported inventory to controller" ); diff --git a/crates/controller/migrations/0002_model_runtimes.sql b/crates/controller/migrations/0002_model_runtimes.sql new file mode 100644 index 0000000..e86b73b --- /dev/null +++ b/crates/controller/migrations/0002_model_runtimes.sql @@ -0,0 +1,8 @@ +CREATE TABLE model_runtimes ( + device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + models_json TEXT NOT NULL DEFAULT '[]', + PRIMARY KEY (device_id, kind) +); + +CREATE INDEX model_runtimes_device_id_idx ON model_runtimes(device_id); diff --git a/crates/controller/src/database.rs b/crates/controller/src/database.rs index 3e30709..a1deea6 100644 --- a/crates/controller/src/database.rs +++ b/crates/controller/src/database.rs @@ -2,7 +2,7 @@ use anyhow::Context; use sqlx::{AnyPool, any::AnyPoolOptions}; use std::{collections::BTreeMap, path::PathBuf}; -use agentdesktop_core::model::{McpServer, Skill}; +use agentdesktop_core::model::{LocalModel, McpServer, ModelRuntime, Skill}; use agentdesktop_proto::fleet::{ConfigStatus, Hello, Inventory, TelemetryEvent, telemetry_event}; use serde::Serialize; @@ -114,9 +114,28 @@ pub struct DeviceDetail { #[serde(flatten)] pub device: DeviceSummary, pub discoveries: Vec, + pub model_runtimes: Vec, pub recent_events: Vec, } +#[derive(sqlx::FromRow)] +struct ModelRuntimeRow { + kind: String, + models_json: String, +} + +impl TryFrom for ModelRuntime { + type Error = anyhow::Error; + + fn try_from(row: ModelRuntimeRow) -> Result { + Ok(Self { + kind: row.kind, + models: serde_json::from_str(&row.models_json) + .context("decode discovered local models")?, + }) + } +} + #[derive(Clone, Debug, Serialize)] pub struct TelemetryEventRecord { pub id: String, @@ -279,6 +298,18 @@ impl Database { .into_iter() .map(DeviceDiscovery::try_from) .collect::>()?; + let rows: Vec = sqlx::query_as( + "SELECT kind, models_json FROM model_runtimes + WHERE device_id = $1 ORDER BY kind ASC", + ) + .bind(device_id) + .fetch_all(&self.pool) + .await + .context("load discovered model runtimes")?; + let model_runtimes = rows + .into_iter() + .map(ModelRuntime::try_from) + .collect::>()?; let mut device: DeviceSummary = device.into(); device.installed_tools = discoveries .iter() @@ -288,6 +319,7 @@ impl Database { Ok(Some(DeviceDetail { device, discoveries, + model_runtimes, recent_events, })) } @@ -380,6 +412,30 @@ impl Database { .execute(&mut *transaction) .await?; } + sqlx::query("DELETE FROM model_runtimes WHERE device_id = $1") + .bind(device_id) + .execute(&mut *transaction) + .await?; + for runtime in &inventory.model_runtimes { + let models = runtime + .models + .iter() + .map(|model| LocalModel { + name: model.name.clone(), + }) + .collect::>(); + let models_json = + serde_json::to_string(&models).context("encode discovered local models")?; + sqlx::query( + "INSERT INTO model_runtimes (device_id, kind, models_json) + VALUES ($1, $2, $3)", + ) + .bind(device_id) + .bind(&runtime.kind) + .bind(models_json) + .execute(&mut *transaction) + .await?; + } transaction.commit().await?; Ok(()) } @@ -514,7 +570,8 @@ mod tests { use std::{collections::BTreeMap, path::PathBuf}; use agentdesktop_proto::fleet::{ - Discovery, Inventory, McpServer, Skill, TelemetryEvent, ToolUseEvent, telemetry_event, + Discovery, Inventory, LocalModel, McpServer, ModelRuntime, Skill, TelemetryEvent, + ToolUseEvent, telemetry_event, }; use super::Database; @@ -561,6 +618,12 @@ mod tests { front_matter_json: serde_json::to_vec(&front_matter).unwrap(), }], }], + model_runtimes: vec![ModelRuntime { + kind: "ollama".to_owned(), + models: vec![LocalModel { + name: "qwen3:8b".to_owned(), + }], + }], }, ) .await @@ -598,6 +661,8 @@ mod tests { device.discoveries[0].skills[0].front_matter["name"], "llm-research" ); + assert_eq!(device.model_runtimes[0].kind, "ollama"); + assert_eq!(device.model_runtimes[0].models[0].name, "qwen3:8b"); assert_eq!(device.recent_events.len(), 1); assert_eq!(device.recent_events[0].event_type, "tool.use"); assert_eq!(device.recent_events[0].payload["toolName"], "Bash"); diff --git a/crates/controller/src/service.rs b/crates/controller/src/service.rs index 528c008..c776778 100644 --- a/crates/controller/src/service.rs +++ b/crates/controller/src/service.rs @@ -459,10 +459,16 @@ async fn handle_agent_message( .iter() .map(|discovery| discovery.skills.len()) .sum::(); + let model_runtimes = inventory.model_runtimes.len(); + let models = inventory + .model_runtimes + .iter() + .map(|runtime| runtime.models.len()) + .sum::(); database.replace_inventory(device_id, &inventory).await?; info!( device_id, - discoveries, mcp_servers, skills, "stored device inventory" + discoveries, mcp_servers, skills, model_runtimes, models, "stored device inventory" ); } Some(agent_message::Message::ConfigStatus(status)) => { diff --git a/crates/core/src/model.rs b/crates/core/src/model.rs index 4cbfdc7..1e35a93 100644 --- a/crates/core/src/model.rs +++ b/crates/core/src/model.rs @@ -3,8 +3,26 @@ use std::{collections::BTreeMap, path::PathBuf}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] pub struct Discovery { pub agents: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub model_runtimes: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ModelRuntime { + /// Local runtime that owns the discovered models. + pub kind: String, + pub models: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LocalModel { + /// Runtime-scoped name used for inference requests. + pub name: String, } #[derive(Clone, Debug, Deserialize, Serialize)] diff --git a/crates/proto/proto/fleet.proto b/crates/proto/proto/fleet.proto index 67b67dd..b8985d8 100644 --- a/crates/proto/proto/fleet.proto +++ b/crates/proto/proto/fleet.proto @@ -113,6 +113,16 @@ message Heartbeat { message Inventory { repeated Discovery discoveries = 1; + repeated ModelRuntime model_runtimes = 2; +} + +message ModelRuntime { + string kind = 1; + repeated LocalModel models = 2; +} + +message LocalModel { + string name = 1; } message Discovery { diff --git a/frontend/controller/src/stories/fixtures.ts b/frontend/controller/src/stories/fixtures.ts index 9c48295..700ffce 100644 --- a/frontend/controller/src/stories/fixtures.ts +++ b/frontend/controller/src/stories/fixtures.ts @@ -224,6 +224,12 @@ export const deviceDetail: DeviceDetail = { skills: deviceSkills.slice(0, 5), }, ], + model_runtimes: [ + { + kind: "ollama", + models: [{ name: "gemma3:4b" }, { name: "qwen3:8b" }], + }, + ], recent_events: [ { id: "event-session", @@ -253,6 +259,7 @@ export const failedDeviceDetail: DeviceDetail = { export const emptyDeviceDetail: DeviceDetail = { ...deviceDetail, discoveries: [], + model_runtimes: [], recent_events: [], }; diff --git a/frontend/controller/src/types.ts b/frontend/controller/src/types.ts index 9c86af4..e8fbec1 100644 --- a/frontend/controller/src/types.ts +++ b/frontend/controller/src/types.ts @@ -34,6 +34,10 @@ export type DeviceDetail = Device & { frontMatter: Record; }>; }>; + model_runtimes: Array<{ + kind: string; + models: Array<{ name: string }>; + }>; recent_events: Array<{ id: string; timestamp_unix_ms: number; diff --git a/frontend/controller/src/views/DeviceView.stories.tsx b/frontend/controller/src/views/DeviceView.stories.tsx index aa3b14b..d31d129 100644 --- a/frontend/controller/src/views/DeviceView.stories.tsx +++ b/frontend/controller/src/views/DeviceView.stories.tsx @@ -35,7 +35,12 @@ const meta = { export default meta; type Story = StoryObj; -export const Healthy: Story = {}; +export const Healthy: Story = { + play: async ({ canvas }) => { + await expect(canvas.getByText("Ollama")).toBeVisible(); + await expect(canvas.getByText("qwen3:8b")).toBeVisible(); + }, +}; export const BrowsesDiscoveredCapabilities: Story = { play: async ({ canvas }) => { diff --git a/frontend/controller/src/views/DeviceView.tsx b/frontend/controller/src/views/DeviceView.tsx index bee88e1..eccbc1f 100644 --- a/frontend/controller/src/views/DeviceView.tsx +++ b/frontend/controller/src/views/DeviceView.tsx @@ -1,10 +1,11 @@ import { CardHeader, friendlyTool, + ModelRuntimeInventory, ToolIcon, ToolInventory, } from "@agentdesktop/ui"; -import { ArrowLeft, Box, CircleAlert, Code2, Trash2 } from "lucide-react"; +import { ArrowLeft, Box, CircleAlert, Code2, Cpu, Trash2 } from "lucide-react"; import { useEffect, useRef } from "react"; import { @@ -40,6 +41,10 @@ export function DeviceView({ onDeleteConfirm, onDeleteRequest, }: DeviceViewProps) { + const modelCount = device.model_runtimes.reduce( + (total, runtime) => total + runtime.models.length, + 0, + ); return (
@@ -160,6 +165,24 @@ export function DeviceView({
)} +
+ + {device.model_runtimes.length ? ( +
+ {device.model_runtimes.map((runtime) => ( + + ))} +
+ ) : ( +
+ + No local models have been reported by this device. +
+ )} +
; + }>; } diff --git a/frontend/desktop/src/views/ToolsView.stories.tsx b/frontend/desktop/src/views/ToolsView.stories.tsx index ccca700..e88f709 100644 --- a/frontend/desktop/src/views/ToolsView.stories.tsx +++ b/frontend/desktop/src/views/ToolsView.stories.tsx @@ -28,6 +28,8 @@ type Story = StoryObj; export const Populated: Story = { play: async ({ canvas }) => { + await expect(canvas.getByText("Ollama")).toBeVisible(); + await expect(canvas.getByText("qwen3:8b")).toBeVisible(); await userEvent.click(canvas.getByText("VS Code")); canvas.getByRole("tab", { name: "MCP servers 8" }).focus(); await userEvent.keyboard("{ArrowRight}"); diff --git a/frontend/desktop/src/views/ToolsView.tsx b/frontend/desktop/src/views/ToolsView.tsx index 0f4404e..430baa8 100644 --- a/frontend/desktop/src/views/ToolsView.tsx +++ b/frontend/desktop/src/views/ToolsView.tsx @@ -1,5 +1,9 @@ -import { CardHeader, ToolInventory } from "@agentdesktop/ui"; -import { AlertCircle, Box } from "lucide-react"; +import { + CardHeader, + ModelRuntimeInventory, + ToolInventory, +} from "@agentdesktop/ui"; +import { AlertCircle, Box, Cpu } from "lucide-react"; import type { Discovery } from "../types"; @@ -10,6 +14,11 @@ export interface ToolsViewProps { export function ToolsView({ discovery, unavailable }: ToolsViewProps) { const agents = discovery?.agents ?? []; + const modelRuntimes = discovery?.modelRuntimes ?? []; + const modelCount = modelRuntimes.reduce( + (total, runtime) => total + runtime.models.length, + 0, + ); const mcpCount = agents.reduce( (total, agent) => total + (agent.mcpServers?.length ?? 0), 0, @@ -29,7 +38,7 @@ export function ToolsView({ discovery, unavailable }: ToolsViewProps) {

- {agents.length ? ( + {agents.length || modelCount ? (
{agents.length} @@ -43,6 +52,10 @@ export function ToolsView({ discovery, unavailable }: ToolsViewProps) { {skillCount} Skills
+
+ {modelCount} + Local models +
) : null}
@@ -90,6 +103,29 @@ export function ToolsView({ discovery, unavailable }: ToolsViewProps) { )}
+ {!unavailable ? ( +
+ + {modelRuntimes.length ? ( +
+ {modelRuntimes.map((runtime) => ( + + ))} +
+ ) : ( +
+ +
+ No local models found + Start Ollama before restarting Agent Desktop. +
+
+ )} +
+ ) : null} ); } diff --git a/frontend/ui/src/assets/ollama.svg b/frontend/ui/src/assets/ollama.svg new file mode 100644 index 0000000..caa5b28 --- /dev/null +++ b/frontend/ui/src/assets/ollama.svg @@ -0,0 +1 @@ +Ollama diff --git a/frontend/ui/src/styles.css b/frontend/ui/src/styles.css index 8cbe5f3..d897d07 100644 --- a/frontend/ui/src/styles.css +++ b/frontend/ui/src/styles.css @@ -131,6 +131,62 @@ white-space: nowrap; } +.model-runtime-item { + padding: 14px 15px; + border-bottom: 1px solid var(--border-soft); +} + +.model-runtime-item:last-child { + border-bottom: 0; +} + +.model-runtime-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.model-runtime-heading strong { + color: #24292f; + font-size: 12px; + font-weight: 600; +} + +.model-runtime-heading > span:last-child, +.model-runtime-empty { + color: var(--muted); + font-size: 11px; +} + +.model-runtime-icon { + width: 18px; + height: 18px; + flex: 0 0 auto; + display: block; +} + +.model-name-list { + margin-top: 12px; + padding-left: 26px; + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.model-name-list code { + padding: 5px 8px; + color: #3f3563; + background: #f6f3fc; + border: 1px solid #e4dcf5; + border-radius: 4px; + font-size: 11px; +} + +.model-runtime-empty { + margin: 9px 0 0 26px; +} + .capability-stack { padding: 0 15px 15px 39px; } diff --git a/frontend/ui/src/tools.tsx b/frontend/ui/src/tools.tsx index ca5bc61..f05538e 100644 --- a/frontend/ui/src/tools.tsx +++ b/frontend/ui/src/tools.tsx @@ -2,6 +2,7 @@ import { ChevronLeft, ChevronRight, Code2, + Cpu, Server, Sparkles, } from "lucide-react"; @@ -10,6 +11,7 @@ import claudeCodeIcon from "./assets/claude-code.svg"; import claudeDesktopIcon from "./assets/claude-desktop.svg"; import codexIcon from "./assets/codex.svg"; import copilotIcon from "./assets/copilot.svg"; +import ollamaIcon from "./assets/ollama.svg"; import openCodeIcon from "./assets/opencode.svg"; export interface ToolMcpServer { @@ -34,6 +36,11 @@ export interface ToolDiscovery { skills?: ToolSkill[]; } +export interface ModelRuntimeDiscovery { + kind: string; + models: Array<{ name: string }>; +} + const toolIcons: Record = { codex: codexIcon, "claude-code": claudeCodeIcon, @@ -57,6 +64,55 @@ export function friendlyTool(kind: string) { return names[kind.toLowerCase()] ?? kind; } +export function friendlyModelRuntime(kind: string) { + const names: Record = { + ollama: "Ollama", + }; + return names[kind.toLowerCase()] ?? kind; +} + +function ModelRuntimeIcon({ kind }: { kind: string }) { + return kind.toLowerCase() === "ollama" ? ( + + ) : ( +