Streaming overlay: chat GUI for agent responses
- overlay.zig: frameless translucent panel (fixed bg + rounded corners, only bubbles scroll), drag handle, hover-to-full-opacity, top-right spawn - chat layout: per-message QLabel bubbles (user purple / agent green / tool steps grey mono), transcript retained across turns, End-conversation resets - agent reply streams into its own growing bubble (re-render per chunk) - basic markdown in all messages: **bold**, *italic*, `code`, HTML-escaped - tool steps truncated to 90 chars, click to expand/collapse - polite autoscroll: pins to bottom unless user scrolled up - letta.zig: streamInfer() — --output-format stream-json, per-event callback (tool calls, returns, reply chunks), full reply still returned for TTS - whisper: --language auto (no more hardcoded English) - fix: stderr_log leak crashing Quit via debug allocator 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code <noreply@letta.com>
This commit is contained in:
1 parent
5b050c09e3
commit
2b2235ff86
5 files changed
+500
-1
No files matched your search
+57
-1
@@ -19,6 +19,7 @@ const QBrush = qt6.QBrush;
|
||||
const QRectF = qt6.QRectF;
|
||||
const threading = qt6.threading;
|
||||
const letta = @import("letta.zig");
|
||||
const overlay_mod = @import("overlay.zig");
|
||||
|
||||
// ------------------------------------------------------------- constants ---
|
||||
|
||||
@@ -82,6 +83,49 @@ 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);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Must run on the Qt main thread (touches QSystemTrayIcon / QPixmap).
|
||||
fn setStateMainThread(next: State) void {
|
||||
state = next;
|
||||
@@ -188,6 +232,7 @@ fn startRecording() void {
|
||||
// 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
|
||||
@@ -265,7 +310,10 @@ fn processUtterance() void {
|
||||
|
||||
// --- agent ---
|
||||
setState(.thinking);
|
||||
const response = letta.infer(io, allocator, LETTA_AGENT_ID, text) catch |err| {
|
||||
|
||||
pushOverlay(.begin, null);
|
||||
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| {
|
||||
std.log.err("letta failed: {s}", .{@errorName(err)});
|
||||
setState(.idle);
|
||||
return;
|
||||
@@ -323,6 +371,10 @@ fn onEndConversation(_: QAction) callconv(.c) void {
|
||||
conversation_active = false;
|
||||
if (last_user_text) |t| allocator.free(t);
|
||||
last_user_text = null;
|
||||
if (overlay) |*ov| {
|
||||
ov.reset();
|
||||
ov.hide();
|
||||
}
|
||||
setState(.idle);
|
||||
}
|
||||
|
||||
@@ -401,6 +453,10 @@ pub fn main(init: std.process.Init) !void {
|
||||
tray_icon.show();
|
||||
main_thread_id = std.Thread.getCurrentId();
|
||||
|
||||
var ov = overlay_mod.Overlay.create(allocator);
|
||||
overlay = ov;
|
||||
_ = &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 {};
|
||||
|
||||
Reference in new issue
Block a user