feat(ai): /ai start|stop agent control + in-room typing indicator
Owner of the spawning client can summon/dismiss a local AI agent from inside the room (default ollama/qwen2.5:3b); the agent emits encrypted typing frames that drive a "thinking" spinner in the client. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
54b7637ec8
commit
05bdc2d802
|
|
@ -56,8 +56,15 @@ class AgentBridge(Client):
|
||||||
return None
|
return None
|
||||||
return rest # sole-agent form: `/ai <question>`
|
return rest # sole-agent form: `/ai <question>`
|
||||||
|
|
||||||
|
async def _send_typing(self, ws, on: bool) -> None:
|
||||||
|
"""Tell the room our reply is (not) being generated, so clients can show
|
||||||
|
a spinner. A control frame — never displayed as chat."""
|
||||||
|
frame = json.dumps({"_ai": "typing", "name": self.name, "on": on})
|
||||||
|
await ws.send(self.room_fernet.encrypt(frame.encode()).decode())
|
||||||
|
|
||||||
async def _answer(self, ws, question: str, asker: str) -> None:
|
async def _answer(self, ws, question: str, asker: str) -> None:
|
||||||
self.transcript.append(Msg("user", f"{asker}: {question}"))
|
self.transcript.append(Msg("user", f"{asker}: {question}"))
|
||||||
|
await self._send_typing(ws, True)
|
||||||
try:
|
try:
|
||||||
reply = await asyncio.to_thread(
|
reply = await asyncio.to_thread(
|
||||||
self.provider.complete,
|
self.provider.complete,
|
||||||
|
|
@ -66,6 +73,8 @@ class AgentBridge(Client):
|
||||||
)
|
)
|
||||||
except Exception as e: # noqa: BLE001 — surface any provider failure in-room
|
except Exception as e: # noqa: BLE001 — surface any provider failure in-room
|
||||||
reply = f"[ai error: {e}]"
|
reply = f"[ai error: {e}]"
|
||||||
|
finally:
|
||||||
|
await self._send_typing(ws, False)
|
||||||
reply = reply.strip() or "[empty reply]"
|
reply = reply.strip() or "[empty reply]"
|
||||||
self.transcript.append(Msg("assistant", reply))
|
self.transcript.append(Msg("assistant", reply))
|
||||||
await ws.send(self.room_fernet.encrypt(reply.encode()).decode())
|
await ws.send(self.room_fernet.encrypt(reply.encode()).decode())
|
||||||
|
|
|
||||||
152
hh/src/app.rs
152
hh/src/app.rs
|
|
@ -91,6 +91,11 @@ pub enum Net {
|
||||||
sudoers: Vec<String>,
|
sudoers: Vec<String>,
|
||||||
},
|
},
|
||||||
Ft(ft::Ft),
|
Ft(ft::Ft),
|
||||||
|
/// An AI agent is generating a reply (`on`) or has finished (`!on`).
|
||||||
|
AiTyping {
|
||||||
|
name: String,
|
||||||
|
on: bool,
|
||||||
|
},
|
||||||
Err(String),
|
Err(String),
|
||||||
Closed,
|
Closed,
|
||||||
}
|
}
|
||||||
|
|
@ -127,6 +132,10 @@ pub struct App {
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
/// The room password this client authenticated with (shown by `/pw`).
|
/// The room password this client authenticated with (shown by `/pw`).
|
||||||
pub password: String,
|
pub password: String,
|
||||||
|
/// AI agents currently generating a reply — drives the "thinking" spinner.
|
||||||
|
pub ai_typing: std::collections::HashSet<String>,
|
||||||
|
/// Monotonic tick counter used to animate the AI spinner.
|
||||||
|
pub spin: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
|
|
@ -151,6 +160,8 @@ impl App {
|
||||||
reconnecting: false,
|
reconnecting: false,
|
||||||
error: None,
|
error: None,
|
||||||
password: String::new(),
|
password: String::new(),
|
||||||
|
ai_typing: std::collections::HashSet::new(),
|
||||||
|
spin: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,7 +213,7 @@ impl App {
|
||||||
self.connected = true;
|
self.connected = true;
|
||||||
self.chat_scroll = 0;
|
self.chat_scroll = 0;
|
||||||
self.sys(format!("joined as {} ⛧", self.me));
|
self.sys(format!("joined as {} ⛧", self.me));
|
||||||
self.sys("/sbx launch · /drive (Esc releases) · /send <file> · /pw show password · PgUp/PgDn scroll chat · ctrl-q quit");
|
self.sys("/sbx launch · /drive (Esc releases) · /ai start · /ai <question> · /send <file> · /pw show password · PgUp/PgDn scroll chat · ctrl-q quit");
|
||||||
}
|
}
|
||||||
Net::Message(l) => self.push_line(l),
|
Net::Message(l) => self.push_line(l),
|
||||||
Net::Roster { users, capacity } => {
|
Net::Roster { users, capacity } => {
|
||||||
|
|
@ -213,9 +224,17 @@ impl App {
|
||||||
Net::Left(uid) => {
|
Net::Left(uid) => {
|
||||||
if let Some(p) = self.users.iter().position(|u| u.user_id == uid) {
|
if let Some(p) = self.users.iter().position(|u| u.user_id == uid) {
|
||||||
let name = self.users.remove(p).username;
|
let name = self.users.remove(p).username;
|
||||||
|
self.ai_typing.remove(&name); // a departed agent isn't thinking
|
||||||
self.sys(format!("{name} left"));
|
self.sys(format!("{name} left"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Net::AiTyping { name, on } => {
|
||||||
|
if on {
|
||||||
|
self.ai_typing.insert(name);
|
||||||
|
} else {
|
||||||
|
self.ai_typing.remove(&name);
|
||||||
|
}
|
||||||
|
}
|
||||||
Net::SbxStatus {
|
Net::SbxStatus {
|
||||||
backend,
|
backend,
|
||||||
ready,
|
ready,
|
||||||
|
|
@ -488,6 +507,8 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
||||||
let mut announced_dims: Option<(u16, u16)> = None;
|
let mut announced_dims: Option<(u16, u16)> = None;
|
||||||
let mut active_send: Option<ActiveSend> = None;
|
let mut active_send: Option<ActiveSend> = None;
|
||||||
let mut send_seq: u64 = 0;
|
let mut send_seq: u64 = 0;
|
||||||
|
// The local AI agent subprocess this client spawned via `/ai start`, if any.
|
||||||
|
let mut agent: Option<std::process::Child> = None;
|
||||||
let downloads = PathBuf::from("./downloads");
|
let downloads = PathBuf::from("./downloads");
|
||||||
|
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
|
|
@ -622,7 +643,8 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
||||||
app.chat_scroll = 0; // jump back to live on send
|
app.chat_scroll = 0; // jump back to live on send
|
||||||
handle_command(&line, &mut app, &mut theme, &mut active_send, &mut send_seq,
|
handle_command(&line, &mut app, &mut theme, &mut active_send, &mut send_seq,
|
||||||
&mut broker, &mut broker_meta, &mut launching, &mut announced_dims,
|
&mut broker, &mut broker_meta, &mut launching, &mut announced_dims,
|
||||||
&out_tx, &pty_tx, &broker_tx, &app_tx, &session, &term);
|
&out_tx, &pty_tx, &broker_tx, &app_tx, &session, &term,
|
||||||
|
&mut agent, ¶ms);
|
||||||
}
|
}
|
||||||
KeyCode::Backspace => { app.input.pop(); }
|
KeyCode::Backspace => { app.input.pop(); }
|
||||||
// Scroll: ↑/↓ scroll the sandbox terminal if one is up,
|
// Scroll: ↑/↓ scroll the sandbox terminal if one is up,
|
||||||
|
|
@ -793,7 +815,7 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
||||||
}
|
}
|
||||||
_ = sigterm.recv() => { break Ok(()); }
|
_ = sigterm.recv() => { break Ok(()); }
|
||||||
_ = sighup.recv() => { break Ok(()); }
|
_ = sighup.recv() => { break Ok(()); }
|
||||||
_ = tick.tick() => {}
|
_ = tick.tick() => { app.spin = app.spin.wrapping_add(1); }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -803,6 +825,10 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
||||||
sbx::teardown(be, &name);
|
sbx::teardown(be, &name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(mut child) = agent.take() {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
execute!(
|
execute!(
|
||||||
term.backend_mut(),
|
term.backend_mut(),
|
||||||
|
|
@ -841,6 +867,8 @@ fn handle_command(
|
||||||
app_tx: &UnboundedSender<Net>,
|
app_tx: &UnboundedSender<Net>,
|
||||||
session: &Session,
|
session: &Session,
|
||||||
term: &Terminal<CrosstermBackend<std::io::Stdout>>,
|
term: &Terminal<CrosstermBackend<std::io::Stdout>>,
|
||||||
|
agent: &mut Option<std::process::Child>,
|
||||||
|
params: &net::ConnParams,
|
||||||
) {
|
) {
|
||||||
let room = &session.room;
|
let room = &session.room;
|
||||||
if line == "/help" || line == "/?" {
|
if line == "/help" || line == "/?" {
|
||||||
|
|
@ -1051,6 +1079,48 @@ fn handle_command(
|
||||||
broadcast_acl(out_tx, room, app);
|
broadcast_acl(out_tx, room, app);
|
||||||
app.sys(format!("revoked drive from {target}"));
|
app.sys(format!("revoked drive from {target}"));
|
||||||
}
|
}
|
||||||
|
} else if line == "/ai stop" {
|
||||||
|
// Reap a child that already exited (e.g. failed auth) so the message is honest.
|
||||||
|
if agent
|
||||||
|
.as_mut()
|
||||||
|
.is_some_and(|c| matches!(c.try_wait(), Ok(Some(_))))
|
||||||
|
{
|
||||||
|
*agent = None;
|
||||||
|
}
|
||||||
|
if let Some(mut child) = agent.take() {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
app.sys("⛧ dismissed the AI agent");
|
||||||
|
} else {
|
||||||
|
app.sys("no AI agent was started from this client");
|
||||||
|
}
|
||||||
|
} else if let Some(rest) = line
|
||||||
|
.strip_prefix("/ai start")
|
||||||
|
.filter(|r| r.is_empty() || r.starts_with(' '))
|
||||||
|
{
|
||||||
|
// Drop a handle to an agent that has already exited so we can restart.
|
||||||
|
if agent
|
||||||
|
.as_mut()
|
||||||
|
.is_some_and(|c| matches!(c.try_wait(), Ok(Some(_))))
|
||||||
|
{
|
||||||
|
*agent = None;
|
||||||
|
}
|
||||||
|
if agent.is_some() {
|
||||||
|
app.sys("an AI agent is already running from this client — /ai stop first");
|
||||||
|
} else {
|
||||||
|
let m = rest.trim();
|
||||||
|
let model = if m.is_empty() { "qwen2.5:3b" } else { m };
|
||||||
|
let name = "oracle";
|
||||||
|
match spawn_agent(params, &app.password, name, model) {
|
||||||
|
Ok(child) => {
|
||||||
|
*agent = Some(child);
|
||||||
|
app.sys(format!(
|
||||||
|
"⛧ summoning {name} (ollama/{model})… it will announce when online"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => app.err(format!("/ai start failed: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if !line.is_empty() && app.connected {
|
} else if !line.is_empty() && app.connected {
|
||||||
let _ = out_tx.send(WsMsg::Text(room.encrypt(line.as_bytes())));
|
let _ = out_tx.send(WsMsg::Text(room.encrypt(line.as_bytes())));
|
||||||
}
|
}
|
||||||
|
|
@ -1112,3 +1182,79 @@ fn spawn_launch(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Locate the repo root (the dir containing `cmd_chat/agent/`) by walking up from
|
||||||
|
/// the executable's path and the current directory — so `/ai start` finds the
|
||||||
|
/// Python agent whether the client runs from the checkout or its `target/` dir.
|
||||||
|
fn find_repo_root() -> Option<std::path::PathBuf> {
|
||||||
|
let mut starts: Vec<std::path::PathBuf> = Vec::new();
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
starts.push(exe);
|
||||||
|
}
|
||||||
|
if let Ok(cwd) = std::env::current_dir() {
|
||||||
|
starts.push(cwd);
|
||||||
|
}
|
||||||
|
for start in starts {
|
||||||
|
let mut dir: Option<&std::path::Path> = Some(start.as_path());
|
||||||
|
while let Some(d) = dir {
|
||||||
|
if d.join("cmd_chat").join("agent").is_dir() {
|
||||||
|
return Some(d.to_path_buf());
|
||||||
|
}
|
||||||
|
dir = d.parent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the Python AI agent as a child process that joins this room as a normal
|
||||||
|
/// encrypted client (same SRP + room password). Returns the child handle so
|
||||||
|
/// `/ai stop` (and client quit) can kill it. The agent's stdout/stderr go to a
|
||||||
|
/// log file in the temp dir so its prints never corrupt the TUI.
|
||||||
|
fn spawn_agent(
|
||||||
|
params: &net::ConnParams,
|
||||||
|
password: &str,
|
||||||
|
name: &str,
|
||||||
|
model: &str,
|
||||||
|
) -> std::result::Result<std::process::Child, String> {
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
let root = find_repo_root().ok_or_else(|| {
|
||||||
|
"can't locate the repo (cmd_chat/) — run the client from the checkout".to_string()
|
||||||
|
})?;
|
||||||
|
// Prefer the project venv's interpreter; fall back to HH_AI_PYTHON or python3.
|
||||||
|
let venv_py = root.join(".venv/bin/python");
|
||||||
|
let program = if venv_py.is_file() {
|
||||||
|
venv_py
|
||||||
|
} else {
|
||||||
|
std::path::PathBuf::from(std::env::var("HH_AI_PYTHON").unwrap_or_else(|_| "python3".into()))
|
||||||
|
};
|
||||||
|
let log_path = std::env::temp_dir().join(format!("hh-agent-{name}.log"));
|
||||||
|
let log = std::fs::File::create(&log_path)
|
||||||
|
.map_err(|e| format!("agent log {}: {e}", log_path.display()))?;
|
||||||
|
let log_err = log.try_clone().map_err(|e| e.to_string())?;
|
||||||
|
let mut cmd = Command::new(&program);
|
||||||
|
cmd.current_dir(&root)
|
||||||
|
.arg("-m")
|
||||||
|
.arg("cmd_chat.agent")
|
||||||
|
.arg(¶ms.ip)
|
||||||
|
.arg(params.port.to_string())
|
||||||
|
.arg("--name")
|
||||||
|
.arg(name)
|
||||||
|
.arg("--provider")
|
||||||
|
.arg("ollama")
|
||||||
|
.arg("--model")
|
||||||
|
.arg(model)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::from(log))
|
||||||
|
.stderr(Stdio::from(log_err));
|
||||||
|
if !password.is_empty() {
|
||||||
|
cmd.arg("--password").arg(password);
|
||||||
|
}
|
||||||
|
if params.no_tls {
|
||||||
|
cmd.arg("--no-tls");
|
||||||
|
}
|
||||||
|
if params.insecure {
|
||||||
|
cmd.arg("--insecure");
|
||||||
|
}
|
||||||
|
cmd.spawn()
|
||||||
|
.map_err(|e| format!("could not start agent ({}): {e}", program.display()))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,13 @@ fn decode_msg(room: &fernet::Fernet, m: &Value, live: bool) -> Decoded {
|
||||||
Decoded::Skip
|
Decoded::Skip
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if t.starts_with("{\"_ai\":") {
|
||||||
|
return if live {
|
||||||
|
parse_ai(&t).map(Decoded::Sbx).unwrap_or(Decoded::Skip)
|
||||||
|
} else {
|
||||||
|
Decoded::Skip
|
||||||
|
};
|
||||||
|
}
|
||||||
(t, false)
|
(t, false)
|
||||||
}
|
}
|
||||||
Err(_) => ("[unreadable — wrong room password?]".to_string(), true),
|
Err(_) => ("[unreadable — wrong room password?]".to_string(), true),
|
||||||
|
|
@ -220,6 +227,19 @@ fn parse_sbx(text: &str, sender: &str) -> Option<Net> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse a decrypted `{"_ai":"typing",...}` frame — an AI agent signalling that
|
||||||
|
/// it is (or has finished) generating a reply, so the UI can show a spinner.
|
||||||
|
fn parse_ai(text: &str) -> Option<Net> {
|
||||||
|
let v: Value = serde_json::from_str(text).ok()?;
|
||||||
|
if v["_ai"].as_str()? != "typing" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Net::AiTyping {
|
||||||
|
name: v["name"].as_str().unwrap_or("ai").to_string(),
|
||||||
|
on: v["on"].as_bool().unwrap_or(false),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse a decrypted `{"_perm":"acl",...}` frame.
|
/// Parse a decrypted `{"_perm":"acl",...}` frame.
|
||||||
fn parse_perm(text: &str) -> Option<Net> {
|
fn parse_perm(text: &str) -> Option<Net> {
|
||||||
let v: Value = serde_json::from_str(text).ok()?;
|
let v: Value = serde_json::from_str(text).ok()?;
|
||||||
|
|
|
||||||
27
hh/src/ui.rs
27
hh/src/ui.rs
|
|
@ -128,6 +128,15 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) {
|
||||||
),
|
),
|
||||||
kv("/sbx stop", "tear down the sandbox (purges the VM)"),
|
kv("/sbx stop", "tear down the sandbox (purges the VM)"),
|
||||||
kv("/drive", "type into the shared shell (Esc releases)"),
|
kv("/drive", "type into the shared shell (Esc releases)"),
|
||||||
|
kv(
|
||||||
|
"/ai start [model]",
|
||||||
|
"spawn a local AI agent (default ollama/qwen2.5:3b)",
|
||||||
|
),
|
||||||
|
kv("/ai stop", "dismiss the agent you started"),
|
||||||
|
kv(
|
||||||
|
"/ai <question>",
|
||||||
|
"ask an AI agent in the room (/ai <name> <q> if many)",
|
||||||
|
),
|
||||||
kv(
|
kv(
|
||||||
"/grant <user>",
|
"/grant <user>",
|
||||||
"let a member drive the shell (owner)",
|
"let a member drive the shell (owner)",
|
||||||
|
|
@ -357,6 +366,17 @@ fn draw_roster(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Th
|
||||||
f.render_widget(roster, area);
|
f.render_widget(roster, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Animated "⠋ oracle is thinking…" title shown while AI agents generate a reply.
|
||||||
|
fn ai_thinking_title(app: &App) -> String {
|
||||||
|
const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
|
let glyph = FRAMES[(app.spin / 2) % FRAMES.len()];
|
||||||
|
let mut names: Vec<&str> = app.ai_typing.iter().map(String::as_str).collect();
|
||||||
|
names.sort_unstable();
|
||||||
|
let who = names.join(", ");
|
||||||
|
let verb = if names.len() > 1 { "are" } else { "is" };
|
||||||
|
format!(" {glyph} {who} {verb} thinking… ")
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_input(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {
|
fn draw_input(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {
|
||||||
let input = Paragraph::new(Line::from(vec![
|
let input = Paragraph::new(Line::from(vec![
|
||||||
Span::styled("> ", Style::default().fg(theme.accent)),
|
Span::styled("> ", Style::default().fg(theme.accent)),
|
||||||
|
|
@ -378,9 +398,14 @@ fn draw_input(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &The
|
||||||
None if app.driving => {
|
None if app.driving => {
|
||||||
format!(" {} DRIVING the shell — Esc to release ", theme.sigil)
|
format!(" {} DRIVING the shell — Esc to release ", theme.sigil)
|
||||||
}
|
}
|
||||||
|
None if !app.ai_typing.is_empty() => ai_thinking_title(app),
|
||||||
None => " message · enter send · /drive for shell · ctrl-q quit ".to_string(),
|
None => " message · enter send · /drive for shell · ctrl-q quit ".to_string(),
|
||||||
},
|
},
|
||||||
Style::default().fg(theme.title),
|
Style::default().fg(if app.ai_typing.is_empty() {
|
||||||
|
theme.title
|
||||||
|
} else {
|
||||||
|
theme.accent
|
||||||
|
}),
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
f.render_widget(input, area);
|
f.render_widget(input, area);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user