AI pill: several Ollama servers (pills.ai.servers, comma-separated, in order): the model list shows all their models, a question goes to the first server that has the chosen one; a local ollama is started only when no other server has it
This commit is contained in:
@@ -115,7 +115,7 @@ Whether the open card takes the song cover's colours instead (`appearance.colors
|
||||
|
||||
Two small one-icon pills sit left of the island and two right of it, on every monitor; they move outward while the island opens. A click morphs a pill into its card. Only one card is open across the shell: opening the island closes a card, and opening a card closes the island. Esc, the cross or a click elsewhere closes it.
|
||||
|
||||
- **AI** (sparkle): a chat with a local Ollama model, `gemma4:e4b` at `http://localhost:11434` by default; the model chip in its header lists the installed models and switches at once. Answers stream in through curl. When Ollama doesn't answer, the shell starts the OpenRC service with `doas rc-service ollama start` (`pills.ai.start_service`). Stop ends an answer and New chat starts over. Nothing leaves the machine.
|
||||
- **AI** (sparkle): a chat with an Ollama model on your own servers, `gemma4:e4b` at `http://localhost:11434` by default. `pills.ai.servers` takes several, comma-separated, in order: the model chip in the header lists every server's models (each once, with its server when there are several) and switches at once, and a question goes to the first server that has the chosen model. Answers stream in through curl. When Ollama doesn't answer, the shell starts the OpenRC service with `doas rc-service ollama start` (`pills.ai.start_service`). Stop ends an answer and New chat starts over. Nothing goes to a cloud service, only to the servers listed.
|
||||
- **System** (pulse): CPU, the integrated GPU's clock, the external GPU's load (with its video memory and the programs on it, formerly in the control centre), memory, swap, network and disk read/write as graphs over the last minute, plus the hottest sensors with icons, in cool/warm/hot colours (`pills.monitor.warm|hot`). Everything is read from `/proc` and `/sys`, so there is no process list. Processes opens `ghostty --class=amane.monitor -e btm` on the special workspace `monitor`; a second press shows or hides it.
|
||||
- **Home** (house): `~/.face` as the avatar (a person glyph until the file exists), a greeting, the date, a big clock, and Munich's weather now and for four days from open-meteo (no key), over a photo of the current weather (`~/.config/amane/weather/<kind>-<day|night>.jpg`, downloaded once from Wikimedia Commons, credits in `weather/CREDITS.md`; swap any file, `pills.home.photo_opacity` tones it down). A big month grid marks events (dots in their calendars' colours), birthdays from your iCloud contacts (a cake) and the public holidays of `pills.calendar.holidays` (BY by default); click a day for its list. Events can be created, edited and deleted there (a series: title, place and calendar only). For iCloud, set `pills.calendar.account` to your Apple ID and store an [app-specific password](https://account.apple.com) in the keyring: `secret-tool store --label='Magic Island calendar' service amane-calendar account <Apple ID>` (gnome-keyring or another Secret Service has to run). The password never goes into `config.toml`. Other CalDAV/CardDAV servers work through `pills.calendar.server` and `contacts_server`.
|
||||
- **Notifications** (bell, with the unread count): Amane is the notification daemon (`org.freedesktop.Notifications`), so dunst must not run; it is commented out in `~/.config/hypr/autocmds.lua` (the old file is beside it as `autocmds.lua.before-amane-notifications`). Popups appear at the top right of the focused monitor with the app's icon or image, summary, body (simple markup) and action buttons. They time out by urgency (critical ones stay) and wait while the pointer is over them. A click runs the default action. The card lists the history, newest first; each entry's cross deletes it, and Clear all deletes everything. Mute app keeps an app's notifications in the history without popups; Block app drops them (`notifications.apps.muted|blocked`, undone from chips at the top of the card). Do not disturb turns all popups off.
|
||||
|
||||
@@ -14,10 +14,11 @@ use crate::parse::{self, Chunk};
|
||||
use crate::{pods, theme};
|
||||
|
||||
/*
|
||||
* the AI card: a chat with a local model through ollama's http api (curl, streamed line
|
||||
* by line), never a cloud service. ollama isn't kept running: the first question starts
|
||||
* its openrc service when it doesn't answer (doas, the same fixed rc-service rule the
|
||||
* control centre uses)
|
||||
* the AI card: a chat with a model on your own ollama servers (pills.ai.servers) through
|
||||
* their http api (curl, streamed line by line), never a cloud service. a question goes to
|
||||
* the first server that has the chosen model. a local ollama isn't kept running: it's
|
||||
* started (doas, the same fixed rc-service rule the control centre uses) when it doesn't
|
||||
* answer and no other server has the model
|
||||
*/
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum Who {
|
||||
@@ -36,8 +37,8 @@ pub struct Ai {
|
||||
typed: String,
|
||||
// bumped by Stop and New chat, so an answer still streaming stops writing
|
||||
round: u32,
|
||||
// the models ollama has, while the model list is open (empty while it loads)
|
||||
models: Option<Vec<String>>,
|
||||
// (server, model) for every model the servers have, while the model list is open (empty while it loads)
|
||||
models: Option<Vec<(String, String)>>,
|
||||
}
|
||||
|
||||
impl Service for Ai {
|
||||
@@ -114,11 +115,15 @@ fn new_chat() {
|
||||
ai.back = 0.0;
|
||||
}
|
||||
|
||||
// on the answering thread: start ollama if needed, then stream the reply into the chat
|
||||
// on the answering thread: find the model's server (starting a local ollama if needed), then stream the reply into the chat
|
||||
fn answer(round: u32) -> Result<(), String> {
|
||||
let server = settings::server().trim_end_matches('/').to_string();
|
||||
let model = settings::model();
|
||||
|
||||
wake(&server, |words| note(round, words))?;
|
||||
let server = models(Some(&model), |words| note(round, words))?
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == model)
|
||||
.map(|(server, _)| server)
|
||||
.ok_or_else(|| format!("None of pills.ai.servers has {model}"))?;
|
||||
|
||||
let body = request();
|
||||
|
||||
@@ -170,18 +175,97 @@ fn answer(round: u32) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// starts the local ollama service when it doesn't answer, telling `say` what's going on
|
||||
// pills.ai.servers, in order, without trailing slashes
|
||||
fn servers() -> Vec<String> {
|
||||
settings::servers().split(',').map(|server| server.trim().trim_end_matches('/').to_string()).filter(|server| !server.is_empty()).collect()
|
||||
}
|
||||
|
||||
fn local(server: &str) -> bool {
|
||||
server.contains("://localhost") || server.contains("://127.0.0.1")
|
||||
}
|
||||
|
||||
// the models a server has; none when it doesn't answer
|
||||
fn models_on(server: &str) -> Option<Vec<String>> {
|
||||
let done = Command::new("curl").args(["-s", "--connect-timeout", "1", "-m", "5", &format!("{server}/api/tags")]).output().ok()?;
|
||||
|
||||
if !done.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let tags = parse::json(&String::from_utf8_lossy(&done.stdout));
|
||||
|
||||
let names = tags.first().and_then(|tags| tags.get("models")).map(|models| match models {
|
||||
parse::Value::List(models) => models.iter().filter_map(|model| model.get("name").and_then(parse::Value::text).map(str::to_string)).collect(),
|
||||
_ => Vec::new(),
|
||||
});
|
||||
|
||||
Some(names.unwrap_or_default())
|
||||
}
|
||||
|
||||
/*
|
||||
* (server, model) for the servers' models, in the servers' order, each model once with the
|
||||
* first server that has it; with `wanted`, only until a server has that model. a local
|
||||
* server that doesn't answer is started (pills.ai.start_service) when the others don't have
|
||||
* `wanted`, or for the whole list; `say` tells what's going on
|
||||
*/
|
||||
fn models(wanted: Option<&str>, say: impl Fn(&str)) -> Result<Vec<(String, String)>, String> {
|
||||
let servers = servers();
|
||||
|
||||
if servers.is_empty() {
|
||||
return Err(String::from("pills.ai.servers names no server"));
|
||||
}
|
||||
|
||||
let mut started = false;
|
||||
|
||||
loop {
|
||||
let mut found: Vec<(String, String)> = Vec::new();
|
||||
let mut silent_local = None;
|
||||
let mut answered = false;
|
||||
|
||||
for server in &servers {
|
||||
let Some(names) = models_on(server) else {
|
||||
if local(server) && silent_local.is_none() {
|
||||
silent_local = Some(server.clone());
|
||||
}
|
||||
|
||||
continue;
|
||||
};
|
||||
|
||||
answered = true;
|
||||
|
||||
for name in names {
|
||||
if !found.iter().any(|(_, known)| *known == name) {
|
||||
found.push((server.clone(), name));
|
||||
}
|
||||
}
|
||||
|
||||
if wanted.is_some_and(|wanted| found.iter().any(|(_, name)| name == wanted)) {
|
||||
return Ok(found);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(server) = silent_local.filter(|_| !started && settings::start_service()) {
|
||||
wake(&server, &say)?;
|
||||
|
||||
started = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
return match (answered, found.is_empty()) {
|
||||
(false, _) => Err(format!("No Ollama server answers ({})", servers.join(", "))),
|
||||
(true, true) => Err(String::from("The servers have no models (ollama pull <model>)")),
|
||||
(true, false) => Ok(found),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// starts the local ollama service, telling `say` what's going on, and waits until it answers
|
||||
fn wake(server: &str, say: impl Fn(&str)) -> Result<(), String> {
|
||||
if answers(server) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let local = server.contains("://localhost") || server.contains("://127.0.0.1");
|
||||
|
||||
if !(settings::start_service() && local) {
|
||||
return Err(format!("Ollama doesn't answer at {server}"));
|
||||
}
|
||||
|
||||
say("Starting ollama…");
|
||||
|
||||
let _ = Command::new("doas").args(["-n", "rc-service", "ollama", "start"]).output();
|
||||
@@ -213,24 +297,7 @@ fn pick() {
|
||||
drop(ai);
|
||||
|
||||
thread::spawn(|| {
|
||||
let server = settings::server().trim_end_matches('/').to_string();
|
||||
let say = |words: &str| Ai::write().note = (!words.is_empty()).then(|| words.to_string());
|
||||
|
||||
let names = wake(&server, say).and_then(|()| {
|
||||
let done = Command::new("curl").args(["-s", "-m", "5", &format!("{server}/api/tags")]).output().map_err(|error| format!("curl: {error}"))?;
|
||||
let tags = parse::json(&String::from_utf8_lossy(&done.stdout));
|
||||
|
||||
let names: Vec<String> = tags
|
||||
.first()
|
||||
.and_then(|tags| tags.get("models"))
|
||||
.map(|models| match models {
|
||||
parse::Value::List(models) => models.iter().filter_map(|model| model.get("name").and_then(parse::Value::text).map(str::to_string)).collect(),
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if names.is_empty() { Err(String::from("Ollama has no models (ollama pull <model>)")) } else { Ok(names) }
|
||||
});
|
||||
let names = models(None, |words| Ai::write().note = (!words.is_empty()).then(|| words.to_string()));
|
||||
|
||||
let mut ai = Ai::write();
|
||||
|
||||
@@ -366,12 +433,17 @@ pub fn card(width: f32, height: f32) -> Rectangle {
|
||||
|
||||
let chat: Box<dyn Widget> = if let Some(models) = &ai.models {
|
||||
let current = settings::model();
|
||||
let mut rows: Vec<Box<dyn Widget>> = vec![Box::new(Text::new(if models.is_empty() { "Asking ollama for its models…" } else { "Chat with" }).size(text::small()).color(theme.dim))];
|
||||
let several = servers().len() > 1;
|
||||
let mut rows: Vec<Box<dyn Widget>> = vec![Box::new(Text::new(if models.is_empty() { "Asking the servers for their models…" } else { "Chat with" }).size(text::small()).color(theme.dim))];
|
||||
|
||||
for name in models {
|
||||
for (server, name) in models {
|
||||
let chosen = *name == current;
|
||||
let picked = name.clone();
|
||||
|
||||
// with more than one server, where the model is, without the scheme
|
||||
let host = if several { server.split("://").last().unwrap_or(server) } else { "" };
|
||||
let host_w = host.chars().count() as f32 * text::small() * crate::config::appearance::font::letter_width();
|
||||
|
||||
rows.push(Box::new(
|
||||
Rectangle::new()
|
||||
.width(width)
|
||||
@@ -382,13 +454,20 @@ pub fn card(width: f32, height: f32) -> Rectangle {
|
||||
.align_child(Start, Center)
|
||||
.cursor(amane::Pointer)
|
||||
.on_click(move |_| choose(picked.clone()))
|
||||
.child(Text::new(name.as_str()).size(text::body()).color(theme.text).elide()),
|
||||
.child(
|
||||
Row::new(vec![
|
||||
Box::new(Rectangle::new().width(width - 24.0 - host_w - 12.0).height(34.0).align_child(Start, Center).child(Text::new(name.as_str()).size(text::body()).color(theme.text).elide())) as Box<dyn Widget>,
|
||||
Box::new(Text::new(host).size(text::small()).color(theme.dim)),
|
||||
])
|
||||
.gap(12.0)
|
||||
.align(Center),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
Box::new(Rectangle::new().width(width).height(chat_h).clip().child(Column::new(rows).gap(6.0)))
|
||||
} else if bubbles.is_empty() {
|
||||
Box::new(Rectangle::new().width(width).height(chat_h).align_child(Center, Center).child(Text::new(format!("Ask {} anything; it runs on this computer", settings::model())).size(text::small()).color(theme.dim)))
|
||||
Box::new(Rectangle::new().width(width).height(chat_h).align_child(Center, Center).child(Text::new(if servers().iter().all(|server| local(server)) { format!("Ask {} anything; it runs on this computer", settings::model()) } else { format!("Ask {} anything; it runs on your own servers", settings::model()) }).size(text::small()).color(theme.dim)))
|
||||
} else {
|
||||
// the column's bottom on the area's bottom, moved down by the scroll back; what's above is cut
|
||||
let back = ai.back.min((total - chat_h).max(0.0));
|
||||
|
||||
+1
-1
@@ -513,7 +513,7 @@ settings! {
|
||||
ai {
|
||||
width: f32 = 440.0, 280..=1200; "Width of the AI card"
|
||||
height: f32 = 520.0, 200..=1200; "Height of the AI card"
|
||||
server: String = String::from("http://localhost:11434"); "The Ollama server the AI card talks to"
|
||||
servers: String = String::from("http://localhost:11434"); "The Ollama servers the AI card talks to, comma-separated, in order: the model list shows all their models, a question goes to the first that has the chosen one"
|
||||
model: String = String::from("gemma4:e4b"); "The Ollama model to chat with, with its tag (ollama list shows them)"
|
||||
start_service: bool = true; "Start the local ollama service (doas rc-service ollama start) when it isn't answering"
|
||||
system: String = String::from("You are a helpful assistant inside a Linux desktop shell. Answer briefly and plainly."); "What the model is told before every chat"
|
||||
|
||||
Reference in New Issue
Block a user