Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,17 @@ pub fn build(b: *std.Build) void {
.optimize = optimize,
.root_source_file = b.path("src/env.zig"),
}) });
// Where a conversation is written, and that a client holding its own history gets no file.
const store_tests = b.addTest(.{ .root_module = b.createModule(.{
.target = target,
.optimize = optimize,
.root_source_file = b.path("src/store.zig"),
}) });
const test_step = b.step("test", "Run the chat's tests");
test_step.dependOn(&b.addRunArtifact(auth_tests).step);
test_step.dependOn(&b.addRunArtifact(tests).step);
test_step.dependOn(&b.addRunArtifact(transcript_tests).step);
test_step.dependOn(&b.addRunArtifact(permission_tests).step);
test_step.dependOn(&b.addRunArtifact(env_tests).step);
test_step.dependOn(&b.addRunArtifact(store_tests).step);
}
14 changes: 9 additions & 5 deletions plugin.zig
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@
//! client's group in the sidebar (`src/Explorer.zig`). Agents over ACP are a client here for now
//! (`src/AcpClient.zig`), on their way to the agent plugin.
const std = @import("std");
const builtin = @import("builtin");
const sdk = @import("fizzy_sdk");
const dvui = @import("dvui");
const Chat = @import("src/Chat.zig");
const View = @import("src/View.zig");
const Commands = @import("src/Commands.zig");
const AcpClient = @import("src/AcpClient.zig");
/// ACP spawns the agent as a process, on a thread: nowhere in a browser. Everything that names it
/// is behind `has_acp`, so the web build never analyses it.
const has_acp = builtin.os.tag != .freestanding;
const AcpClient = if (has_acp) @import("src/AcpClient.zig") else struct {};
const Explorer = @import("src/Explorer.zig");
const Settings = @import("src/Settings.zig");
const DocHandle = sdk.DocHandle;
Expand Down Expand Up @@ -70,7 +74,7 @@ var chats: std.AutoArrayHashMapUnmanaged(u64, *Chat) = .empty;

pub fn register(host: *sdk.Host) !void {
Schema.load(host, plugin_id, &settings);
AcpClient.settings = &settings;
if (has_acp) AcpClient.settings = &settings;
Chat.owner = &plugin;
Commands.chats = &chats;
plugin.state = @ptrCast(&settings);
Expand All @@ -86,15 +90,15 @@ pub fn register(host: *sdk.Host) !void {
});
try Commands.register(host, &plugin);
// Clients of the chat, through its commands like any other plugin.
try AcpClient.register(host, &plugin);
if (has_acp) try AcpClient.register(host, &plugin);
}

fn drawExplorer(_: ?*anyopaque) anyerror!dvui.App.Result {
return Explorer.draw(sdk.host());
}

fn beginFrame(_: *anyopaque) void {
AcpClient.frame();
if (has_acp) AcpClient.frame();
Explorer.frame(sdk.host());
for (chats.values()) |c| {
c.sendQueued();
Expand All @@ -111,7 +115,7 @@ fn deinit(_: *anyopaque) void {
gpa.destroy(c);
}
chats.deinit(gpa);
AcpClient.deinit(gpa);
if (has_acp) AcpClient.deinit(gpa);
Commands.deinit(gpa);
Explorer.deinit(gpa);
}
Expand Down
23 changes: 9 additions & 14 deletions src/Chat.zig
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
const Chat = @This();

const std = @import("std");
const builtin = @import("builtin");
const sdk = @import("fizzy_sdk");
const dvui = @import("dvui");
const Transcript = @import("Transcript.zig");
const View = @import("View.zig");
const Commands = @import("Commands.zig");
const chat = @import("api.zig");
const store = @import("store.zig");

gpa: std.mem.Allocator,
host: *sdk.Host,
Expand Down Expand Up @@ -73,6 +75,8 @@ pub var owner: *sdk.Plugin = undefined;

/// A chat from its transcript on disk; a path with no file yet is a new, empty chat.
pub fn fromPath(path: []const u8) !Chat {
// The web has no transcripts on disk: a chat there starts empty, and its client fills it.
if (comptime builtin.os.tag == .freestanding) return fromBytes(path, "");
const gpa = sdk.allocator();
const bytes = std.Io.Dir.cwd().readFileAlloc(dvui.io, path, gpa, .limited(64 * 1024 * 1024)) catch |err| switch (err) {
error.FileNotFound => return fromBytes(path, ""),
Expand All @@ -95,7 +99,7 @@ pub fn fromBytes(path: []const u8, bytes: []const u8) !Chat {
};
errdefer self.deinitFields();
if (std.mem.trim(u8, bytes, " \t\r\n").len > 0) {
const dir = try imagesDir(gpa, self.path);
const dir = try store.imagesDir(gpa, self.path);
defer gpa.free(dir);
if (self.transcript.load(bytes, dir, dvui.io)) |h| {
defer gpa.free(h.client);
Expand Down Expand Up @@ -137,12 +141,6 @@ pub fn chatIdOf(path: []const u8) chat.ChatId {
return h.final() | 1;
}

/// Where the images of the transcript at `path` are kept: beside it, in a folder of its name.
pub fn imagesDir(gpa: std.mem.Allocator, path: []const u8) ![]u8 {
const stem = if (std.mem.endsWith(u8, path, ".chat")) path[0 .. path.len - 5] else path;
return std.fmt.allocPrint(gpa, "{s}.images", .{stem});
}

pub fn handle(self: *Chat) sdk.DocHandle {
return .{ .ptr = self, .id = self.id, .owner = owner };
}
Expand All @@ -155,13 +153,8 @@ pub fn save(self: *Chat) !void {
}

pub fn write(self: *Chat) !void {
var out: std.Io.Writer.Allocating = .init(self.gpa);
defer out.deinit();
const dir = try imagesDir(self.gpa, self.path);
defer self.gpa.free(dir);
try self.transcript.save(&out.writer, .{ .client = self.client_id, .title = self.title.items, .folder = self.folder, .@"resume" = self.resume_state }, dir, dvui.io);
if (std.fs.path.dirname(self.path)) |d| std.Io.Dir.cwd().createDirPath(dvui.io, d) catch {};
try std.Io.Dir.cwd().writeFile(dvui.io, .{ .sub_path = self.path, .data = out.written() });
const history: chat.History = if (Commands.clientKeeps(self.client_id)) .client else .chat;
try store.write(self.gpa, dvui.io, history, self.path, &self.transcript, .{ .client = self.client_id, .title = self.title.items, .folder = self.folder, .@"resume" = self.resume_state });
self.saved_version = self.transcript.version;
self.header_dirty = false;
@import("Explorer.zig").changed();
Expand Down Expand Up @@ -284,6 +277,8 @@ fn sendWith(self: *Chat, text: []const u8, attached: []const Attachment) bool {
var sent: std.ArrayListUnmanaged(chat.Block) = .empty;
if (trimmed.len > 0) shown.append(arena, .{ .text = trimmed }) catch return false;
for (attached) |a| {
// Attachments are files on disk; the web has none to read.
if (comptime builtin.os.tag == .freestanding) continue;
if (a.image()) {
const bytes = std.Io.Dir.cwd().readFileAlloc(dvui.io, a.path, arena, .limited(32 * 1024 * 1024)) catch continue;
shown.append(arena, .{ .image = .{ .mime = a.mime, .bytes = bytes, .alt = std.fs.path.basename(a.path) } }) catch {};
Expand Down
17 changes: 14 additions & 3 deletions src/Commands.zig
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//! no other client. Nothing here is in fizzy's SDK: the contract is this plugin's, versioned by
//! `api.version`.
const std = @import("std");
const builtin = @import("builtin");
const sdk = @import("fizzy_sdk");
const dvui = @import("dvui");
const api = @import("api.zig");
Expand All @@ -17,7 +18,7 @@ pub var chats: *std.AutoArrayHashMapUnmanaged(u64, *Chat) = undefined;
/// posts land here, and the document adopts them when it registers (`adopt`).
var opening: std.ArrayListUnmanaged(*Chat) = .empty;

const Client = struct { id: []u8, title: []u8, version: u32 };
const Client = struct { id: []u8, title: []u8, version: u32, history: api.History };
/// The registered clients, by id.
var clients: std.ArrayListUnmanaged(Client) = .empty;

Expand Down Expand Up @@ -66,6 +67,12 @@ pub fn clientTitle(id: []const u8) ?[]const u8 {
return clients.items[clientIndex(id) orelse return null].title;
}

/// Whether the client holds `id`'s conversations itself (`api.History`): the chat then writes none
/// of them. A client that is not registered has no say: its chats stay on disk as they were.
pub fn clientKeeps(id: []const u8) bool {
return (clients.items[clientIndex(id) orelse return false]).history == .client;
}

pub fn registeredIds(out: *std.ArrayListUnmanaged([]const u8), arena: std.mem.Allocator) void {
for (clients.items) |c| out.append(arena, c.id) catch {};
}
Expand Down Expand Up @@ -181,7 +188,7 @@ fn cmdRegister(a: api.Register, call: *sdk.Command.Call) !void {
if (a.version > api.version) return call.fail("this chat speaks version {d} of the contract; {s} asked for {d}", .{ api.version, a.client, a.version });
if (a.client.len == 0 or std.mem.indexOfAny(u8, a.client, "/\\.") != null) return call.fail("a client id is a plain name: {s}", .{a.client});
const gpa = sdk.allocator();
const entry: Client = .{ .id = try gpa.dupe(u8, a.client), .title = try gpa.dupe(u8, a.title), .version = a.version };
const entry: Client = .{ .id = try gpa.dupe(u8, a.client), .title = try gpa.dupe(u8, a.title), .version = a.version, .history = a.history };
if (clientIndex(a.client)) |i| {
freeClient(gpa, clients.items[i]);
clients.items[i] = entry;
Expand Down Expand Up @@ -210,7 +217,7 @@ fn cmdOpen(a: api.Open, call: *sdk.Command.Call) !void {
if (!isRegistered(a.client)) return call.fail("{s} is not registered (chat.register)", .{a.client});
const host = sdk.host();
const gpa = sdk.allocator();
const path = try Explorer.newPath(host, a.client, a.folder);
const path = try Explorer.newPath(host, a.client, a.folder, clientKeeps(a.client));
defer host.allocator.free(path);
// The chat exists from now: what the client posts before fizzy has opened its document waits
// here, and the document adopts it.
Expand All @@ -231,6 +238,8 @@ fn cmdOpen(a: api.Open, call: *sdk.Command.Call) !void {
fn resolve(arena: std.mem.Allocator, b: api.Block) !api.Block {
if (b != .image or b.image.bytes.len > 0) return b;
var img = b.image;
// An image arrives as bytes on the web, which has no path to read.
if (comptime builtin.os.tag == .freestanding) return error.NoFilesOnTheWeb;
img.bytes = try std.Io.Dir.cwd().readFileAlloc(dvui.io, img.path, arena, .limited(32 * 1024 * 1024));
if (img.mime.len == 0) img.mime = mimeOf(img.path);
return .{ .image = img };
Expand Down Expand Up @@ -336,6 +345,8 @@ fn cmdSetSettings(a: api.SetSettings, call: *sdk.Command.Call) !void {
/// What the person's + does, for whoever else attaches: a drop from the file tree, an agent.
fn cmdAttach(a: api.Attach, call: *sdk.Command.Call) !void {
const c = if (a.chat != 0) try find(a.chat, call) else front() orelse return call.fail("no chat is in front", .{});
// A path is a file on disk; the web has none to attach.
if (comptime builtin.os.tag == .freestanding) return call.fail("files cannot be attached on the web", .{});
std.Io.Dir.cwd().access(dvui.io, a.path, .{}) catch return call.fail("no file at {s}", .{a.path});
c.attach(a.path);
changed(c);
Expand Down
27 changes: 20 additions & 7 deletions src/Explorer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
//! conversation whole. Transcripts from before chats had clients sit directly in `chats/`: they
//! are the agent's, and move into `chats/agent/` the first time the list is read.
const std = @import("std");
const builtin = @import("builtin");
const sdk = @import("fizzy_sdk");
const dvui = @import("dvui");
const core = @import("core");
Expand Down Expand Up @@ -73,14 +74,18 @@ pub fn dir(host: *sdk.Host, gpa: std.mem.Allocator) ?[]u8 {
}

/// A new, empty transcript of `client`'s, written so it opens as theirs, about `folder` (empty:
/// the one fizzy has open). Owned by `host.allocator`.
pub fn newPath(host: *sdk.Host, client: []const u8, folder: []const u8) ![]u8 {
/// the one fizzy has open). Owned by `host.allocator`. With `keeps` (the client holds its own
/// history, `api.History`) nothing is written: the path only names the chat, and needs no config
/// folder, so it works where there is none (the web).
pub fn newPath(host: *sdk.Host, client: []const u8, folder: []const u8, keeps: bool) ![]u8 {
const gpa = host.allocator;
const chats = dir(host, gpa) orelse return error.NoConfigFolder;
const chats = dir(host, gpa) orelse if (keeps) try gpa.dupe(u8, "chats") else return error.NoConfigFolder;
defer gpa.free(chats);
// The web has no disk: there a chat is always its client's (a `.chat` one has no config folder).
const on_disk = builtin.os.tag != .freestanding and !keeps;
const client_dir = try std.fs.path.join(gpa, &.{ chats, client });
defer gpa.free(client_dir);
std.Io.Dir.cwd().createDirPath(dvui.io, client_dir) catch {};
if (on_disk) std.Io.Dir.cwd().createDirPath(dvui.io, client_dir) catch {};
// Named by when it began, so names sort as the chats do and never collide in practice.
const now = std.Io.Clock.real.now(dvui.io).toSeconds();
const day = std.time.epoch.EpochSeconds{ .secs = @intCast(now) };
Expand All @@ -95,10 +100,14 @@ pub fn newPath(host: *sdk.Host, client: []const u8, folder: []const u8) ![]u8 {
if (n == 0) "" else try std.fmt.bufPrint(name_buf[48..], "-{d}", .{n}),
});
const p = try std.fs.path.join(gpa, &.{ client_dir, name });
std.Io.Dir.cwd().access(dvui.io, p, .{}) catch break p;
// Nothing on disk to collide with, but a chat of this client open under the name.
if (!on_disk) {
if (host.docFromPath(p) == null) break p;
} else std.Io.Dir.cwd().access(dvui.io, p, .{}) catch break p;
gpa.free(p);
};
errdefer gpa.free(path);
if (!on_disk) return path;
var head: std.Io.Writer.Allocating = .init(gpa);
defer head.deinit();
const project = if (folder.len > 0) folder else host.folder() orelse "";
Expand Down Expand Up @@ -136,6 +145,8 @@ fn migrate(gpa: std.mem.Allocator, chats: []const u8) void {
fn scan(host: *sdk.Host) void {
const gpa = host.allocator;
clear(gpa);
// The web has no folder of transcripts to list: a client there lists its own chats.
if (comptime builtin.os.tag == .freestanding) return;
const chats = dir(host, gpa) orelse return;
defer gpa.free(chats);
if (!migrated) {
Expand Down Expand Up @@ -227,9 +238,11 @@ fn deleteChat(host: *sdk.Host, path: []const u8) void {
host.closeDocById(doc.id) catch |err| return dvui.log.err("chat: could not close {s}: {t}", .{ path, err });
if (host.docFromPath(path) != null) return dvui.log.warn("chat: {s} is still open, so it stays", .{path});
}
// The web has no files of a chat's to delete: its client keeps it.
if (comptime builtin.os.tag == .freestanding) return changed();
const cwd = std.Io.Dir.cwd();
cwd.deleteFile(dvui.io, path) catch |err| return dvui.log.err("chat: could not delete {s}: {t}", .{ path, err });
if (Chat.imagesDir(host.allocator, path)) |images| {
if (@import("store.zig").imagesDir(host.allocator, path)) |images| {
defer host.allocator.free(images);
cwd.deleteTree(dvui.io, images) catch |err| dvui.log.warn("chat: could not delete {s}: {t}", .{ images, err });
} else |_| {}
Expand Down Expand Up @@ -559,7 +572,7 @@ fn newChat(host: *sdk.Host, client: []const u8, folder: []const u8) void {
_ = host.callCommand(id, args, host.arena());
return;
}
const path = newPath(host, client, folder) catch |err| return dvui.log.err("chat: could not start a new chat: {t}", .{err});
const path = newPath(host, client, folder, Commands.clientKeeps(client)) catch |err| return dvui.log.err("chat: could not start a new chat: {t}", .{err});
defer host.allocator.free(path);
_ = host.openFile(.{ .path = path }) catch {};
}
5 changes: 5 additions & 0 deletions src/Transcript.zig
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
const Transcript = @This();

const std = @import("std");
const builtin = @import("builtin");
const chat = @import("api.zig");

gpa: std.mem.Allocator,
Expand Down Expand Up @@ -440,6 +441,8 @@ fn savedBlock(arena: std.mem.Allocator, b: *const Block, images_dir: []const u8,
.image => |i| blk: {
const name = try std.fmt.allocPrint(arena, "{x:0>16}.{s}", .{ i.hash, extensionOf(i.mime) });
const path = try std.fs.path.join(arena, &.{ images_dir, name });
// No images folder on the web: a transcript is never written to disk there.
if (comptime builtin.os.tag == .freestanding) break :blk .{ .image = .{ .mime = i.mime, .file = name, .alt = i.alt } };
std.Io.Dir.cwd().access(io, path, .{}) catch {
std.Io.Dir.cwd().createDirPath(io, images_dir) catch {};
try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = i.bytes });
Expand Down Expand Up @@ -539,6 +542,8 @@ fn loadedBlock(self: *Transcript, arena: std.mem.Allocator, p: SavedPart, images
.thought => |t| try Block.copy(self.gpa, .{ .thought = t }),
.code => |c| try Block.copy(self.gpa, .{ .code = .{ .language = c.language, .text = c.text } }),
.image => |i| blk: {
// No images folder on the web: a transcript is never read from disk there.
if (comptime builtin.os.tag == .freestanding) break :blk null;
const path = try std.fs.path.join(arena, &.{ images_dir, std.fs.path.basename(i.file) });
const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(64 * 1024 * 1024)) catch break :blk null;
break :blk try Block.copy(self.gpa, .{ .image = .{ .mime = i.mime, .bytes = bytes, .alt = i.alt } });
Expand Down
9 changes: 7 additions & 2 deletions src/api.zig
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//! fingerprint. Bump `version` when a shape changes in a way an older client would misread.
//!
//! **What a client calls** (all `visibility = .internal`):
//! `chat.register` `{ client, title, version }`, `chat.unregister` `{ client }`,
//! `chat.register` `{ client, title, version, history }`, `chat.unregister` `{ client }`,
//! `chat.open` `{ client, title }` → `{ chat }`, `chat.post` `{ chat, author, blocks }` →
//! `{ message }`, `chat.append_text` `{ chat, message, kind, text }`, `chat.append`
//! `{ chat, message, block }`, `chat.tool` `{ chat, message, tool }` → `{ tool }`,
Expand Down Expand Up @@ -129,7 +129,12 @@ pub const TextKind = enum(u8) { text, thought };

// ---- the commands' arguments and results ----------------------------------------------------

pub const Register = struct { client: []const u8, title: []const u8, version: u32 = version };
/// Who keeps a client's conversations: the chat, as `.chat` transcripts under its config folder
/// (the default), or the client, which holds them itself: the chat then keeps only the window
/// it has loaded and writes no transcript or image file for it.
pub const History = enum(u8) { chat, client };

pub const Register = struct { client: []const u8, title: []const u8, version: u32 = version, history: History = .chat };
pub const Unregister = struct { client: []const u8 };
pub const Open = struct {
client: []const u8,
Expand Down
Loading
Loading