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:
pierreandLetta Code committed 2026-09-01 20:37:45 +03:00
1 parent 5b050c09e3
commit 2b2235ff86
5 files changed
+500 -1

No files matched your search

+57 -1
View File
@@ -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 {};