From 904214dcd48dbb60d748635aaa038b5511c7085e Mon Sep 17 00:00:00 2001 From: Name Date: Fri, 15 May 2026 14:36:06 +1000 Subject: [PATCH] Ai-launcher V1 --- ai-launcher/README.md | 172 ++++++++++++++++++ ai-launcher/services/ai-launcher.service | 12 ++ ai-launcher/services/claude-max-api.service | 11 ++ ai-launcher/src/main.rs | 187 ++++++++++++++++++++ 4 files changed, 382 insertions(+) create mode 100644 ai-launcher/README.md create mode 100644 ai-launcher/services/ai-launcher.service create mode 100644 ai-launcher/services/claude-max-api.service create mode 100644 ai-launcher/src/main.rs diff --git a/ai-launcher/README.md b/ai-launcher/README.md new file mode 100644 index 0000000..6994a30 --- /dev/null +++ b/ai-launcher/README.md @@ -0,0 +1,172 @@ +# AI Launcher — Setup Guide + +A floating GTK4 AI assistant for Linux. Press a keybind anywhere on the desktop to show/hide it. Type a question, get a Claude response. Escape to dismiss. + +--- + +## How it works + +``` +Keybind pressed + │ + ▼ +ai-launcher binary (GTK4/Rust daemon, starts hidden) + │ HTTP POST → localhost:3456/v1/chat/completions + ▼ +claude-max-api-proxy (Node.js, OpenAI-compatible API server) + │ calls Claude Code CLI internally + ▼ +claude CLI (uses your existing Anthropic login) + │ + ▼ +Anthropic API → response flows back up +``` + +The keybind re-runs the binary. GTK's single-instance D-Bus detects the running daemon and sends it an activate signal, toggling show/hide — no cold start. + +--- + +## Prerequisites + +### 1. Rust +```bash +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +source ~/.cargo/env +``` + +### 2. GTK4 + build dependencies +```bash +sudo apt install libgtk-4-dev build-essential pkg-config libssl-dev +``` + +### 3. Node.js + npm +```bash +sudo apt install nodejs npm +``` + +### 4. Claude Code CLI +```bash +npm install -g @anthropic-ai/claude-code +claude # follow the login/auth flow +``` + +### 5. claude-max-api-proxy +```bash +npm install -g claude-max-api-proxy +``` + +After install, find where npm placed the binary: +```bash +which claude-max-api +``` +Note this path — you'll need it for the service file. + +--- + +## Build & install the launcher + +```bash +cd ~/ai-launcher +cargo install --path . +``` + +This places the binary at `~/.cargo/bin/ai-launcher`. + +--- + +## Systemd user services + +Create the service directory: +```bash +mkdir -p ~/.config/systemd/user +``` + +### claude-max-api.service +Create `~/.config/systemd/user/claude-max-api.service`: + +```ini +[Unit] +Description=Claude Max API Proxy +After=network.target + +[Service] +ExecStart=/bin/bash -lc 'REPLACE_WITH_OUTPUT_OF_which_claude-max-api' +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=default.target +``` + +Replace `REPLACE_WITH_OUTPUT_OF_which_claude-max-api` with the actual path (e.g. `/usr/bin/claude-max-api` or `/home/USER/.npm-global/bin/claude-max-api`). + +The `/bin/bash -lc` wrapper is required so npm's PATH is available to systemd. + +### ai-launcher.service +Create `~/.config/systemd/user/ai-launcher.service`: + +```ini +[Unit] +Description=AI Launcher +After=graphical-session.target claude-max-api.service +PartOf=graphical-session.target + +[Service] +ExecStart=/home/YOUR_USERNAME/.cargo/bin/ai-launcher +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=graphical-session.target +``` + +Replace `YOUR_USERNAME` with your Linux username. + +### Enable and start +```bash +systemctl --user daemon-reload +systemctl --user enable --now claude-max-api +systemctl --user enable --now ai-launcher +``` + +### Verify both are running +```bash +systemctl --user status claude-max-api +systemctl --user status ai-launcher +``` + +--- + +## Keybind (GNOME) + +Settings → Keyboard → Custom Shortcuts → Add: +- **Name:** AI Launcher +- **Command:** `/home/YOUR_USERNAME/.cargo/bin/ai-launcher` +- **Shortcut:** your preferred key combo (e.g. Super+Space) + +--- + +## Troubleshooting + +| Problem | Fix | +|---|---| +| `status=203/EXEC` | Binary not found — run `cargo install --path .` first, check username in service file | +| `status=127` (claude-max-api) | Wrong binary path — run `which claude-max-api` and update `ExecStart` | +| `status=1` with "Claude CLI not found" | Missing `-lc` in ExecStart, or `claude` not authenticated — run `claude` to log in | +| `Could not find openssl` during cargo build | Run `sudo apt install pkg-config libssl-dev` | +| Window doesn't appear on keybind | Run `systemctl --user status ai-launcher` — service may have crashed | + +--- + +## Files in this guide + +``` +guides/ai-launcher/ +├── README.md — this file +├── Cargo.toml — Rust dependencies +├── src/ +│ └── main.rs — application source +└── services/ + ├── claude-max-api.service — systemd service template + └── ai-launcher.service — systemd service template +``` diff --git a/ai-launcher/services/ai-launcher.service b/ai-launcher/services/ai-launcher.service new file mode 100644 index 0000000..2f507b9 --- /dev/null +++ b/ai-launcher/services/ai-launcher.service @@ -0,0 +1,12 @@ +[Unit] +Description=AI Launcher +After=graphical-session.target claude-max-api.service +PartOf=graphical-session.target + +[Service] +ExecStart=/home/YOUR_USERNAME/.cargo/bin/ai-launcher +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=graphical-session.target diff --git a/ai-launcher/services/claude-max-api.service b/ai-launcher/services/claude-max-api.service new file mode 100644 index 0000000..aeaf7be --- /dev/null +++ b/ai-launcher/services/claude-max-api.service @@ -0,0 +1,11 @@ +[Unit] +Description=Claude Max API Proxy +After=network.target + +[Service] +ExecStart=/bin/bash -lc 'BINARY_PATH_HERE' +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=default.target diff --git a/ai-launcher/src/main.rs b/ai-launcher/src/main.rs new file mode 100644 index 0000000..8adaefd --- /dev/null +++ b/ai-launcher/src/main.rs @@ -0,0 +1,187 @@ +use gtk4::prelude::*; +use gtk4::{ + Application, ApplicationWindow, Box, CssProvider, Entry, + Orientation, ScrolledWindow, TextView, WrapMode, +}; +use std::sync::{Arc, Mutex}; + +const APP_ID: &str = "com.ailauncher.app"; + +const CSS: &str = " +window { + background: rgba(30, 30, 30, 0.95); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); +} +.launcher-input { + background: rgba(255, 255, 255, 0.08); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + padding: 10px 14px; + font-size: 16px; + caret-color: #7aa2f7; +} +.launcher-input:focus { + border-color: #7aa2f7; +} +.launcher-response { + background: transparent; + color: #c0caf5; + font-size: 14px; + padding: 8px; +} +"; + +#[derive(Clone, serde::Serialize)] +struct Message { + role: String, + content: String, +} + +#[derive(Clone, serde::Serialize)] +struct ChatRequest { + model: String, + messages: Vec, + stream: bool, +} + +fn build_ui(app: &Application) { + let window = ApplicationWindow::builder() + .application(app) + .title("AI Launcher") + .default_width(620) + .default_height(400) + .decorated(false) + .resizable(false) + .build(); + + let provider = CssProvider::new(); + provider.load_from_data(CSS); + gtk4::style_context_add_provider_for_display( + >k4::gdk::Display::default().unwrap(), + &provider, + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + + let vbox = Box::new(Orientation::Vertical, 10); + vbox.set_margin_top(20); + vbox.set_margin_bottom(20); + vbox.set_margin_start(20); + vbox.set_margin_end(20); + + let entry = Entry::new(); + entry.set_placeholder_text(Some("Ask me anything...")); + entry.add_css_class("launcher-input"); + + let response_view = TextView::new(); + response_view.set_editable(false); + response_view.set_wrap_mode(WrapMode::Word); + response_view.add_css_class("launcher-response"); + + let scrolled = ScrolledWindow::builder() + .child(&response_view) + .vexpand(true) + .build(); + + vbox.append(&entry); + vbox.append(&scrolled); + window.set_child(Some(&vbox)); + + let key_controller = gtk4::EventControllerKey::new(); + let win_clone = window.clone(); + key_controller.connect_key_pressed(move |_, key, _, _| { + if key == gtk4::gdk::Key::Escape { + win_clone.hide(); + } + glib::Propagation::Proceed + }); + window.add_controller(key_controller); + + let history: Arc>> = Arc::new(Mutex::new(vec![])); + let response_view_clone = response_view.clone(); + let history_clone = history.clone(); + + entry.connect_activate(move |e| { + let text = e.text().to_string(); + if text.is_empty() { return; } + e.set_text(""); + + let buffer = response_view_clone.buffer(); + buffer.set_text("Thinking..."); + + history_clone.lock().unwrap().push(Message { + role: "user".to_string(), + content: text, + }); + + let messages = history_clone.lock().unwrap().clone(); + let history_ref = history_clone.clone(); + let buffer_clone = buffer.clone(); + + let (sender, receiver) = async_channel::bounded::(1); + + tokio::spawn(async move { + let client = reqwest::Client::new(); + let request = ChatRequest { + model: "claude-sonnet-4".to_string(), + messages, + stream: false, + }; + + match client + .post("http://localhost:3456/v1/chat/completions") + .json(&request) + .send() + .await + { + Ok(resp) => { + match resp.json::().await { + Ok(json) => { + let content = json["choices"][0]["message"]["content"] + .as_str().unwrap_or("No response") + .to_string(); + sender.send(content).await.unwrap(); + } + Err(e) => { sender.send(format!("Parse error: {}", e)).await.unwrap(); } + } + } + Err(e) => { sender.send(format!("Request error: {}", e)).await.unwrap(); } + } + }); + + glib::MainContext::default().spawn_local(async move { + if let Ok(response) = receiver.recv().await { + buffer_clone.set_text(&response); + history_ref.lock().unwrap().push(Message { + role: "assistant".to_string(), + content: response, + }); + } + }); + }); + + window.hide(); +} + +#[tokio::main] +async fn main() { + let app = Application::builder() + .application_id(APP_ID) + .build(); + + app.connect_activate(|app| { + if let Some(win) = app.windows().first() { + if win.is_visible() { + win.hide(); + } else { + win.show(); + win.present(); + } + } else { + build_ui(app); + } + }); + + app.run(); +}