Agent + conversation pickers, prefs persistence
- 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>
This commit is contained in:
1 parent
bcd48a3443
commit
2fdc812a13
2 files changed
+416
-9
No files matched your search
+208
-8
@@ -1,4 +1,5 @@
|
|||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
const Io = std.Io;
|
||||||
|
|
||||||
const LETTA_PATH = "/home/pierre/.bun/bin/letta";
|
const LETTA_PATH = "/home/pierre/.bun/bin/letta";
|
||||||
|
|
||||||
@@ -61,13 +62,39 @@ pub fn streamInfer(
|
|||||||
ctx: ?*anyopaque,
|
ctx: ?*anyopaque,
|
||||||
onEvent: *const fn (ctx: ?*anyopaque, ev: Event) void,
|
onEvent: *const fn (ctx: ?*anyopaque, ev: Event) void,
|
||||||
) ![]u8 {
|
) ![]u8 {
|
||||||
|
return streamInferConv(io, allocator, agent, "default", prompt, ctx, onEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn streamInferConv(
|
||||||
|
io: std.Io,
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
agent: []const u8,
|
||||||
|
conversation: []const u8,
|
||||||
|
prompt: []const u8,
|
||||||
|
ctx: ?*anyopaque,
|
||||||
|
onEvent: *const fn (ctx: ?*anyopaque, ev: Event) void,
|
||||||
|
) ![]u8 {
|
||||||
|
// CLI rule: --agent only pairs with --conversation default; for named
|
||||||
|
// conversations the agent is implied, so we omit it.
|
||||||
|
const is_default = std.mem.eql(u8, conversation, "default");
|
||||||
|
var argv: [11][]const u8 = undefined;
|
||||||
|
var ai: usize = 0;
|
||||||
|
argv[ai] = LETTA_PATH; ai += 1;
|
||||||
|
if (is_default) {
|
||||||
|
argv[ai] = "--agent"; ai += 1;
|
||||||
|
argv[ai] = agent; ai += 1;
|
||||||
|
}
|
||||||
|
argv[ai] = "-p"; ai += 1;
|
||||||
|
argv[ai] = prompt; ai += 1;
|
||||||
|
argv[ai] = "--conversation"; ai += 1;
|
||||||
|
argv[ai] = conversation; ai += 1;
|
||||||
|
argv[ai] = "--toolset"; ai += 1;
|
||||||
|
argv[ai] = "default"; ai += 1;
|
||||||
|
argv[ai] = "--output-format"; ai += 1;
|
||||||
|
argv[ai] = "stream-json"; ai += 1;
|
||||||
|
|
||||||
var child = try std.process.spawn(io, .{
|
var child = try std.process.spawn(io, .{
|
||||||
.argv = &.{
|
.argv = argv[0..ai],
|
||||||
LETTA_PATH, "--agent", agent,
|
|
||||||
"-p", prompt, "--conversation",
|
|
||||||
"default", "--toolset", "default",
|
|
||||||
"--output-format", "stream-json",
|
|
||||||
},
|
|
||||||
.stdout = .pipe,
|
.stdout = .pipe,
|
||||||
.stderr = .inherit,
|
.stderr = .inherit,
|
||||||
});
|
});
|
||||||
@@ -75,6 +102,8 @@ pub fn streamInfer(
|
|||||||
|
|
||||||
var reply: std.ArrayList(u8) = .empty;
|
var reply: std.ArrayList(u8) = .empty;
|
||||||
errdefer reply.deinit(allocator);
|
errdefer reply.deinit(allocator);
|
||||||
|
var raw_tail: std.ArrayList(u8) = .empty; // last bytes, debug on empty reply
|
||||||
|
defer raw_tail.deinit(allocator);
|
||||||
|
|
||||||
var line_buf: [16 * 1024]u8 = undefined;
|
var line_buf: [16 * 1024]u8 = undefined;
|
||||||
var line_len: usize = 0;
|
var line_len: usize = 0;
|
||||||
@@ -83,6 +112,12 @@ pub fn streamInfer(
|
|||||||
while (true) {
|
while (true) {
|
||||||
const n = stdout_file.readStreaming(io, &.{&buf}) catch break;
|
const n = stdout_file.readStreaming(io, &.{&buf}) catch break;
|
||||||
if (n == 0) break;
|
if (n == 0) break;
|
||||||
|
raw_tail.appendSlice(allocator, buf[0..n]) catch {};
|
||||||
|
if (raw_tail.items.len > 4096) {
|
||||||
|
const excess = raw_tail.items.len - 4096;
|
||||||
|
std.mem.copyForwards(u8, raw_tail.items[0..4096], raw_tail.items[excess..]);
|
||||||
|
raw_tail.shrinkRetainingCapacity(4096);
|
||||||
|
}
|
||||||
for (buf[0..n]) |ch| {
|
for (buf[0..n]) |ch| {
|
||||||
if (ch == '\n') {
|
if (ch == '\n') {
|
||||||
if (line_len > 0) {
|
if (line_len > 0) {
|
||||||
@@ -97,6 +132,9 @@ pub fn streamInfer(
|
|||||||
}
|
}
|
||||||
_ = child.wait(io) catch {};
|
_ = child.wait(io) catch {};
|
||||||
|
|
||||||
|
if (reply.items.len == 0 and raw_tail.items.len > 0) {
|
||||||
|
std.debug.print("inferon/letta: EMPTY reply, raw stream tail:\n{s}\n", .{raw_tail.items});
|
||||||
|
}
|
||||||
return reply.toOwnedSlice(allocator);
|
return reply.toOwnedSlice(allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,12 +147,21 @@ fn handleLine(
|
|||||||
reply: *std.ArrayList(u8),
|
reply: *std.ArrayList(u8),
|
||||||
) void {
|
) void {
|
||||||
const mt = dupeStr(allocator, line, "message_type") orelse {
|
const mt = dupeStr(allocator, line, "message_type") orelse {
|
||||||
// Non-message line; the "result" line holds the full text as backup.
|
// The "result" line holds the full text as backup.
|
||||||
if (std.mem.indexOf(u8, line, "\"type\":\"result\"") != null and reply.items.len == 0) {
|
if (std.mem.indexOf(u8, line, "\"type\":\"result\"") != null and reply.items.len == 0) {
|
||||||
if (dupeStr(allocator, line, "result")) |r| {
|
if (dupeStr(allocator, line, "result")) |r| {
|
||||||
defer allocator.free(r);
|
defer allocator.free(r);
|
||||||
reply.appendSlice(allocator, r) catch {};
|
reply.appendSlice(allocator, r) catch {};
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Conversation-mode (no --agent) ignores --output-format and emits
|
||||||
|
// PLAIN TEXT — treat non-JSON lines as the reply itself.
|
||||||
|
if (line.len > 0 and line[0] != '{') {
|
||||||
|
reply.appendSlice(allocator, line) catch {};
|
||||||
|
reply.append(allocator, ' ') catch {};
|
||||||
|
const dup = allocator.dupe(u8, line) catch return;
|
||||||
|
onEvent(ctx, .{ .chunk = dup });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -144,7 +191,7 @@ fn handleLine(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Extract "key":"value" (with escape handling), allocated with `allocator`.
|
/// Extract "key":"value" (with escape handling), allocated with `allocator`.
|
||||||
fn dupeStr(allocator: std.mem.Allocator, line: []const u8, key: []const u8) ?[]u8 {
|
pub fn dupeStr(allocator: std.mem.Allocator, line: []const u8, key: []const u8) ?[]u8 {
|
||||||
var pat_buf: [64]u8 = undefined;
|
var pat_buf: [64]u8 = undefined;
|
||||||
const pat = std.fmt.bufPrint(&pat_buf, "\"{s}\":\"", .{key}) catch return null;
|
const pat = std.fmt.bufPrint(&pat_buf, "\"{s}\":\"", .{key}) catch return null;
|
||||||
const start = std.mem.indexOf(u8, line, pat) orelse return null;
|
const start = std.mem.indexOf(u8, line, pat) orelse return null;
|
||||||
@@ -168,3 +215,156 @@ fn dupeStr(allocator: std.mem.Allocator, line: []const u8, key: []const u8) ?[]u
|
|||||||
}
|
}
|
||||||
return out.toOwnedSlice(allocator) catch null;
|
return out.toOwnedSlice(allocator) catch null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- listings ---
|
||||||
|
|
||||||
|
pub const Agent = struct { id: []u8, name: []u8 }; // caller frees both
|
||||||
|
|
||||||
|
fn readAllStdout(io: std.Io, alloc: std.mem.Allocator, argv: []const []const u8) ![]u8 {
|
||||||
|
var child = try std.process.spawn(io, .{
|
||||||
|
.argv = argv,
|
||||||
|
.stdin = .ignore,
|
||||||
|
.stdout = .pipe,
|
||||||
|
.stderr = .ignore,
|
||||||
|
});
|
||||||
|
const out = child.stdout.?;
|
||||||
|
var buf: std.ArrayList(u8) = .empty;
|
||||||
|
errdefer buf.deinit(alloc);
|
||||||
|
var tmp: [16 * 1024]u8 = undefined;
|
||||||
|
while (true) {
|
||||||
|
const n = out.readStreaming(io, &.{&tmp}) catch break;
|
||||||
|
if (n == 0) break;
|
||||||
|
try buf.appendSlice(alloc, tmp[0..n]);
|
||||||
|
}
|
||||||
|
_ = child.wait(io) catch {};
|
||||||
|
return buf.toOwnedSlice(alloc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `letta agents list` JSON into id/name pairs.
|
||||||
|
pub fn listAgents(io: std.Io, alloc: std.mem.Allocator) ![]Agent {
|
||||||
|
const json = try readAllStdout(io, alloc, &.{ LETTA_PATH, "agents", "list" });
|
||||||
|
defer alloc.free(json);
|
||||||
|
|
||||||
|
var out: std.ArrayList(Agent) = .empty;
|
||||||
|
errdefer out.deinit(alloc);
|
||||||
|
var i: usize = 0;
|
||||||
|
while (jsonStr(alloc, json, i, "id")) |r| {
|
||||||
|
defer alloc.free(r.val);
|
||||||
|
if (!std.mem.startsWith(u8, r.val, "agent-")) {
|
||||||
|
i = r.end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const id = try alloc.dupe(u8, r.val);
|
||||||
|
errdefer alloc.free(id);
|
||||||
|
// name is the next field after id in each item — no fallback leak.
|
||||||
|
const name: []u8 = if (jsonStr(alloc, json, r.end, "name")) |nr| nr.val else try alloc.dupe(u8, "?");
|
||||||
|
try out.append(alloc, .{ .id = id, .name = name });
|
||||||
|
i = r.end;
|
||||||
|
}
|
||||||
|
return out.toOwnedSlice(alloc);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const Convo = struct { id: []u8, preview: []u8 }; // caller frees both
|
||||||
|
|
||||||
|
/// Conversations for an agent, straight from the local backend's storage
|
||||||
|
/// (the CLI has no list-conversations surface; messages list only exports
|
||||||
|
/// "default"). Reads <backend>/conversations/*/conversation.json.
|
||||||
|
var conv_dir_buf: [512]u8 = undefined;
|
||||||
|
pub var CONVERSATIONS_DIR: []const u8 = "";
|
||||||
|
|
||||||
|
pub fn initConversationsDir() void {
|
||||||
|
const home = std.c.getenv("HOME") orelse return;
|
||||||
|
CONVERSATIONS_DIR = std.fmt.bufPrint(&conv_dir_buf, "{s}/.letta/lc-local-backend/conversations", .{std.mem.span(home)}) catch return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn listConversations(io: std.Io, alloc: std.mem.Allocator, agent: []const u8) ![]Convo {
|
||||||
|
var out: std.ArrayList(Convo) = .empty;
|
||||||
|
errdefer out.deinit(alloc);
|
||||||
|
try out.append(alloc, .{ .id = try alloc.dupe(u8, "default"), .preview = try alloc.dupe(u8, "") });
|
||||||
|
|
||||||
|
var dir = try Io.Dir.openDirAbsolute(io, CONVERSATIONS_DIR, .{ .iterate = true });
|
||||||
|
defer dir.close(io);
|
||||||
|
var it = dir.iterate();
|
||||||
|
while (try it.next(io)) |entry| {
|
||||||
|
if (entry.kind != .directory) continue;
|
||||||
|
var buf: [4096]u8 = undefined;
|
||||||
|
const sub = try std.fmt.bufPrint(&buf, "{s}/conversation.json", .{entry.name});
|
||||||
|
const json = dir.readFileAlloc(io, sub, alloc, .limited(64 * 1024)) catch continue;
|
||||||
|
defer alloc.free(json);
|
||||||
|
|
||||||
|
const aid = jsonStr(alloc, json, 0, "agent_id") orelse continue;
|
||||||
|
defer alloc.free(aid.val);
|
||||||
|
if (!std.mem.eql(u8, aid.val, agent)) continue;
|
||||||
|
|
||||||
|
// skip archived
|
||||||
|
if (std.mem.indexOf(u8, json, "\"archived\": true") != null) continue;
|
||||||
|
|
||||||
|
const cid = jsonStr(alloc, json, 0, "id") orelse continue;
|
||||||
|
var dup = false;
|
||||||
|
for (out.items) |existing| {
|
||||||
|
if (std.mem.eql(u8, existing.id, cid.val)) dup = true;
|
||||||
|
}
|
||||||
|
if (dup) {
|
||||||
|
alloc.free(cid.val);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview: summary if set, else the last message text.
|
||||||
|
var preview: []u8 = try alloc.dupe(u8, "");
|
||||||
|
if (jsonStr(alloc, json, 0, "summary")) |s| {
|
||||||
|
if (s.val.len > 0) {
|
||||||
|
alloc.free(preview);
|
||||||
|
preview = s.val;
|
||||||
|
} else {
|
||||||
|
alloc.free(s.val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (preview.len == 0) {
|
||||||
|
var mbuf: [4096]u8 = undefined;
|
||||||
|
const msub = try std.fmt.bufPrint(&mbuf, "{s}/messages.jsonl", .{entry.name});
|
||||||
|
const msgs = dir.readFileAlloc(io, msub, alloc, .limited(16 * 1024 * 1024)) catch null;
|
||||||
|
if (msgs) |m| {
|
||||||
|
defer alloc.free(m);
|
||||||
|
if (std.mem.lastIndexOfScalar(u8, m, '\n')) |nl| {
|
||||||
|
const last = m[nl + 1 ..];
|
||||||
|
if (jsonStr(alloc, last, 0, "text")) |t| {
|
||||||
|
alloc.free(preview);
|
||||||
|
preview = t.val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try out.append(alloc, .{ .id = cid.val, .preview = preview });
|
||||||
|
}
|
||||||
|
return out.toOwnedSlice(alloc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whitespace-tolerant JSON string-field extractor: finds "key" (with or
|
||||||
|
/// without space after the colon), returns the unescaped value.
|
||||||
|
pub fn jsonStr(alloc: std.mem.Allocator, json: []const u8, from: usize, key: []const u8) ?struct { val: []u8, end: usize } {
|
||||||
|
var pat_buf: [64]u8 = undefined;
|
||||||
|
const pat = std.fmt.bufPrint(&pat_buf, "\"{s}\"", .{key}) catch return null;
|
||||||
|
const ks = std.mem.indexOfPos(u8, json, from, pat) orelse return null;
|
||||||
|
var i = ks + pat.len;
|
||||||
|
while (i < json.len and (json[i] == ' ' or json[i] == ':')) i += 1;
|
||||||
|
if (i >= json.len or json[i] != '"') return null;
|
||||||
|
i += 1;
|
||||||
|
|
||||||
|
var out: std.ArrayList(u8) = .empty;
|
||||||
|
while (i < json.len and json[i] != '"') {
|
||||||
|
if (json[i] == '\\' and i + 1 < json.len) {
|
||||||
|
const esc: u8 = switch (json[i + 1]) {
|
||||||
|
'n' => '\n',
|
||||||
|
't' => '\t',
|
||||||
|
else => json[i + 1],
|
||||||
|
};
|
||||||
|
out.append(alloc, esc) catch return null;
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
out.append(alloc, json[i]) catch return null;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (i >= json.len) return null;
|
||||||
|
return .{ .val = out.toOwnedSlice(alloc) catch return null, .end = i + 1 };
|
||||||
|
}
|
||||||
+208
-1
@@ -70,6 +70,78 @@ var tray_icon: QSystemTrayIcon = undefined;
|
|||||||
var allocator: std.mem.Allocator = undefined;
|
var allocator: std.mem.Allocator = undefined;
|
||||||
|
|
||||||
var conversation_active: bool = false;
|
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;
|
var last_user_text: ?[]u8 = null;
|
||||||
|
|
||||||
fn setLastUserText(text: []u8) void {
|
fn setLastUserText(text: []u8) void {
|
||||||
@@ -324,7 +396,7 @@ fn processUtterance() void {
|
|||||||
|
|
||||||
pushOverlay(.begin, null);
|
pushOverlay(.begin, null);
|
||||||
if (allocator.dupe(u8, text) catch null) |user_dup| pushOverlay(.user, user_dup);
|
if (allocator.dupe(u8, text) catch null) |user_dup| pushOverlay(.user, user_dup);
|
||||||
const response = letta.streamInfer(io, allocator, LETTA_AGENT_ID, text, null, onLettaEvent) catch |err| {
|
const response = letta.streamInferConv(io, allocator, current_agent, current_conversation, text, null, onLettaEvent) catch |err| {
|
||||||
std.log.err("letta failed: {s}", .{@errorName(err)});
|
std.log.err("letta failed: {s}", .{@errorName(err)});
|
||||||
setState(.idle);
|
setState(.idle);
|
||||||
return;
|
return;
|
||||||
@@ -370,6 +442,110 @@ fn onTrayActivated(_: QSystemTrayIcon, reason: i32) callconv(.c) void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
fn onEndConversation(_: QAction) callconv(.c) void {
|
||||||
if (recorder != null) stopRecording();
|
if (recorder != null) stopRecording();
|
||||||
conversation_active = false;
|
conversation_active = false;
|
||||||
@@ -444,7 +620,28 @@ pub fn main(init: std.process.Init) !void {
|
|||||||
const end_action = QAction.new5("&End conversation", QWidget{ .ptr = null });
|
const end_action = QAction.new5("&End conversation", QWidget{ .ptr = null });
|
||||||
end_action.onTriggered(onEndConversation);
|
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 });
|
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.addAction(end_action);
|
||||||
_ = tray_menu.addSeparator();
|
_ = tray_menu.addSeparator();
|
||||||
tray_menu.addAction(quit_action);
|
tray_menu.addAction(quit_action);
|
||||||
@@ -459,6 +656,7 @@ pub fn main(init: std.process.Init) !void {
|
|||||||
|
|
||||||
var ov = overlay_mod.Overlay.create(allocator);
|
var ov = overlay_mod.Overlay.create(allocator);
|
||||||
overlay = ov;
|
overlay = ov;
|
||||||
|
ov.window.show(); // visible from startup per Pierre
|
||||||
_ = &ov;
|
_ = &ov;
|
||||||
|
|
||||||
// Own the whisper-server lifecycle: spawn AFTER the tray exists (setState
|
// Own the whisper-server lifecycle: spawn AFTER the tray exists (setState
|
||||||
@@ -491,6 +689,15 @@ pub fn main(init: std.process.Init) !void {
|
|||||||
if (last_user_text) |t| allocator.free(t);
|
if (last_user_text) |t| allocator.free(t);
|
||||||
last_user_text = null;
|
last_user_text = null;
|
||||||
sentence_buf.deinit(allocator);
|
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
|
overlay_mod.Overlay.shutdownMem(); // static, no instance needed
|
||||||
playback.shutdown();
|
playback.shutdown();
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user