diff --git a/build.zig b/build.zig index f9989f1..15681ab 100644 --- a/build.zig +++ b/build.zig @@ -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); } diff --git a/plugin.zig b/plugin.zig index 7919a85..fa9dca1 100644 --- a/plugin.zig +++ b/plugin.zig @@ -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; @@ -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); @@ -86,7 +90,7 @@ 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 { @@ -94,7 +98,7 @@ fn drawExplorer(_: ?*anyopaque) anyerror!dvui.App.Result { } fn beginFrame(_: *anyopaque) void { - AcpClient.frame(); + if (has_acp) AcpClient.frame(); Explorer.frame(sdk.host()); for (chats.values()) |c| { c.sendQueued(); @@ -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); } diff --git a/src/Chat.zig b/src/Chat.zig index 558a05b..08db260 100644 --- a/src/Chat.zig +++ b/src/Chat.zig @@ -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, @@ -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, ""), @@ -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); @@ -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 }; } @@ -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(); @@ -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 {}; diff --git a/src/Commands.zig b/src/Commands.zig index 8d14550..9f5dc7c 100644 --- a/src/Commands.zig +++ b/src/Commands.zig @@ -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"); @@ -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; @@ -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 {}; } @@ -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; @@ -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. @@ -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 }; @@ -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); diff --git a/src/Explorer.zig b/src/Explorer.zig index 8573076..645129f 100644 --- a/src/Explorer.zig +++ b/src/Explorer.zig @@ -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"); @@ -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) }; @@ -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 ""; @@ -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) { @@ -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 |_| {} @@ -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 {}; } diff --git a/src/Transcript.zig b/src/Transcript.zig index ed01015..1fa6aa9 100644 --- a/src/Transcript.zig +++ b/src/Transcript.zig @@ -11,6 +11,7 @@ const Transcript = @This(); const std = @import("std"); +const builtin = @import("builtin"); const chat = @import("api.zig"); gpa: std.mem.Allocator, @@ -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 }); @@ -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 } }); diff --git a/src/api.zig b/src/api.zig index 4dbe869..068675a 100644 --- a/src/api.zig +++ b/src/api.zig @@ -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 }`, @@ -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, diff --git a/src/store.zig b/src/store.zig new file mode 100644 index 0000000..ad2d19c --- /dev/null +++ b/src/store.zig @@ -0,0 +1,54 @@ +//! Writing a conversation down. A client that holds its own history (`api.History.client`) has +//! nothing written for it: no transcript, no images, no folder: the chat is only the window it +//! has loaded. std-only, so it is tested without a window. +const std = @import("std"); +const builtin = @import("builtin"); +const api = @import("api.zig"); +const Transcript = @import("Transcript.zig"); + +/// 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}); +} + +/// Write `transcript` to `path` and its images beside it, unless the client keeps the history. +pub fn write(gpa: std.mem.Allocator, io: std.Io, history: api.History, path: []const u8, transcript: *const Transcript, header: Transcript.Header) !void { + // The web has no disk either: whoever holds the history there is the client. + if (builtin.os.tag == .freestanding or history == .client) return; + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + const images = try imagesDir(gpa, path); + defer gpa.free(images); + try transcript.save(&out.writer, header, images, io); + if (std.fs.path.dirname(path)) |d| std.Io.Dir.cwd().createDirPath(io, d) catch {}; + try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = out.written() }); +} + +test "a chat is written where it is kept, and nowhere for a client that keeps its own history" { + const gpa = std.testing.allocator; + var io_impl: std.Io.Threaded = .init_single_threaded; + const io = io_impl.io(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try tmp.dir.realPathFileAlloc(io, ".", gpa); + defer gpa.free(root); + + var t: Transcript = .{ .gpa = gpa }; + defer t.deinit(); + _ = try t.post(.person, &.{.{ .text = "Hello?" }}); + _ = try t.post(.other, &.{.{ .image = .{ .mime = "image/png", .bytes = "not really a png" } }}); + const header: Transcript.Header = .{ .client = "sample", .title = "Hello?", .@"resume" = "" }; + + const kept = try std.fs.path.join(gpa, &.{ root, "chats", "sample", "a.chat" }); + defer gpa.free(kept); + const held = try std.fs.path.join(gpa, &.{ root, "chats", "held", "a.chat" }); + defer gpa.free(held); + + try write(gpa, io, .client, held, &t, header); + try std.testing.expectError(error.FileNotFound, tmp.dir.access(io, "chats/held", .{})); + + try write(gpa, io, .chat, kept, &t, header); + try tmp.dir.access(io, "chats/sample/a.chat", .{}); + try tmp.dir.access(io, "chats/sample/a.images", .{}); +}