162 lines
7.0 KiB
Rust
162 lines
7.0 KiB
Rust
//! The seam between the chat panel and whatever answers it. The panel (conversation, sign-in,
|
|
//! approvals, turns, undo, the tool gate) only talks to a [`ChatBackend`]; the first backend is
|
|
//! [`crate::agent_chat::acp_client::AcpBackend`], which drives an agent the user already has over
|
|
//! the Agent Client Protocol. Another backend (one that calls a model API itself, say) slots in
|
|
//! by implementing the trait and sending its tool calls into [`StartContext::tools`]: they then
|
|
//! pass the same gate, count against the same per-turn limit and undo as one step, exactly like
|
|
//! the ACP agent's.
|
|
|
|
use serde_json::Value;
|
|
|
|
pub use super::acp::{AuthMethod, PermissionOption, ToolStatus};
|
|
|
|
/// One part of a message to the agent.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum PromptPart {
|
|
Text(String),
|
|
/// A picture of the design (PNG), sent only to agents that take images.
|
|
Image {
|
|
mime: String,
|
|
base64: String,
|
|
},
|
|
}
|
|
|
|
/// What a backend reports to the panel.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum Event {
|
|
/// Ready for a message. `agent`: its name and version; `images`: it takes pictures.
|
|
Ready { agent: String, images: bool },
|
|
/// Text it streams (`thought`: its reasoning, shown dimmed).
|
|
Text { thought: bool, text: String },
|
|
/// A new tool call, or news about one.
|
|
Tool { id: String, title: Option<String>, kind: Option<String>, status: Option<ToolStatus>, output: Option<String> },
|
|
/// Its plan, replaced whole: each entry with its status.
|
|
Plan(Vec<(String, String)>),
|
|
/// It asks before running something of its own; answered with [`ChatBackend::answer_permission`].
|
|
Permission { key: u64, title: String, options: Vec<PermissionOption> },
|
|
/// It needs the user to sign in; `methods` are its own ways to do so.
|
|
AuthRequired { methods: Vec<AuthMethod> },
|
|
/// What it says about its login.
|
|
AuthStatus(String),
|
|
/// Its own modes (an ACP agent's `modes`: ask, accept edits, plan, …) and the current one.
|
|
Modes { current: String, available: Vec<(String, String)> },
|
|
/// Its models (empty `available`: only the current one changed).
|
|
Models { current: String, available: Vec<super::acp::ModelInfo> },
|
|
/// The message is answered (`reason`: why it stopped, empty for a normal end).
|
|
TurnEnded { reason: String },
|
|
/// Something failed; the backend is still running unless [`Event::Exited`] follows.
|
|
Error(String),
|
|
/// It ended; its last error output.
|
|
Exited { tail: Vec<String> },
|
|
}
|
|
|
|
/// What a backend gets to start.
|
|
pub struct StartContext {
|
|
/// Its working folder: an empty folder of SolidCraft's own, not the user's files.
|
|
pub cwd: std::path::PathBuf,
|
|
/// The MCP server that reaches this window (`solidcraft-cli mcp --connect` to the chat's own
|
|
/// endpoint), for backends that start MCP servers; `None` when it could not be set up.
|
|
pub bridge: Option<super::acp::McpBridge>,
|
|
/// Where tool calls run in process go, for backends that run their own tool loop.
|
|
pub tools: Option<std::sync::mpsc::Sender<crate::ControlRequest>>,
|
|
/// Wakes the window when something arrives.
|
|
pub wake: Box<dyn Fn() + Send + Sync>,
|
|
}
|
|
|
|
/// A chat backend. Methods never block the frame; replies arrive through [`ChatBackend::poll`].
|
|
pub trait ChatBackend {
|
|
/// What arrived since the last call.
|
|
fn poll(&mut self) -> Vec<Event>;
|
|
/// Send a message (the panel only sends one after [`Event::Ready`] and while no turn runs).
|
|
fn prompt(&mut self, parts: &[PromptPart]);
|
|
/// Stop the message being answered; open permission questions count as cancelled.
|
|
fn cancel(&mut self);
|
|
/// Start a fresh conversation on the running backend (no restart); [`Event::Ready`] follows.
|
|
/// A message being answered is cancelled first.
|
|
fn new_session(&mut self);
|
|
/// Answer an [`Event::Permission`] with one of its options (`None`: cancelled).
|
|
fn answer_permission(&mut self, key: u64, option: Option<&str>);
|
|
/// Ask the backend to sign in by itself with one of its methods (it opens the browser).
|
|
fn authenticate(&mut self, method: &str);
|
|
/// Use one of the models it reported in [`Event::Models`].
|
|
fn set_model(&mut self, _model: &str) {}
|
|
/// Switch to one of the modes it reported in [`Event::Modes`].
|
|
fn set_mode(&mut self, _mode: &str) {}
|
|
/// Its process, for status and tests.
|
|
fn pid(&self) -> Option<u32> {
|
|
None
|
|
}
|
|
/// End it and everything it started. `wait`: block until done (quitting).
|
|
fn shutdown(&mut self, wait: bool);
|
|
}
|
|
|
|
/// One HTTP request of the API backend (the desktop host sends it; nothing else in the chat
|
|
/// reaches the network).
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct HttpRequest {
|
|
pub method: &'static str,
|
|
pub url: String,
|
|
pub headers: Vec<(String, String)>,
|
|
pub body: Option<Vec<u8>>,
|
|
pub timeout_s: u64,
|
|
}
|
|
|
|
/// Sends an [`HttpRequest`]: the status and the body, or why it failed.
|
|
pub type HttpFn = std::sync::Arc<dyn Fn(&HttpRequest) -> Result<(u16, Vec<u8>), String> + Send + Sync>;
|
|
|
|
/// The system keychain (the desktop host's), for API keys: never in the preferences.
|
|
pub trait Secrets: Send + Sync {
|
|
fn get(&self, id: &str) -> Result<Option<String>, String>;
|
|
fn set(&self, id: &str, secret: &str) -> Result<(), String>;
|
|
fn delete(&self, id: &str) -> Result<(), String>;
|
|
}
|
|
|
|
/// Standard base64 with padding (RFC 4648), for pictures in messages.
|
|
pub fn base64(data: &[u8]) -> String {
|
|
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
|
|
for chunk in data.chunks(3) {
|
|
let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)];
|
|
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
|
|
for (i, shift) in [18u32, 12, 6, 0].into_iter().enumerate() {
|
|
if i <= chunk.len() {
|
|
out.push(char::from(T.get(((n >> shift) & 63) as usize).copied().unwrap_or(b'A')));
|
|
} else {
|
|
out.push('=');
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// A message's parts as ACP content blocks.
|
|
pub fn content_blocks(parts: &[PromptPart]) -> Vec<Value> {
|
|
parts
|
|
.iter()
|
|
.map(|p| match p {
|
|
PromptPart::Text(t) => serde_json::json!({"type": "text", "text": t}),
|
|
PromptPart::Image { mime, base64 } => serde_json::json!({"type": "image", "mimeType": mime, "data": base64}),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn base64_matches_the_rfc_examples() {
|
|
for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")]
|
|
{
|
|
assert_eq!(base64(i.as_bytes()), o);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pictures_go_as_image_blocks() {
|
|
let b = content_blocks(&[PromptPart::Text("hi".into()), PromptPart::Image { mime: "image/png".into(), base64: "AA==".into() }]);
|
|
assert_eq!(b[1]["type"], "image");
|
|
assert_eq!(b[1]["mimeType"], "image/png");
|
|
}
|
|
}
|