Ai-launcher V1
This commit is contained in:
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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<Message>,
|
||||||
|
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<Mutex<Vec<Message>>> = 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::<String>(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::<serde_json::Value>().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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user