- tray submenus: Agent (from letta agents list, radio-style) and Conversation (from local backend storage, CLI has no listing surface) - conversation entries show preview: summary or last-message text - conversation menu refreshes on aboutToShow (KDE DBus menu syncs a step behind eager mutation) - CLI rule honored: --agent only with --conversation default; named conversations imply the agent - plain-text fallback parsing for non-stream-json output - empty-reply debug: raw stream tail dump - prefs: ~/.local/inferon/prefs.json, saved on pick, loaded at startup, HOME-resolved paths (std.c.getenv), owned buffers (no alias double-free) - leak fixes: agent name fallback, quit-path drains for menu entries 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code <noreply@letta.com>
707 lines
23 KiB
Zig
707 lines
23 KiB
Zig
//! inferon — voice interface daemon for Letta Code agents.
|
|
//! Phase 2: pw-record subprocess capture wired into the state machine.
|
|
|
|
const std = @import("std");
|
|
const Io = std.Io;
|
|
const whisper = @import("whisper.zig");
|
|
const melo = @import("melo.zig");
|
|
const playback = @import("playback.zig");
|
|
const qt6 = @import("libqt6zig");
|
|
const QApplication = qt6.QApplication;
|
|
const QSystemTrayIcon = qt6.QSystemTrayIcon;
|
|
const QWidget = qt6.QWidget;
|
|
const QMenu = qt6.QMenu;
|
|
const QAction = qt6.QAction;
|
|
const QPixmap = qt6.QPixmap;
|
|
const QIcon = qt6.QIcon;
|
|
const QColor = qt6.QColor;
|
|
const QPainter = qt6.QPainter;
|
|
const QBrush = qt6.QBrush;
|
|
const QRectF = qt6.QRectF;
|
|
const threading = qt6.threading;
|
|
const letta = @import("letta.zig");
|
|
const overlay_mod = @import("overlay.zig");
|
|
|
|
// ------------------------------------------------------------- constants ---
|
|
|
|
const RECORDER_BIN = "pw-record";
|
|
const RECORDINGS_DIR = "/tmp/inferon";
|
|
/// whisper.cpp wants 16 kHz mono S16 LE.
|
|
const RECORD_RATE = "16000";
|
|
const RECORD_CHANNELS = "1";
|
|
const RECORD_FORMAT = "s16";
|
|
const LETTA_AGENT_ID = "agent-local-f69432ca-1bcd-42eb-b7f9-cf18d187282c";
|
|
// ---------------------------------------------------------------- states ---
|
|
|
|
const State = enum {
|
|
idle, // not listening, waiting for user toggle
|
|
recording, // mic capture in progress
|
|
transcribing, // audio -> whisper server
|
|
thinking, // agent processing
|
|
speaking, // TTS playback
|
|
error_backend, // whisper/LLM unreachable
|
|
|
|
fn label(s: State) []const u8 {
|
|
return switch (s) {
|
|
.idle => "idle",
|
|
.recording => "rec",
|
|
.transcribing => "stt",
|
|
.thinking => "think",
|
|
.speaking => "say",
|
|
.error_backend => "err",
|
|
};
|
|
}
|
|
|
|
/// Tray icon color per state (RGB).
|
|
fn color(s: State) [3]u8 {
|
|
return switch (s) {
|
|
.idle => .{ 90, 90, 110 }, // muted grey
|
|
.recording => .{ 220, 60, 60 }, // red
|
|
.transcribing => .{ 220, 160, 60 }, // orange
|
|
.thinking => .{ 90, 160, 230 }, // blue
|
|
.speaking => .{ 120, 210, 110 }, // green
|
|
.error_backend => .{ 180, 40, 180 }, // magenta
|
|
};
|
|
}
|
|
};
|
|
|
|
var state: State = .idle;
|
|
var tray_icon: QSystemTrayIcon = undefined;
|
|
var allocator: std.mem.Allocator = undefined;
|
|
|
|
var conversation_active: bool = false;
|
|
var agent_menu: QMenu = undefined;
|
|
var conv_menu: QMenu = undefined;
|
|
var current_agent: []const u8 = LETTA_AGENT_ID;
|
|
var current_conversation: []const u8 = "default";
|
|
|
|
// ---------------------------------------------------------------- prefs ---
|
|
|
|
var prefs_dir_buf: [512]u8 = undefined;
|
|
var prefs_path_buf: [576]u8 = undefined;
|
|
var PREFS_DIR: []const u8 = "";
|
|
var PREFS_PATH: []const u8 = "";
|
|
|
|
/// Resolve prefs paths from $HOME once at startup.
|
|
fn initPrefsPaths() void {
|
|
const home = std.c.getenv("HOME") orelse return;
|
|
const home_s = std.mem.span(home);
|
|
PREFS_DIR = std.fmt.bufPrint(&prefs_dir_buf, "{s}/.local/inferon", .{home_s}) catch return;
|
|
PREFS_PATH = std.fmt.bufPrint(&prefs_path_buf, "{s}/prefs.json", .{PREFS_DIR}) catch return;
|
|
}
|
|
|
|
var prefs_agent_buf: ?[]u8 = null;
|
|
var prefs_convo_buf: ?[]u8 = null;
|
|
|
|
fn setPrefsAgent(id: []const u8) void {
|
|
if (allocator.dupe(u8, id)) |d| {
|
|
if (prefs_agent_buf) |old| allocator.free(old);
|
|
prefs_agent_buf = d;
|
|
current_agent = d;
|
|
} else |_| {}
|
|
}
|
|
|
|
fn setPrefsConvo(id: []const u8) void {
|
|
if (allocator.dupe(u8, id)) |d| {
|
|
if (prefs_convo_buf) |old| allocator.free(old);
|
|
prefs_convo_buf = d;
|
|
current_conversation = d;
|
|
} else |_| {}
|
|
}
|
|
|
|
fn savePrefs() void {
|
|
const io = io_ctx orelse return;
|
|
Io.Dir.createDirAbsolute(io, PREFS_DIR, .default_dir) catch {};
|
|
const f = Io.Dir.createFileAbsolute(io, PREFS_PATH, .{ .truncate = true }) catch return;
|
|
var buf: [1024]u8 = undefined;
|
|
const body = std.fmt.bufPrint(&buf,
|
|
"{{\"agent\": \"{s}\", \"conversation\": \"{s}\"}}\n",
|
|
.{ current_agent, current_conversation },
|
|
) catch return;
|
|
var wbuf: [1024]u8 = undefined;
|
|
var w = f.writer(io, &wbuf);
|
|
w.interface.writeAll(body) catch {};
|
|
w.interface.flush() catch {};
|
|
f.close(io);
|
|
}
|
|
|
|
fn loadPrefs() void {
|
|
const io = io_ctx orelse return;
|
|
var f = Io.Dir.openFileAbsolute(io, PREFS_PATH, .{}) catch return;
|
|
defer f.close(io);
|
|
var buf: [1024]u8 = undefined;
|
|
var rbuf: [1024]u8 = undefined;
|
|
var r = f.reader(io, &rbuf);
|
|
const n = r.interface.readSliceShort(&buf) catch return;
|
|
if (letta.jsonStr(allocator, buf[0..n], 0, "agent")) |a| {
|
|
setPrefsAgent(a.val);
|
|
allocator.free(a.val);
|
|
}
|
|
if (letta.jsonStr(allocator, buf[0..n], 0, "conversation")) |c| {
|
|
setPrefsConvo(c.val);
|
|
allocator.free(c.val);
|
|
}
|
|
}
|
|
var last_user_text: ?[]u8 = null;
|
|
|
|
fn setLastUserText(text: []u8) void {
|
|
if (last_user_text) |old| allocator.free(old);
|
|
last_user_text = text;
|
|
}
|
|
// ------------------------------------------------------------------ icon ---
|
|
|
|
var current_icon: ?QIcon = null;
|
|
var current_pixmap: ?QPixmap = null;
|
|
|
|
var main_thread_id: std.Thread.Id = 0;
|
|
var pending_state: State = .idle;
|
|
|
|
// --------------------------------------------------------------- overlay ---
|
|
|
|
var overlay: ?overlay_mod.Overlay = null;
|
|
|
|
const OverlayKind = enum { begin, user, step, chunk };
|
|
const OverlayMsg = struct { kind: OverlayKind, text: ?[]u8 };
|
|
|
|
/// Worker-thread side: queue an overlay update onto the Qt main loop.
|
|
fn pushOverlay(kind: OverlayKind, text: ?[]u8) void {
|
|
const msg = allocator.create(OverlayMsg) catch {
|
|
if (text) |t| allocator.free(t);
|
|
return;
|
|
};
|
|
msg.* = .{ .kind = kind, .text = text };
|
|
threading.async(msg, onOverlayMsg);
|
|
}
|
|
|
|
fn onOverlayMsg(ctx: ?*anyopaque) callconv(.c) void {
|
|
const msg: *OverlayMsg = @ptrCast(@alignCast(ctx orelse return));
|
|
defer allocator.destroy(msg);
|
|
if (overlay) |*ov| switch (msg.kind) {
|
|
.begin => ov.begin(),
|
|
.user => ov.user(msg.text orelse ""),
|
|
.step => ov.step(msg.text orelse ""),
|
|
.chunk => ov.chunk(msg.text orelse ""),
|
|
};
|
|
if (msg.text) |t| allocator.free(t);
|
|
}
|
|
|
|
|
|
fn onLettaEvent(_: ?*anyopaque, ev: letta.Event) void {
|
|
switch (ev) {
|
|
.step => |s| {
|
|
const dup = allocator.dupe(u8, s) catch return;
|
|
pushOverlay(.step, dup);
|
|
},
|
|
.chunk => |c| {
|
|
const dup = allocator.dupe(u8, c) catch return;
|
|
pushOverlay(.chunk, dup);
|
|
// Stream voice: queue each sentence as it completes.
|
|
sentence_buf.appendSlice(allocator, c) catch {};
|
|
if (c.len > 0) {
|
|
const last = c[c.len - 1];
|
|
if (last == '.' or last == '!' or last == '?') flushSentence(false);
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Must run on the Qt main thread (touches QSystemTrayIcon / QPixmap).
|
|
fn setStateMainThread(next: State) void {
|
|
state = next;
|
|
|
|
// Circle icon drawn with QPainter — a bare pixmap.fill rendered blank
|
|
// on the KDE SNI host.
|
|
const c = next.color();
|
|
const color = QColor.new5(c[0], c[1], c[2]);
|
|
defer color.delete();
|
|
const pen_color = QColor.new5(20, 20, 26);
|
|
defer pen_color.delete();
|
|
const transparent = QColor.new8("transparent");
|
|
defer transparent.delete();
|
|
|
|
const pixmap = QPixmap.new2(128, 128);
|
|
pixmap.fill1(transparent);
|
|
|
|
const brush = QBrush.new3(color);
|
|
defer brush.delete();
|
|
|
|
const painter = QPainter.new();
|
|
_ = painter.begin(pixmap);
|
|
painter.setRenderHint(1); // Antialiasing
|
|
painter.setPen(pen_color);
|
|
painter.setBrush(brush);
|
|
const rect = QRectF.new6(16, 16, 96, 96);
|
|
defer rect.delete();
|
|
painter.drawEllipse(rect);
|
|
_ = painter.end();
|
|
painter.delete();
|
|
|
|
const icon = QIcon.new2(pixmap);
|
|
|
|
if (current_icon) |old| old.delete();
|
|
if (current_pixmap) |old| old.delete();
|
|
current_icon = icon;
|
|
current_pixmap = pixmap;
|
|
|
|
tray_icon.setIcon(icon);
|
|
|
|
const tip = std.fmt.allocPrint(allocator, "inferon — {s}", .{next.label()}) catch return;
|
|
defer allocator.free(tip);
|
|
tray_icon.setToolTip(tip);
|
|
}
|
|
|
|
fn onStateFromWorker(_: ?*anyopaque) callconv(.c) void {
|
|
setStateMainThread(pending_state);
|
|
}
|
|
|
|
/// Thread-safe state transition: runs directly on the main thread, or
|
|
/// marshals through Qt's event loop when called from the pipeline worker.
|
|
fn setState(next: State) void {
|
|
if (std.Thread.getCurrentId() == main_thread_id) {
|
|
setStateMainThread(next);
|
|
} else {
|
|
pending_state = next;
|
|
threading.async(null, onStateFromWorker);
|
|
}
|
|
}
|
|
|
|
/// Buffer for sentence-split streaming into the playback pipeline.
|
|
var sentence_buf: std.ArrayList(u8) = .empty;
|
|
|
|
fn flushSentence(final_flush: bool) void {
|
|
const a = allocator;
|
|
if (sentence_buf.items.len == 0) {
|
|
if (final_flush) playback.endReply();
|
|
return;
|
|
}
|
|
// Sentences end on . ! ? — hold back a bare trailing terminator-less tail
|
|
// unless this is the end of the reply.
|
|
if (!final_flush) {
|
|
const last = sentence_buf.items[sentence_buf.items.len - 1];
|
|
if (last != '.' and last != '!' and last != '?') return;
|
|
}
|
|
const s = sentence_buf.toOwnedSlice(a) catch return;
|
|
playback.push(s);
|
|
if (final_flush) playback.endReply();
|
|
}
|
|
|
|
// --------------------------------------------------------------- capture ---
|
|
|
|
var recorder: ?std.process.Child = null;
|
|
var recording_path: ?[]const u8 = null;
|
|
var io_ctx: ?std.Io = null;
|
|
|
|
fn startRecording() void {
|
|
const io = io_ctx orelse return;
|
|
Io.Dir.createDirAbsolute(io, RECORDINGS_DIR, .default_dir) catch |e| switch (e) {
|
|
error.PathAlreadyExists => {},
|
|
else => {
|
|
std.debug.print("inferon: cannot create {s}: {s}\n", .{ RECORDINGS_DIR, @errorName(e) });
|
|
setState(.error_backend);
|
|
return;
|
|
},
|
|
};
|
|
|
|
const path = std.fmt.allocPrint(
|
|
allocator,
|
|
"{s}/utterance-{d}.wav",
|
|
.{ RECORDINGS_DIR, Io.Clock.now(.real, io).nanoseconds },
|
|
) catch {
|
|
setState(.error_backend);
|
|
return;
|
|
};
|
|
recording_path = path;
|
|
|
|
// pw-record finalizes the WAV header on SIGTERM — kill() sends it.
|
|
// stderr goes to a log so failures aren't silent.
|
|
const stderr_log = std.fmt.allocPrint(allocator, "{s}/pw-record.log", .{RECORDINGS_DIR}) catch null;
|
|
defer if (stderr_log) |sl| allocator.free(sl);
|
|
const stderr_file: ?Io.File = if (stderr_log) |lp|
|
|
Io.Dir.cwd().createFile(io, lp[1..], .{ .truncate = true }) catch null
|
|
else
|
|
null;
|
|
|
|
recorder = std.process.spawn(io, .{
|
|
.argv = &.{
|
|
RECORDER_BIN,
|
|
"--rate",
|
|
RECORD_RATE,
|
|
"--channels",
|
|
RECORD_CHANNELS,
|
|
"--format",
|
|
RECORD_FORMAT,
|
|
path,
|
|
},
|
|
.stdin = .ignore,
|
|
.stdout = .ignore,
|
|
.stderr = if (stderr_file) |f| .{ .file = f } else .ignore,
|
|
}) catch |e| {
|
|
std.debug.print("inferon: failed to spawn {s}: {s}\n", .{ RECORDER_BIN, @errorName(e) });
|
|
allocator.free(path);
|
|
recording_path = null;
|
|
setState(.error_backend);
|
|
return;
|
|
};
|
|
setState(.recording);
|
|
}
|
|
|
|
/// Stop pw-record. kill() delivers SIGTERM (pw-record finalizes the WAV
|
|
/// header) and reaps the child itself — no separate wait() needed.
|
|
fn stopRecording() void {
|
|
const io = io_ctx orelse return;
|
|
const child = &(recorder orelse return);
|
|
|
|
child.kill(io);
|
|
recorder = null;
|
|
}
|
|
|
|
fn onQuit(_: QAction) callconv(.c) void {
|
|
if (recorder != null) stopRecording();
|
|
whisper.stop(); // whisper-server dies with us
|
|
melo.stop();
|
|
QApplication.quit();
|
|
}
|
|
|
|
// -------------------------------------------------------------- handlers ---
|
|
|
|
/// Delete a temp file if it exists. Best-effort — /tmp cleanup, not critical.
|
|
fn cleanupFile(path: []const u8) void {
|
|
const io = io_ctx orelse return;
|
|
Io.Dir.deleteFileAbsolute(io, path) catch {};
|
|
}
|
|
|
|
/// Full pipeline after recording stops: transcribe -> infer -> speak.
|
|
/// All blocking on the UI thread for now (same as before); states advance
|
|
/// automatically, no manual clicking between stages.
|
|
fn processUtterance() void {
|
|
const io = io_ctx orelse return;
|
|
const wav_in = recording_path orelse return;
|
|
defer {
|
|
cleanupFile(wav_in);
|
|
allocator.free(wav_in);
|
|
recording_path = null;
|
|
}
|
|
|
|
setState(.transcribing);
|
|
const text: []u8 = whisper.transcribe(allocator, io, wav_in) catch |e| {
|
|
std.debug.print("inferon: transcription failed: {s}\n", .{@errorName(e)});
|
|
setState(.error_backend);
|
|
return;
|
|
};
|
|
std.debug.print("inferon: transcript: \"{s}\"\n", .{text});
|
|
setLastUserText(text);
|
|
|
|
// --- agent ---
|
|
setState(.thinking);
|
|
|
|
pushOverlay(.begin, null);
|
|
if (allocator.dupe(u8, text) catch null) |user_dup| pushOverlay(.user, user_dup);
|
|
const response = letta.streamInferConv(io, allocator, current_agent, current_conversation, text, null, onLettaEvent) catch |err| {
|
|
std.log.err("letta failed: {s}", .{@errorName(err)});
|
|
setState(.idle);
|
|
return;
|
|
};
|
|
defer allocator.free(response);
|
|
std.debug.print("[INFERON]\n{s}\n\n", .{response});
|
|
|
|
// --- tts + playback: sentences already streaming; flush the tail ---
|
|
setState(.speaking);
|
|
flushSentence(true);
|
|
playback.waitDrained();
|
|
|
|
// After speaking: back to idle-but-in-conversation. User clicks when
|
|
// they want to talk again.
|
|
setState(.idle);
|
|
}
|
|
|
|
fn onTrayActivated(_: QSystemTrayIcon, reason: i32) callconv(.c) void {
|
|
// Trigger = 3 (click). Only toggle on left click; context menu handles the rest.
|
|
if (reason != 3) return;
|
|
|
|
switch (state) {
|
|
.idle => {
|
|
conversation_active = true;
|
|
startRecording();
|
|
},
|
|
.recording => {
|
|
stopRecording();
|
|
setState(.transcribing); // claim the pipeline slot before spawning
|
|
// Pipeline runs on a worker thread; the Qt event loop stays
|
|
// responsive (icon repaints, menu works) while whisper/letta/melo
|
|
// block. setState marshals back to the main thread.
|
|
const worker = std.Thread.spawn(.{}, processUtterance, .{}) catch |e| {
|
|
std.debug.print("inferon: failed to spawn worker: {s}\n", .{@errorName(e)});
|
|
setState(.error_backend);
|
|
return;
|
|
};
|
|
worker.detach();
|
|
},
|
|
// Clicks during the pipeline are no-ops now — states advance on their own.
|
|
.transcribing, .thinking, .speaking => {},
|
|
.error_backend => setState(.idle),
|
|
}
|
|
}
|
|
|
|
fn onToggleWindow(a: QAction) callconv(.c) void {
|
|
if (a.isChecked()) {
|
|
if (overlay) |*ov| ov.window.show();
|
|
} else {
|
|
if (overlay) |*ov| ov.hide();
|
|
}
|
|
}
|
|
|
|
const AgentEntry = struct { action: QAction, id: []u8 };
|
|
var agent_entries: std.ArrayList(AgentEntry) = .empty;
|
|
|
|
fn onPickAgent(a: QAction) callconv(.c) void {
|
|
for (agent_entries.items) |e| {
|
|
e.action.setChecked(e.action.ptr == a.ptr);
|
|
if (e.action.ptr == a.ptr) setPrefsAgent(e.id);
|
|
}
|
|
setPrefsConvo("default");
|
|
populateConvMenu();
|
|
savePrefs();
|
|
std.debug.print("inferon: agent -> {s}\n", .{current_agent});
|
|
}
|
|
|
|
fn onConvMenuShow(_: QMenu) callconv(.c) void {
|
|
populateConvMenu();
|
|
}
|
|
|
|
fn populateAgentMenu() void {
|
|
for (agent_entries.items) |e| allocator.free(e.id);
|
|
agent_entries.clearRetainingCapacity();
|
|
agent_menu.clear();
|
|
|
|
const agents = letta.listAgents(io_ctx.?, allocator) catch return;
|
|
defer {
|
|
for (agents) |ag| {
|
|
allocator.free(ag.id);
|
|
allocator.free(ag.name);
|
|
}
|
|
allocator.free(agents);
|
|
}
|
|
for (agents) |ag| {
|
|
const label = std.fmt.allocPrint(allocator, "{s}", .{ag.name}) catch continue;
|
|
defer allocator.free(label);
|
|
const a = QAction.new5(label, QWidget{ .ptr = null });
|
|
a.setCheckable(true);
|
|
a.setChecked(std.mem.eql(u8, ag.id, current_agent));
|
|
a.onTriggered(onPickAgent);
|
|
agent_menu.addAction(a);
|
|
const id_dup = allocator.dupe(u8, ag.id) catch continue;
|
|
agent_entries.append(allocator, .{ .action = a, .id = id_dup }) catch allocator.free(id_dup);
|
|
}
|
|
}
|
|
|
|
const ConvEntry = struct { action: QAction, id: []u8, preview: []u8 };
|
|
var conv_entries: std.ArrayList(ConvEntry) = .empty;
|
|
|
|
fn onPickConv(a: QAction) callconv(.c) void {
|
|
for (conv_entries.items) |e| {
|
|
e.action.setChecked(e.action.ptr == a.ptr);
|
|
if (e.action.ptr == a.ptr) setPrefsConvo(e.id);
|
|
}
|
|
savePrefs();
|
|
std.debug.print("inferon: conversation -> {s}\n", .{current_conversation});
|
|
}
|
|
|
|
fn populateConvMenu() void {
|
|
for (conv_entries.items) |e| {
|
|
allocator.free(e.id);
|
|
allocator.free(e.preview);
|
|
}
|
|
conv_entries.clearRetainingCapacity();
|
|
conv_menu.clear();
|
|
|
|
const convs = letta.listConversations(io_ctx.?, allocator, current_agent) catch return;
|
|
defer {
|
|
for (convs) |c| {
|
|
allocator.free(c.id);
|
|
allocator.free(c.preview);
|
|
}
|
|
allocator.free(convs);
|
|
}
|
|
for (convs) |c| {
|
|
const trunc = if (c.preview.len > 60) c.preview[0..60] else c.preview;
|
|
const label = if (trunc.len > 0)
|
|
std.fmt.allocPrint(allocator, "{s} — {s}", .{ c.id, trunc }) catch continue
|
|
else
|
|
allocator.dupe(u8, c.id) catch continue;
|
|
defer allocator.free(label);
|
|
const a = QAction.new5(label, QWidget{ .ptr = null });
|
|
a.setCheckable(true);
|
|
a.setChecked(std.mem.eql(u8, c.id, current_conversation));
|
|
a.onTriggered(onPickConv);
|
|
conv_menu.addAction(a);
|
|
const id_dup = allocator.dupe(u8, c.id) catch continue;
|
|
const prev_dup = allocator.dupe(u8, c.preview) catch {
|
|
allocator.free(id_dup);
|
|
continue;
|
|
};
|
|
conv_entries.append(allocator, .{ .id = id_dup, .action = a, .preview = prev_dup }) catch {
|
|
allocator.free(id_dup);
|
|
allocator.free(prev_dup);
|
|
};
|
|
}
|
|
}
|
|
|
|
fn onEndConversation(_: QAction) callconv(.c) void {
|
|
if (recorder != null) stopRecording();
|
|
conversation_active = false;
|
|
if (last_user_text) |t| allocator.free(t);
|
|
last_user_text = null;
|
|
if (overlay) |*ov| {
|
|
ov.reset();
|
|
ov.hide();
|
|
}
|
|
setState(.idle);
|
|
}
|
|
|
|
// --------------------------------------------------------------- signals ---
|
|
|
|
/// Async-signal-safe teardown: raw kill() of children only, no Io calls.
|
|
/// Children are orphan-reaped by init when we exit right after.
|
|
const SigType = @TypeOf(std.posix.SIG.INT);
|
|
|
|
fn handleFatalSignal(sig: SigType) callconv(.c) void {
|
|
if (whisper.pid()) |p| {
|
|
std.posix.kill(p, std.posix.SIG.TERM) catch {};
|
|
}
|
|
if (melo.pid()) |p| {
|
|
std.posix.kill(p, std.posix.SIG.TERM) catch {};
|
|
}
|
|
if (recorder) |c| {
|
|
if (c.id) |p| std.posix.kill(p, std.posix.SIG.TERM) catch {};
|
|
}
|
|
// Restore default handler and re-raise so the exit status is truthful.
|
|
const act = std.posix.Sigaction{
|
|
.handler = .{ .handler = std.posix.SIG.DFL },
|
|
.mask = std.posix.sigemptyset(),
|
|
.flags = 0,
|
|
};
|
|
std.posix.sigaction(sig, &act, null);
|
|
std.posix.raise(sig) catch {};
|
|
}
|
|
|
|
fn installSignalHandlers() void {
|
|
const act = std.posix.Sigaction{
|
|
.handler = .{ .handler = handleFatalSignal },
|
|
.mask = std.posix.sigemptyset(),
|
|
.flags = 0, // no SA_RESTART: let blocking syscalls die too
|
|
};
|
|
std.posix.sigaction(std.posix.SIG.INT, &act, null);
|
|
std.posix.sigaction(std.posix.SIG.TERM, &act, null);
|
|
}
|
|
|
|
// ------------------------------------------------------------------ main ---
|
|
|
|
pub fn main(init: std.process.Init) !void {
|
|
const argv = try qt6.init(init.gpa, init.minimal.args);
|
|
defer qt6.deinit(init.gpa, argv);
|
|
var argc: i32 = @intCast(argv.len);
|
|
const qapp: QApplication = .new(init.arena.allocator(), &argc, argv);
|
|
defer qapp.delete();
|
|
|
|
if (!QSystemTrayIcon.isSystemTrayAvailable()) {
|
|
std.debug.print("inferon: no system tray available\n", .{});
|
|
return error.NoSystemTray;
|
|
}
|
|
|
|
QApplication.setQuitOnLastWindowClosed(false);
|
|
allocator = init.gpa;
|
|
io_ctx = init.io;
|
|
|
|
installSignalHandlers();
|
|
|
|
const quit_action = QAction.new5("&Quit", QWidget{ .ptr = null });
|
|
quit_action.onTriggered(onQuit);
|
|
|
|
const end_action = QAction.new5("&End conversation", QWidget{ .ptr = null });
|
|
end_action.onTriggered(onEndConversation);
|
|
|
|
// Floating-window toggle
|
|
const win_action = QAction.new5("&Floating window", QWidget{ .ptr = null });
|
|
win_action.setCheckable(true);
|
|
win_action.setChecked(true);
|
|
win_action.onTriggered(onToggleWindow);
|
|
|
|
initPrefsPaths();
|
|
letta.initConversationsDir();
|
|
loadPrefs();
|
|
agent_menu = QMenu.new(QWidget{ .ptr = null });
|
|
agent_menu.setTitle("Agent");
|
|
conv_menu = QMenu.new(QWidget{ .ptr = null });
|
|
conv_menu.setTitle("Conversation");
|
|
populateAgentMenu();
|
|
populateConvMenu();
|
|
conv_menu.onAboutToShow(onConvMenuShow);
|
|
|
|
const tray_menu = QMenu.new(QWidget{ .ptr = null });
|
|
_ = tray_menu.addMenu(agent_menu);
|
|
_ = tray_menu.addMenu(conv_menu);
|
|
_ = tray_menu.addSeparator();
|
|
tray_menu.addAction(win_action);
|
|
tray_menu.addAction(end_action);
|
|
_ = tray_menu.addSeparator();
|
|
tray_menu.addAction(quit_action);
|
|
|
|
tray_icon = .new3(QWidget{ .ptr = null });
|
|
tray_icon.setContextMenu(tray_menu);
|
|
tray_icon.onActivated(onTrayActivated);
|
|
|
|
setState(.idle);
|
|
tray_icon.show();
|
|
main_thread_id = std.Thread.getCurrentId();
|
|
|
|
var ov = overlay_mod.Overlay.create(allocator);
|
|
overlay = ov;
|
|
ov.window.show(); // visible from startup per Pierre
|
|
_ = &ov;
|
|
|
|
// Own the whisper-server lifecycle: spawn AFTER the tray exists (setState
|
|
// touches tray_icon). Create the log dir first.
|
|
Io.Dir.createDirAbsolute(init.io, RECORDINGS_DIR, .default_dir) catch {};
|
|
whisper.start(allocator, init.io, RECORDINGS_DIR) catch |e| {
|
|
std.debug.print("inferon: failed to start whisper-server: {s}\n", .{@errorName(e)});
|
|
setState(.error_backend);
|
|
};
|
|
if (whisper.waitReady(init.io)) {
|
|
std.debug.print("inferon: whisper-server ready on :{d}\n", .{whisper.WHISPER_PORT});
|
|
} else {
|
|
std.debug.print("inferon: whisper-server NOT ready after {d}s\n", .{whisper.READY_TIMEOUT_S});
|
|
}
|
|
|
|
// TTS backend: melo_server.py (fails soft — notifications still work).
|
|
melo.start(allocator, init.io) catch |e| {
|
|
std.debug.print("inferon: failed to start melo server: {s}\n", .{@errorName(e)});
|
|
};
|
|
if (melo.waitReady(allocator, init.io)) {
|
|
std.debug.print("inferon: melo ready\n", .{});
|
|
playback.start(allocator, init.io);
|
|
} else {
|
|
std.debug.print("inferon: melo NOT ready\n", .{});
|
|
}
|
|
|
|
_ = QApplication.exec();
|
|
|
|
// Session-lifetime buffers: free so the debug allocator doesn't squawk.
|
|
if (last_user_text) |t| allocator.free(t);
|
|
last_user_text = null;
|
|
sentence_buf.deinit(allocator);
|
|
if (prefs_agent_buf) |b| allocator.free(b);
|
|
if (prefs_convo_buf) |b| allocator.free(b);
|
|
for (agent_entries.items) |e| allocator.free(e.id);
|
|
agent_entries.deinit(allocator);
|
|
for (conv_entries.items) |e| {
|
|
allocator.free(e.id);
|
|
allocator.free(e.preview);
|
|
}
|
|
conv_entries.deinit(allocator);
|
|
overlay_mod.Overlay.shutdownMem(); // static, no instance needed
|
|
playback.shutdown();
|
|
|
|
whisper.stop();
|
|
melo.stop();
|
|
}
|