Agent chat: queue messages while the agent works, Send now, Stop, New chat, and a model picker (#1566, #1571) (#1566)
Thanks @bherbruck.
This commit is contained in:
1 parent
fdb059cfed
commit
baf44657c1
16 files changed
+1293
-33
No files matched your search
@@ -613,7 +613,6 @@
|
||||
"ui.agent_chat_undo_label": "Agenten-Chat: {message}",
|
||||
"ui.agent_chat_undone": "(rückgängig gemacht)",
|
||||
"ui.agent_chat_view_attached": "Ansicht angehängt",
|
||||
"ui.agent_chat_wait_turn": "Der Agent arbeitet noch an der letzten Nachricht: Warten Sie oder klicken Sie auf Abbrechen.",
|
||||
"ui.agent_cursor": "Agent-Cursor",
|
||||
"ui.agent_cursor_speed": "Agent-Cursor-Geschwindigkeit",
|
||||
"ui.ai_add": "Hinzufügen",
|
||||
|
||||
+15
-1
@@ -795,6 +795,7 @@
|
||||
"ui.agent_chat_detecting": "Looking for agents…",
|
||||
"ui.agent_chat_dock_left": "Dock on the left",
|
||||
"ui.agent_chat_dock_right": "Dock on the right",
|
||||
"ui.agent_chat_edit": "Edit",
|
||||
"ui.agent_chat_exited": "The agent stopped.",
|
||||
"ui.agent_chat_failed_start": "The agent could not start: {error}",
|
||||
"ui.agent_chat_intro": "Chat with a coding agent you already use, or a model endpoint you choose. It works on the open design through SolidCraft's tools: its changes show as it makes them and undo as one step per message. What it may do without asking is set in Preferences > AI.",
|
||||
@@ -802,6 +803,10 @@
|
||||
"ui.agent_chat_left": "Left",
|
||||
"ui.agent_chat_limit": "The agent reached the limit of {limit} changes for one message. Send another message to let it go on.",
|
||||
"ui.agent_chat_minimize": "Minimise to a strip",
|
||||
"ui.agent_chat_model_tip": "The model the agent uses. Your choice is remembered for this agent.",
|
||||
"ui.agent_chat_new": "New chat",
|
||||
"ui.agent_chat_new_confirm": "Start a new chat? The current one is cleared.",
|
||||
"ui.agent_chat_new_yes": "New chat",
|
||||
"ui.agent_chat_no_cli": "solidcraft-cli was not found next to the app, so the agent can't reach the design.",
|
||||
"ui.agent_chat_no_control": "The agent chat's endpoint did not start, so the agent can't reach the design.",
|
||||
"ui.agent_chat_no_images": "This agent doesn't take pictures",
|
||||
@@ -812,12 +817,21 @@
|
||||
"ui.agent_chat_permission": "The agent asks to run: {title}",
|
||||
"ui.agent_chat_pick": "Agent",
|
||||
"ui.agent_chat_placeholder": "Ask the agent to change the design…",
|
||||
"ui.agent_chat_placeholder_busy": "Queue a message for when the agent is done (Ctrl+Enter: send now)",
|
||||
"ui.agent_chat_privacy": "Nothing goes anywhere except to the agent you run, under your own account.",
|
||||
"ui.agent_chat_privacy_note": "SolidCraft sends nothing anywhere: the agent you run talks to its own service under your own account. SolidCraft never sees or stores a key or password.",
|
||||
"ui.agent_chat_queue": "Queue",
|
||||
"ui.agent_chat_queue_full": "Too many messages are waiting: remove one or wait for the agent.",
|
||||
"ui.agent_chat_queued": "Queued",
|
||||
"ui.agent_chat_queued_stopped": "Queued (stopped, not sent)",
|
||||
"ui.agent_chat_refresh": "Refresh",
|
||||
"ui.agent_chat_refused": "Not allowed for the agent: {reason}",
|
||||
"ui.agent_chat_remove": "Remove from the queue",
|
||||
"ui.agent_chat_right": "Right",
|
||||
"ui.agent_chat_save": "Save",
|
||||
"ui.agent_chat_send": "Send",
|
||||
"ui.agent_chat_send_now": "Send now",
|
||||
"ui.agent_chat_send_now_tip": "Stop the agent's current step and send this next (Ctrl+Enter)",
|
||||
"ui.agent_chat_side": "Agent chat side",
|
||||
"ui.agent_chat_signed_in": "signed in",
|
||||
"ui.agent_chat_state_auth": "Sign-in needed",
|
||||
@@ -828,12 +842,12 @@
|
||||
"ui.agent_chat_state_starting": "Starting…",
|
||||
"ui.agent_chat_stop": "Stop",
|
||||
"ui.agent_chat_stop_tip": "Stop the agent and everything it started",
|
||||
"ui.agent_chat_stop_turn_tip": "Stop the agent (Esc). Queued messages stay queued.",
|
||||
"ui.agent_chat_turn_limit": "Changes per message",
|
||||
"ui.agent_chat_undo_changes": "Undo these changes",
|
||||
"ui.agent_chat_undo_label": "Agent chat: {message}",
|
||||
"ui.agent_chat_undone": "(undone)",
|
||||
"ui.agent_chat_view_attached": "View attached",
|
||||
"ui.agent_chat_wait_turn": "The agent is still working on the last message: wait for it or press Cancel.",
|
||||
"ui.agent_cursor": "Agent cursor",
|
||||
"ui.agent_cursor_speed": "Agent cursor speed",
|
||||
"ui.ai_add": "Add",
|
||||
|
||||
+15
-1
@@ -795,6 +795,7 @@
|
||||
"ui.agent_chat_detecting": "エージェントを検索中…",
|
||||
"ui.agent_chat_dock_left": "左にドッキング",
|
||||
"ui.agent_chat_dock_right": "右にドッキング",
|
||||
"ui.agent_chat_edit": "編集",
|
||||
"ui.agent_chat_exited": "エージェントが停止しました。",
|
||||
"ui.agent_chat_failed_start": "エージェントを起動できませんでした: {error}",
|
||||
"ui.agent_chat_intro": "普段使っているコーディングエージェント、または選んだモデルのエンドポイントとチャットできます。SolidCraft のツールで開いているデザインを操作し、変更はその場で表示され、メッセージごとに1回で元に戻せます。確認なしで許可する範囲は「環境設定 > AI」で設定します。",
|
||||
@@ -802,6 +803,10 @@
|
||||
"ui.agent_chat_left": "左",
|
||||
"ui.agent_chat_limit": "エージェントが1メッセージあたりの変更上限 {limit} に達しました。続けさせるには、もう一度メッセージを送ってください。",
|
||||
"ui.agent_chat_minimize": "細いバーに最小化",
|
||||
"ui.agent_chat_model_tip": "エージェントが使うモデル。選択はこのエージェントに対して記憶されます。",
|
||||
"ui.agent_chat_new": "新しいチャット",
|
||||
"ui.agent_chat_new_confirm": "新しいチャットを始めますか? 現在のチャットは消去されます。",
|
||||
"ui.agent_chat_new_yes": "新しいチャット",
|
||||
"ui.agent_chat_no_cli": "アプリの隣に solidcraft-cli が見つからないため、エージェントはデザインにアクセスできません。",
|
||||
"ui.agent_chat_no_control": "エージェントチャットのエンドポイントが起動しなかったため、エージェントはデザインにアクセスできません。",
|
||||
"ui.agent_chat_no_images": "このエージェントは画像を受け付けません",
|
||||
@@ -812,12 +817,21 @@
|
||||
"ui.agent_chat_permission": "エージェントが実行の許可を求めています: {title}",
|
||||
"ui.agent_chat_pick": "エージェント",
|
||||
"ui.agent_chat_placeholder": "デザインの変更をエージェントに依頼…",
|
||||
"ui.agent_chat_placeholder_busy": "エージェントの作業が終わったら送るメッセージを入力(Ctrl+Enter: 今すぐ送信)",
|
||||
"ui.agent_chat_privacy": "あなたのアカウントで実行するエージェント以外には、何も送信されません。",
|
||||
"ui.agent_chat_privacy_note": "SolidCraft はどこにも何も送信しません。あなたが実行するエージェントが、あなた自身のアカウントでそのサービスと通信します。SolidCraft がキーやパスワードを見たり保存したりすることはありません。",
|
||||
"ui.agent_chat_queue": "待機に追加",
|
||||
"ui.agent_chat_queue_full": "待機中のメッセージが多すぎます。削除するか、エージェントの完了をお待ちください。",
|
||||
"ui.agent_chat_queued": "待機中",
|
||||
"ui.agent_chat_queued_stopped": "待機中(停止済み・未送信)",
|
||||
"ui.agent_chat_refresh": "再検索",
|
||||
"ui.agent_chat_refused": "エージェントには許可されていません: {reason}",
|
||||
"ui.agent_chat_remove": "待機から削除",
|
||||
"ui.agent_chat_right": "右",
|
||||
"ui.agent_chat_save": "保存",
|
||||
"ui.agent_chat_send": "送信",
|
||||
"ui.agent_chat_send_now": "今すぐ送信",
|
||||
"ui.agent_chat_send_now_tip": "エージェントの現在のステップを止めて、これを次に送信します(Ctrl+Enter)",
|
||||
"ui.agent_chat_side": "エージェントチャットの位置",
|
||||
"ui.agent_chat_signed_in": "サインイン済み",
|
||||
"ui.agent_chat_state_auth": "サインインが必要",
|
||||
@@ -828,12 +842,12 @@
|
||||
"ui.agent_chat_state_starting": "起動中…",
|
||||
"ui.agent_chat_stop": "停止",
|
||||
"ui.agent_chat_stop_tip": "エージェントと、それが起動したものをすべて停止します",
|
||||
"ui.agent_chat_stop_turn_tip": "エージェントを停止します(Esc)。待機中のメッセージは待機のままです。",
|
||||
"ui.agent_chat_turn_limit": "1メッセージあたりの変更数",
|
||||
"ui.agent_chat_undo_changes": "これらの変更を元に戻す",
|
||||
"ui.agent_chat_undo_label": "エージェントチャット: {message}",
|
||||
"ui.agent_chat_undone": "(元に戻しました)",
|
||||
"ui.agent_chat_view_attached": "ビューを添付済み",
|
||||
"ui.agent_chat_wait_turn": "エージェントはまだ前のメッセージを処理中です。待つか「キャンセル」を押してください。",
|
||||
"ui.agent_cursor": "エージェントカーソル",
|
||||
"ui.agent_cursor_speed": "エージェントカーソル速度",
|
||||
"ui.ai_add": "追加",
|
||||
|
||||
@@ -613,7 +613,6 @@
|
||||
"ui.agent_chat_undo_label": "에이전트 채팅: {message}",
|
||||
"ui.agent_chat_undone": "(취소됨)",
|
||||
"ui.agent_chat_view_attached": "뷰 첨부됨",
|
||||
"ui.agent_chat_wait_turn": "에이전트가 아직 이전 메시지를 처리 중입니다. 기다리거나 취소를 누르세요.",
|
||||
"ui.agent_cursor": "에이전트 커서",
|
||||
"ui.agent_cursor_speed": "에이전트 커서 속도",
|
||||
"ui.ai_add": "추가",
|
||||
|
||||
@@ -788,7 +788,6 @@
|
||||
"ui.agent_chat_undo_label": "Ajan sohbeti: {message}",
|
||||
"ui.agent_chat_undone": "(geri alındı)",
|
||||
"ui.agent_chat_view_attached": "Görünüm eklendi",
|
||||
"ui.agent_chat_wait_turn": "Ajan hâlâ son mesaj üzerinde çalışıyor: Bekleyin veya İptal'e tıklayın.",
|
||||
"ui.agent_cursor": "Ajan imleci",
|
||||
"ui.agent_cursor_speed": "Ajan imleci hızı",
|
||||
"ui.ai_add": "Ekle",
|
||||
|
||||
+15
-1
@@ -795,6 +795,7 @@
|
||||
"ui.agent_chat_detecting": "正在查找智能体…",
|
||||
"ui.agent_chat_dock_left": "停靠在左侧",
|
||||
"ui.agent_chat_dock_right": "停靠在右侧",
|
||||
"ui.agent_chat_edit": "编辑",
|
||||
"ui.agent_chat_exited": "智能体已停止。",
|
||||
"ui.agent_chat_failed_start": "智能体无法启动:{error}",
|
||||
"ui.agent_chat_intro": "与你已在使用的编程智能体或你选择的模型端点聊天。它通过 SolidCraft 的工具处理打开的设计:更改即时显示,每条消息的更改可一步撤销。无需询问即可执行的范围在“首选项 > AI”中设置。",
|
||||
@@ -802,6 +803,10 @@
|
||||
"ui.agent_chat_left": "左侧",
|
||||
"ui.agent_chat_limit": "智能体已达到每条消息 {limit} 项更改的上限。再发送一条消息可让它继续。",
|
||||
"ui.agent_chat_minimize": "最小化为细条",
|
||||
"ui.agent_chat_model_tip": "智能体使用的模型。你的选择会为此智能体记住。",
|
||||
"ui.agent_chat_new": "新对话",
|
||||
"ui.agent_chat_new_confirm": "开始新对话?当前对话将被清除。",
|
||||
"ui.agent_chat_new_yes": "新对话",
|
||||
"ui.agent_chat_no_cli": "在应用旁边未找到 solidcraft-cli,因此智能体无法访问设计。",
|
||||
"ui.agent_chat_no_control": "智能体聊天的端点未能启动,因此智能体无法访问设计。",
|
||||
"ui.agent_chat_no_images": "该智能体不接收图片",
|
||||
@@ -812,12 +817,21 @@
|
||||
"ui.agent_chat_permission": "智能体请求运行:{title}",
|
||||
"ui.agent_chat_pick": "智能体",
|
||||
"ui.agent_chat_placeholder": "让智能体修改设计…",
|
||||
"ui.agent_chat_placeholder_busy": "输入消息,待智能体完成后发送(Ctrl+Enter:立即发送)",
|
||||
"ui.agent_chat_privacy": "除了你以自己的账户运行的智能体之外,不会向任何地方发送任何内容。",
|
||||
"ui.agent_chat_privacy_note": "SolidCraft 不向任何地方发送任何内容:你运行的智能体以你自己的账户与它自己的服务通信。SolidCraft 从不查看或保存任何密钥或密码。",
|
||||
"ui.agent_chat_queue": "加入队列",
|
||||
"ui.agent_chat_queue_full": "等待中的消息太多:请删除一条,或等待智能体完成。",
|
||||
"ui.agent_chat_queued": "已排队",
|
||||
"ui.agent_chat_queued_stopped": "已排队(已停止,未发送)",
|
||||
"ui.agent_chat_refresh": "刷新",
|
||||
"ui.agent_chat_refused": "不允许智能体执行:{reason}",
|
||||
"ui.agent_chat_remove": "从队列中移除",
|
||||
"ui.agent_chat_right": "右侧",
|
||||
"ui.agent_chat_save": "保存",
|
||||
"ui.agent_chat_send": "发送",
|
||||
"ui.agent_chat_send_now": "立即发送",
|
||||
"ui.agent_chat_send_now_tip": "停止智能体当前的步骤,并接着发送这条消息(Ctrl+Enter)",
|
||||
"ui.agent_chat_side": "智能体聊天位置",
|
||||
"ui.agent_chat_signed_in": "已登录",
|
||||
"ui.agent_chat_state_auth": "需要登录",
|
||||
@@ -828,12 +842,12 @@
|
||||
"ui.agent_chat_state_starting": "正在启动…",
|
||||
"ui.agent_chat_stop": "停止",
|
||||
"ui.agent_chat_stop_tip": "停止智能体及其启动的所有进程",
|
||||
"ui.agent_chat_stop_turn_tip": "停止智能体(Esc)。排队的消息仍保持排队。",
|
||||
"ui.agent_chat_turn_limit": "每条消息的更改数",
|
||||
"ui.agent_chat_undo_changes": "撤销这些更改",
|
||||
"ui.agent_chat_undo_label": "智能体聊天:{message}",
|
||||
"ui.agent_chat_undone": "(已撤销)",
|
||||
"ui.agent_chat_view_attached": "已附加视图",
|
||||
"ui.agent_chat_wait_turn": "智能体仍在处理上一条消息:请等待或点击取消。",
|
||||
"ui.agent_cursor": "代理光标",
|
||||
"ui.agent_cursor_speed": "代理光标速度",
|
||||
"ui.ai_add": "添加",
|
||||
|
||||
@@ -115,6 +115,40 @@ pub fn modes(session: &Value) -> Option<(String, Vec<(String, String)>)> {
|
||||
(!available.is_empty()).then_some((current, available))
|
||||
}
|
||||
|
||||
/// One model an agent offers.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ModelInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// The agent's models in a `session/new` or `session/load` reply (`models`: `currentModelId` and
|
||||
/// `availableModels: [{modelId, name, description?}]`): the current one and each model. `None`
|
||||
/// when the agent reports none.
|
||||
pub fn models(session: &Value) -> Option<(String, Vec<ModelInfo>)> {
|
||||
let m = session.get("models")?;
|
||||
let current = m.get("currentModelId").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let available: Vec<ModelInfo> = m
|
||||
.get("availableModels")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.take(200)
|
||||
.filter_map(|x| {
|
||||
let id = x.get("modelId").or_else(|| x.get("id"))?.as_str()?.to_string();
|
||||
let name = x.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()).unwrap_or(&id).to_string();
|
||||
let description = x.get("description").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
Some(ModelInfo { id, name, description })
|
||||
})
|
||||
.collect();
|
||||
(!available.is_empty()).then_some((current, available))
|
||||
}
|
||||
|
||||
/// `session/set_model` parameters.
|
||||
pub fn set_model_params(session: &str, model: &str) -> Value {
|
||||
json!({"sessionId": session, "modelId": model})
|
||||
}
|
||||
|
||||
/// Whether the agent takes pictures in a message (`agentCapabilities.promptCapabilities.image`).
|
||||
pub fn takes_images(init: &Value) -> bool {
|
||||
init.pointer("/agentCapabilities/promptCapabilities/image").and_then(Value::as_bool).unwrap_or(false)
|
||||
@@ -245,6 +279,8 @@ pub enum Incoming {
|
||||
AuthStatus(String),
|
||||
/// The agent switched to another of its modes.
|
||||
ModeChanged(String),
|
||||
/// The agent switched to another model.
|
||||
ModelChanged(String),
|
||||
/// A request we don't serve (file system, terminal, …): answer "method not found".
|
||||
Unsupported { rpc: Value, method: String },
|
||||
/// Anything else (mode and command lists, usage): nothing to show.
|
||||
@@ -351,6 +387,10 @@ fn update(u: &Value) -> Incoming {
|
||||
Some(m) => Incoming::ModeChanged(m),
|
||||
None => Incoming::Ignored,
|
||||
},
|
||||
Some("current_model_update") => match s("currentModelId").or_else(|| s("modelId")) {
|
||||
Some(m) => Incoming::ModelChanged(m),
|
||||
None => Incoming::Ignored,
|
||||
},
|
||||
Some("plan") => Incoming::Plan(
|
||||
u.get("entries")
|
||||
.and_then(Value::as_array)
|
||||
@@ -496,4 +536,23 @@ mod tests {
|
||||
let Incoming::Tool { output: Some(o), .. } = update(&u) else { panic!() };
|
||||
assert!(o.len() <= MAX_OUTPUT + 3 && o.ends_with('…'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_models_of_a_session_reply_are_read_like_its_modes() {
|
||||
let v = json!({"sessionId": "s", "models": {"currentModelId": "sonnet", "availableModels": [
|
||||
{"modelId": "opus", "name": "Opus", "description": "Most capable"},
|
||||
{"modelId": "sonnet", "name": "Sonnet"},
|
||||
{"modelId": "haiku"},
|
||||
{"name": "no id"}]}});
|
||||
let (current, list) = models(&v).unwrap();
|
||||
assert_eq!(current, "sonnet");
|
||||
let names: Vec<_> = list.iter().map(|m| (m.id.as_str(), m.name.as_str(), m.description.as_str())).collect();
|
||||
assert_eq!(names, [("opus", "Opus", "Most capable"), ("sonnet", "Sonnet", ""), ("haiku", "haiku", "")]);
|
||||
// An agent without models, or with an empty list, reports none.
|
||||
assert!(models(&json!({"sessionId": "s"})).is_none());
|
||||
assert!(models(&json!({"models": {"currentModelId": "x", "availableModels": []}})).is_none());
|
||||
let changed = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"current_model_update","currentModelId":"haiku"}}}"#;
|
||||
assert_eq!(parse(changed), Some(Incoming::ModelChanged("haiku".into())));
|
||||
assert_eq!(set_model_params("s", "opus"), json!({"sessionId": "s", "modelId": "opus"}));
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ enum Pending {
|
||||
Prompt,
|
||||
Authenticate,
|
||||
SetMode,
|
||||
SetModel,
|
||||
}
|
||||
|
||||
/// Variables the agent doesn't inherit: a control token set for the app's own `--control` port
|
||||
@@ -90,6 +91,9 @@ impl AcpBackend {
|
||||
if let Some((current, available)) = acp::modes(&v) {
|
||||
out.push(Event::Modes { current, available });
|
||||
}
|
||||
if let Some((current, available)) = acp::models(&v) {
|
||||
out.push(Event::Models { current, available });
|
||||
}
|
||||
out.push(Event::Ready { agent: self.agent.clone(), images: self.images });
|
||||
}
|
||||
None => out.push(Event::Error("session/new: no sessionId".into())),
|
||||
@@ -100,7 +104,7 @@ impl AcpBackend {
|
||||
}
|
||||
// Signed in: try the session again.
|
||||
(Pending::Authenticate, Ok(_)) => self.new_session(),
|
||||
(Pending::SetMode, Ok(_)) => {}
|
||||
(Pending::SetMode | Pending::SetModel, Ok(_)) => {}
|
||||
(what, Err((code, message))) => {
|
||||
let auth = code == acp::AUTH_REQUIRED || message.to_ascii_lowercase().contains("auth");
|
||||
if auth {
|
||||
@@ -128,6 +132,7 @@ impl AcpBackend {
|
||||
}
|
||||
Incoming::AuthStatus(s) => out.push(Event::AuthStatus(s)),
|
||||
Incoming::ModeChanged(m) => out.push(Event::Modes { current: m, available: Vec::new() }),
|
||||
Incoming::ModelChanged(m) => out.push(Event::Models { current: m, available: Vec::new() }),
|
||||
Incoming::Unsupported { rpc, method } => {
|
||||
self.proc.send(&acp::reply_error(rpc, acp::METHOD_NOT_FOUND, &format!("SolidCraft does not serve `{method}`")));
|
||||
}
|
||||
@@ -178,6 +183,14 @@ impl ChatBackend for AcpBackend {
|
||||
}
|
||||
}
|
||||
|
||||
fn new_session(&mut self) {
|
||||
if self.pending.values().any(|p| *p == Pending::Prompt) {
|
||||
self.cancel();
|
||||
}
|
||||
self.session = None;
|
||||
self.new_session();
|
||||
}
|
||||
|
||||
fn answer_permission(&mut self, key: u64, option: Option<&str>) {
|
||||
if let Some(rpc) = self.permissions.remove(&key) {
|
||||
self.proc.send(&acp::permission_reply(rpc, option));
|
||||
@@ -194,6 +207,12 @@ impl ChatBackend for AcpBackend {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_model(&mut self, model: &str) {
|
||||
if let Some(s) = self.session.clone() {
|
||||
self.request(Pending::SetModel, "session/set_model", acp::set_model_params(&s, model));
|
||||
}
|
||||
}
|
||||
|
||||
fn pid(&self) -> Option<u32> {
|
||||
self.proc.pid()
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::acp::ModelInfo;
|
||||
use super::backend::{ChatBackend, Event, HttpFn, HttpRequest, PromptPart, StartContext, ToolStatus};
|
||||
use super::process::{AgentProcess, FromAgent, ProcessHandle};
|
||||
|
||||
@@ -27,6 +28,8 @@ const MAX_HISTORY: usize = 240;
|
||||
const REQUEST_TIMEOUT_S: u64 = 300;
|
||||
/// How long one tool call may take (it may wait for the user's Allow).
|
||||
const TOOL_TIMEOUT: Duration = Duration::from_secs(330);
|
||||
/// How often a waiting request or tool call looks at the cancel flag.
|
||||
const CANCEL_POLL: Duration = Duration::from_millis(40);
|
||||
|
||||
/// Tools the model is not offered.
|
||||
const HIDDEN_TOOLS: &[&str] = &["crash_reports"];
|
||||
@@ -41,6 +44,8 @@ pub struct ApiConfig {
|
||||
|
||||
enum Cmd {
|
||||
Prompt(Vec<PromptPart>),
|
||||
NewChat,
|
||||
SetModel(String),
|
||||
}
|
||||
|
||||
pub struct ApiBackend {
|
||||
@@ -90,6 +95,18 @@ impl ChatBackend for ApiBackend {
|
||||
fn cancel(&mut self) {
|
||||
self.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
fn set_model(&mut self, model: &str) {
|
||||
if let Some(c) = &self.cmds {
|
||||
let _ = c.send(Cmd::SetModel(model.to_string()));
|
||||
}
|
||||
}
|
||||
fn new_session(&mut self) {
|
||||
// A running turn is cancelled; the worker resets the history once it has ended.
|
||||
self.cancel.store(true, Ordering::Relaxed);
|
||||
if let Some(c) = &self.cmds {
|
||||
let _ = c.send(Cmd::NewChat);
|
||||
}
|
||||
}
|
||||
fn answer_permission(&mut self, _key: u64, _option: Option<&str>) {}
|
||||
fn authenticate(&mut self, _method: &str) {}
|
||||
fn pid(&self) -> Option<u32> {
|
||||
@@ -134,9 +151,25 @@ impl Worker {
|
||||
}
|
||||
self.history.push(json!({"role": "system", "content": super::acp::SYSTEM_NOTE}));
|
||||
self.emit(Event::Ready { agent: format!("{} · {}", self.cfg.name, self.cfg.model), images: true });
|
||||
// The endpoint's models for the picker; an endpoint that does not list them shows none.
|
||||
if let Ok(list) = model_list(&self.cfg.http, &self.cfg.base_url, self.cfg.key.as_deref()) {
|
||||
let mut available = list;
|
||||
if !self.cfg.model.is_empty() && !available.iter().any(|m| m.id == self.cfg.model) {
|
||||
available.insert(0, ModelInfo { id: self.cfg.model.clone(), name: self.cfg.model.clone(), description: String::new() });
|
||||
}
|
||||
if !available.is_empty() {
|
||||
self.emit(Event::Models { current: self.cfg.model.clone(), available });
|
||||
}
|
||||
}
|
||||
for cmd in cmds {
|
||||
match cmd {
|
||||
Cmd::Prompt(parts) => self.turn(parts),
|
||||
Cmd::SetModel(m) => self.cfg.model = m,
|
||||
Cmd::NewChat => {
|
||||
self.history.truncate(1);
|
||||
self.cancel.store(false, Ordering::Relaxed);
|
||||
self.emit(Event::Ready { agent: format!("{} · {}", self.cfg.name, self.cfg.model), images: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(mut m) = self.mcp.take() {
|
||||
@@ -152,7 +185,12 @@ impl Worker {
|
||||
m.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
|
||||
let deadline = std::time::Instant::now() + TOOL_TIMEOUT;
|
||||
loop {
|
||||
let left = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
// Wait in short slices so Stop and Send now do not wait for a slow tool: its late
|
||||
// answer is skipped later (its id no longer matches).
|
||||
if self.cancel.load(Ordering::Relaxed) {
|
||||
return Err("cancelled by the user".into());
|
||||
}
|
||||
let left = deadline.saturating_duration_since(std::time::Instant::now()).min(CANCEL_POLL);
|
||||
match m.from_agent.recv_timeout(left) {
|
||||
Ok(FromAgent::Line(l)) => {
|
||||
let Ok(v) = serde_json::from_str::<Value>(l.trim()) else { continue };
|
||||
@@ -165,6 +203,7 @@ impl Worker {
|
||||
return Ok(v.get("result").cloned().unwrap_or(Value::Null));
|
||||
}
|
||||
Ok(FromAgent::Exited(_)) => return Err("the SolidCraft tools stopped".into()),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) if std::time::Instant::now() < deadline => {}
|
||||
Err(_) => return Err("the SolidCraft tools did not answer".into()),
|
||||
}
|
||||
}
|
||||
@@ -196,12 +235,25 @@ impl Worker {
|
||||
self.history.remove(1);
|
||||
}
|
||||
for _ in 0..MAX_REQUESTS_PER_TURN {
|
||||
// The history as of here is complete (every tool call answered): a cancelled request
|
||||
// goes back to it, so no half-finished round is left to confuse the next message.
|
||||
let sound = self.history.len();
|
||||
if self.cancel.load(Ordering::Relaxed) {
|
||||
self.emit(Event::TurnEnded { reason: "cancelled".into() });
|
||||
return;
|
||||
}
|
||||
let reply = match self.request() {
|
||||
Ok(_) if self.cancel.load(Ordering::Relaxed) => {
|
||||
self.history.truncate(sound);
|
||||
self.emit(Event::TurnEnded { reason: "cancelled".into() });
|
||||
return;
|
||||
}
|
||||
Ok(r) => r,
|
||||
Err(_) if self.cancel.load(Ordering::Relaxed) => {
|
||||
self.history.truncate(sound);
|
||||
self.emit(Event::TurnEnded { reason: "cancelled".into() });
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
self.emit(Event::Error(e));
|
||||
self.emit(Event::TurnEnded { reason: "error".into() });
|
||||
@@ -282,7 +334,23 @@ impl Worker {
|
||||
body: Some(body.to_string().into_bytes()),
|
||||
timeout_s: REQUEST_TIMEOUT_S,
|
||||
};
|
||||
let (status, bytes) = (self.cfg.http)(&req)?;
|
||||
// The request runs on a thread of its own so that Stop does not wait for the model: a
|
||||
// cancelled one is abandoned (the host's own timeout ends it) and its answer dropped.
|
||||
let (tx, rx) = channel();
|
||||
let http = self.cfg.http.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(http(&req));
|
||||
});
|
||||
let (status, bytes) = loop {
|
||||
if self.cancel.load(Ordering::Relaxed) {
|
||||
return Err("cancelled by the user".into());
|
||||
}
|
||||
match rx.recv_timeout(CANCEL_POLL) {
|
||||
Ok(r) => break r?,
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(_) => return Err("the request ended without an answer".into()),
|
||||
}
|
||||
};
|
||||
let v: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
|
||||
if status != 200 {
|
||||
return Err(http_error(status, &v));
|
||||
@@ -413,6 +481,32 @@ fn tool_title(name: &str, args: &Value) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET {base_url}/models`: the model ids, in the order the endpoint lists them.
|
||||
fn model_ids(http: &HttpFn, base_url: &str, headers: Vec<(String, String)>) -> Result<Vec<String>, String> {
|
||||
let req = HttpRequest { method: "GET", url: format!("{base_url}/models"), headers, body: None, timeout_s: 15 };
|
||||
let (status, bytes) = http(&req)?;
|
||||
let v: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
|
||||
if status != 200 {
|
||||
return Err(http_error(status, &v));
|
||||
}
|
||||
Ok(v.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().take(500).filter_map(|m| m.get("id").and_then(Value::as_str).map(String::from)).collect())
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The endpoint's models for the picker.
|
||||
pub fn model_list(http: &HttpFn, base_url: &str, key: Option<&str>) -> Result<Vec<ModelInfo>, String> {
|
||||
let mut headers = Vec::new();
|
||||
if let Some(k) = key {
|
||||
if !super::agents::key_safe_for(base_url) {
|
||||
return Err(crate::i18n::tr("ui.ai_key_needs_https").to_string());
|
||||
}
|
||||
headers.push(("Authorization".to_string(), format!("Bearer {k}")));
|
||||
}
|
||||
Ok(model_ids(http, base_url, headers)?.into_iter().map(|id| ModelInfo { name: id.clone(), id, description: String::new() }).collect())
|
||||
}
|
||||
|
||||
/// Ask an endpoint for its models (Test connection): how many, and whether `model` is one.
|
||||
pub fn test_endpoint(http: &HttpFn, base_url: &str, key: Option<&str>, model: &str) -> Result<String, String> {
|
||||
let mut headers = Vec::new();
|
||||
@@ -422,14 +516,8 @@ pub fn test_endpoint(http: &HttpFn, base_url: &str, key: Option<&str>, model: &s
|
||||
}
|
||||
headers.push(("Authorization".to_string(), format!("Bearer {k}")));
|
||||
}
|
||||
let req = HttpRequest { method: "GET", url: format!("{base_url}/models"), headers, body: None, timeout_s: 15 };
|
||||
let (status, bytes) = http(&req)?;
|
||||
let v: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
|
||||
if status != 200 {
|
||||
return Err(http_error(status, &v));
|
||||
}
|
||||
let ids: Vec<&str> =
|
||||
v.get("data").and_then(Value::as_array).map(|a| a.iter().filter_map(|m| m.get("id").and_then(Value::as_str)).collect()).unwrap_or_default();
|
||||
let ids = model_ids(http, base_url, headers)?;
|
||||
let ids: Vec<&str> = ids.iter().map(String::as_str).collect();
|
||||
let found = model.is_empty() || ids.contains(&model);
|
||||
Ok(crate::i18n::format(if found { "ui.ai_test_ok" } else { "ui.ai_test_no_model" }, &[("count", &ids.len().to_string()), ("model", model)]))
|
||||
}
|
||||
@@ -469,6 +557,9 @@ mod tests {
|
||||
let seen = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
|
||||
let log = seen.clone();
|
||||
let http: HttpFn = Arc::new(move |r: &HttpRequest| {
|
||||
if r.method == "GET" {
|
||||
return Ok((200, b"{\"data\":[]}".to_vec()));
|
||||
}
|
||||
log.lock().unwrap().push(serde_json::from_slice(r.body.as_deref().unwrap_or(b"null")).unwrap());
|
||||
let a = answers.lock().unwrap().pop().unwrap_or(json!({}));
|
||||
Ok((200, a.to_string().into_bytes()))
|
||||
@@ -494,6 +585,138 @@ mod tests {
|
||||
b.shutdown(true);
|
||||
}
|
||||
|
||||
/// Cancel while a model request is in flight ends the turn at once (the request is
|
||||
/// abandoned, its late answer dropped), leaves no half-finished round in the history and
|
||||
/// the next message goes through.
|
||||
#[test]
|
||||
fn cancelling_a_request_ends_the_turn_and_the_next_message_still_works() {
|
||||
let (release_tx, release_rx) = channel::<()>();
|
||||
let release_rx = std::sync::Mutex::new(release_rx);
|
||||
let seen = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
|
||||
let log = seen.clone();
|
||||
let http: HttpFn = Arc::new(move |r: &HttpRequest| {
|
||||
if r.method == "GET" {
|
||||
return Ok((200, b"{\"data\":[]}".to_vec()));
|
||||
}
|
||||
let n = {
|
||||
let mut l = log.lock().unwrap();
|
||||
l.push(serde_json::from_slice(r.body.as_deref().unwrap_or(b"null")).unwrap());
|
||||
l.len()
|
||||
};
|
||||
if n == 1 {
|
||||
// The first request hangs until the test lets it go, then answers late.
|
||||
let _ = release_rx.lock().unwrap().recv_timeout(Duration::from_secs(20));
|
||||
return Ok((200, json!({"choices": [{"message": {"role": "assistant", "content": "Too late."}}]}).to_string().into_bytes()));
|
||||
}
|
||||
Ok((200, json!({"choices": [{"message": {"role": "assistant", "content": "Steered."}}]}).to_string().into_bytes()))
|
||||
});
|
||||
let cfg = ApiConfig { name: "Test".into(), base_url: "http://localhost:1/v1".into(), model: "m".into(), key: None, http };
|
||||
let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) };
|
||||
let mut b = ApiBackend::start(cfg, ctx).unwrap();
|
||||
let wait = |b: &mut ApiBackend, events: &mut Vec<Event>| {
|
||||
let start = std::time::Instant::now();
|
||||
while !events.iter().any(|e| matches!(e, Event::TurnEnded { .. })) && start.elapsed() < Duration::from_secs(10) {
|
||||
events.extend(b.poll());
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
};
|
||||
b.prompt(&[PromptPart::Text("long job".into())]);
|
||||
let start = std::time::Instant::now();
|
||||
while seen.lock().unwrap().is_empty() && start.elapsed() < Duration::from_secs(10) {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
b.cancel();
|
||||
let mut events = Vec::new();
|
||||
wait(&mut b, &mut events);
|
||||
assert!(start.elapsed() < Duration::from_secs(8), "the turn ended while the request still hung");
|
||||
assert!(matches!(events.last(), Some(Event::TurnEnded { reason }) if reason == "cancelled"), "{events:?}");
|
||||
assert!(!events.iter().any(|e| matches!(e, Event::Text { .. } | Event::Error(_))), "{events:?}");
|
||||
// The next message is answered, and the late answer of the first never shows up.
|
||||
b.prompt(&[PromptPart::Text("[interrupted] do this instead".into())]);
|
||||
let mut events = Vec::new();
|
||||
wait(&mut b, &mut events);
|
||||
let _ = release_tx.send(());
|
||||
assert!(events.iter().any(|e| matches!(e, Event::Text { text, .. } if text == "Steered.")), "{events:?}");
|
||||
assert!(matches!(events.last(), Some(Event::TurnEnded { reason }) if reason.is_empty()), "{events:?}");
|
||||
let sent = seen.lock().unwrap();
|
||||
let roles: Vec<&str> = sent[1]["messages"].as_array().unwrap().iter().map(|m| m["role"].as_str().unwrap_or("")).collect();
|
||||
assert_eq!(roles, ["system", "user", "user"], "no reply or tool call of the cancelled round is left");
|
||||
b.shutdown(true);
|
||||
}
|
||||
|
||||
/// New chat resets the model's history to the system note: the next request carries nothing
|
||||
/// of the conversation before it.
|
||||
#[test]
|
||||
fn a_new_chat_resets_the_history_to_the_system_note() {
|
||||
let seen = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
|
||||
let log = seen.clone();
|
||||
let http: HttpFn = Arc::new(move |r: &HttpRequest| {
|
||||
if r.method == "GET" {
|
||||
return Ok((200, b"{\"data\":[]}".to_vec()));
|
||||
}
|
||||
log.lock().unwrap().push(serde_json::from_slice(r.body.as_deref().unwrap_or(b"null")).unwrap());
|
||||
Ok((200, json!({"choices": [{"message": {"role": "assistant", "content": "ok"}}]}).to_string().into_bytes()))
|
||||
});
|
||||
let cfg = ApiConfig { name: "Test".into(), base_url: "http://localhost:1/v1".into(), model: "m".into(), key: None, http };
|
||||
let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) };
|
||||
let mut b = ApiBackend::start(cfg, ctx).unwrap();
|
||||
let run = |b: &mut ApiBackend, f: &dyn Fn(&mut ApiBackend), want: usize| {
|
||||
f(b);
|
||||
let mut events = Vec::new();
|
||||
let start = std::time::Instant::now();
|
||||
while events.iter().filter(|e| matches!(e, Event::TurnEnded { .. } | Event::Ready { .. })).count() < want
|
||||
&& start.elapsed() < Duration::from_secs(10)
|
||||
{
|
||||
events.extend(b.poll());
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
};
|
||||
run(&mut b, &|b| b.prompt(&[PromptPart::Text("one".into())]), 2);
|
||||
run(&mut b, &|b| b.new_session(), 1);
|
||||
run(&mut b, &|b| b.prompt(&[PromptPart::Text("two".into())]), 1);
|
||||
let sent = seen.lock().unwrap();
|
||||
let roles: Vec<&str> = sent[1]["messages"].as_array().unwrap().iter().map(|m| m["role"].as_str().unwrap_or("")).collect();
|
||||
assert_eq!(roles, ["system", "user"]);
|
||||
assert_eq!(sent[1]["messages"][1]["content"], "two");
|
||||
b.shutdown(true);
|
||||
}
|
||||
|
||||
/// The endpoint's `/models` fills the picker (with the current model first if it is not
|
||||
/// listed), and choosing one changes the model of the next request.
|
||||
#[test]
|
||||
fn the_endpoint_lists_models_and_a_choice_changes_the_next_request() {
|
||||
let seen = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
|
||||
let log = seen.clone();
|
||||
let http: HttpFn = Arc::new(move |r: &HttpRequest| {
|
||||
if r.method == "GET" {
|
||||
assert!(r.url.ends_with("/models"));
|
||||
return Ok((200, json!({"data": [{"id": "big"}, {"id": "small"}]}).to_string().into_bytes()));
|
||||
}
|
||||
log.lock().unwrap().push(serde_json::from_slice(r.body.as_deref().unwrap_or(b"null")).unwrap());
|
||||
Ok((200, json!({"choices": [{"message": {"role": "assistant", "content": "ok"}}]}).to_string().into_bytes()))
|
||||
});
|
||||
let cfg = ApiConfig { name: "Test".into(), base_url: "http://localhost:1/v1".into(), model: "local".into(), key: None, http };
|
||||
let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) };
|
||||
let mut b = ApiBackend::start(cfg, ctx).unwrap();
|
||||
let mut events = Vec::new();
|
||||
let start = std::time::Instant::now();
|
||||
while !events.iter().any(|e| matches!(e, Event::Models { .. })) && start.elapsed() < Duration::from_secs(10) {
|
||||
events.extend(b.poll());
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
let Some(Event::Models { current, available }) = events.iter().find(|e| matches!(e, Event::Models { .. })) else { panic!("{events:?}") };
|
||||
assert_eq!(current, "local");
|
||||
assert_eq!(available.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(), ["local", "big", "small"]);
|
||||
b.set_model("big");
|
||||
b.prompt(&[PromptPart::Text("hi".into())]);
|
||||
let start = std::time::Instant::now();
|
||||
while seen.lock().unwrap().is_empty() && start.elapsed() < Duration::from_secs(10) {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert_eq!(seen.lock().unwrap()[0]["model"], "big");
|
||||
b.shutdown(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_arguments_are_an_object_or_an_error() {
|
||||
assert_eq!(tool_arguments(None), Ok(json!({})));
|
||||
@@ -515,6 +738,9 @@ mod tests {
|
||||
let seen = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
|
||||
let log = seen.clone();
|
||||
let http: HttpFn = Arc::new(move |r: &HttpRequest| {
|
||||
if r.method == "GET" {
|
||||
return Ok((200, b"{\"data\":[]}".to_vec()));
|
||||
}
|
||||
log.lock().unwrap().push(serde_json::from_slice(r.body.as_deref().unwrap_or(b"null")).unwrap());
|
||||
Ok((200, answers.lock().unwrap().pop().unwrap_or(json!({})).to_string().into_bytes()))
|
||||
});
|
||||
|
||||
@@ -40,6 +40,8 @@ pub enum Event {
|
||||
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.
|
||||
@@ -69,10 +71,15 @@ pub trait ChatBackend {
|
||||
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.
|
||||
|
||||
@@ -145,6 +145,13 @@ struct Turn {
|
||||
label: String,
|
||||
}
|
||||
|
||||
/// A message typed while the agent works: shown in the transcript as "Queued" until it is sent.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Pending {
|
||||
pub text: String,
|
||||
pub image: bool,
|
||||
}
|
||||
|
||||
/// A request held for Allow or Deny.
|
||||
struct Held {
|
||||
id: u64,
|
||||
@@ -171,6 +178,25 @@ pub struct AgentChat {
|
||||
auth_status: Option<String>,
|
||||
/// A message typed before the agent was ready.
|
||||
queued: Option<(String, bool)>,
|
||||
/// Messages typed while the agent works, in order. They go as one prompt when the turn ends
|
||||
/// (or at once with Send now); after Stop they wait for Send.
|
||||
pub pending: Vec<Pending>,
|
||||
/// The queued message being edited, and its text so far.
|
||||
pub pending_edit: Option<(usize, String)>,
|
||||
/// Send now was pressed: the queue goes as soon as the cancelled turn has ended.
|
||||
steering: bool,
|
||||
/// The turn was stopped: its queue is not sent by itself.
|
||||
stopped: bool,
|
||||
/// Why the last turn ended.
|
||||
ended: Option<String>,
|
||||
/// New chat while a message ran: its late end is not reported.
|
||||
discard_end: bool,
|
||||
/// The mode to put back when the fresh session is ready.
|
||||
restore_mode: Option<String>,
|
||||
/// New chat is waiting for the user's yes (a message is running or queued).
|
||||
pub confirm_new: bool,
|
||||
/// Where the panel was drawn last (for its shortcuts).
|
||||
pub panel_rect: Option<egui::Rect>,
|
||||
/// The last message sent, kept to send again after a sign-in.
|
||||
last: Option<(String, bool)>,
|
||||
/// The command the running agent was started with.
|
||||
@@ -182,6 +208,13 @@ pub struct AgentChat {
|
||||
/// The agent's own modes, and the current one.
|
||||
modes: Vec<(String, String)>,
|
||||
mode: String,
|
||||
/// The models the agent offers (none: no picker), and the current one.
|
||||
pub models: Vec<acp::ModelInfo>,
|
||||
pub model: String,
|
||||
/// An API endpoint's models, kept per endpoint (its base URL) for the next connection.
|
||||
models_cache: std::collections::HashMap<String, Vec<acp::ModelInfo>>,
|
||||
/// The running API agent's endpoint (empty for other agents).
|
||||
running_endpoint: String,
|
||||
backend: Option<Box<dyn ChatBackend>>,
|
||||
turn: Option<Turn>,
|
||||
held: Vec<Held>,
|
||||
@@ -227,6 +260,7 @@ impl AgentChat {
|
||||
}
|
||||
|
||||
fn stop_backend(&mut self, wait: bool) {
|
||||
self.steering = false;
|
||||
if let Some(mut b) = self.backend.take() {
|
||||
b.cancel();
|
||||
b.shutdown(wait);
|
||||
@@ -275,6 +309,14 @@ impl AgentChat {
|
||||
self.images = images;
|
||||
self.state = Some(State::Ready);
|
||||
self.picking = Some(false);
|
||||
// Keep the mode across a new chat.
|
||||
if let Some(m) = self.restore_mode.take()
|
||||
&& self.modes.iter().any(|(id, _)| *id == m)
|
||||
&& let Some(b) = self.backend.as_mut()
|
||||
{
|
||||
b.set_mode(&m);
|
||||
self.mode = m;
|
||||
}
|
||||
}
|
||||
Event::Text { thought, text } => {
|
||||
if let Some(Item::Agent { thought: t, text: last }) = self.items.last_mut()
|
||||
@@ -320,6 +362,15 @@ impl AgentChat {
|
||||
}
|
||||
self.mode = current;
|
||||
}
|
||||
Event::Models { current, available } => {
|
||||
if !available.is_empty() {
|
||||
if !self.running_endpoint.is_empty() {
|
||||
self.models_cache.insert(self.running_endpoint.clone(), available.clone());
|
||||
}
|
||||
self.models = available;
|
||||
}
|
||||
self.model = current;
|
||||
}
|
||||
Event::AuthRequired { methods } => {
|
||||
self.auth = methods;
|
||||
self.state = Some(State::NeedsAuth);
|
||||
@@ -334,6 +385,10 @@ impl AgentChat {
|
||||
if self.state() == State::Busy {
|
||||
self.state = Some(State::Ready);
|
||||
}
|
||||
self.ended = Some(reason.clone());
|
||||
if std::mem::take(&mut self.discard_end) {
|
||||
return Some(Outcome::TurnEnd);
|
||||
}
|
||||
if !matches!(reason.as_str(), "" | "auth" | "error") {
|
||||
self.notice(format!("({reason})"), false);
|
||||
}
|
||||
@@ -347,6 +402,7 @@ impl AgentChat {
|
||||
}
|
||||
Event::Exited { tail } => {
|
||||
self.state = Some(State::Exited);
|
||||
self.steering = false;
|
||||
let last: Vec<String> = tail.into_iter().rev().take(6).collect::<Vec<_>>().into_iter().rev().collect();
|
||||
let detail = if last.is_empty() { String::new() } else { format!("\n{}", last.join("\n")) };
|
||||
self.notice(format!("{}{detail}", tr("ui.agent_chat_exited")), true);
|
||||
@@ -468,6 +524,14 @@ pub fn connect(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
chat.running_id = entry.id.clone();
|
||||
chat.modes.clear();
|
||||
chat.mode.clear();
|
||||
chat.models.clear();
|
||||
chat.model.clear();
|
||||
chat.running_endpoint = if entry.kind == agents::Kind::Api { entry.base_url.clone() } else { String::new() };
|
||||
// An endpoint's models from the last time, until the new list arrives.
|
||||
if let Some(list) = chat.models_cache.get(&chat.running_endpoint) {
|
||||
chat.models = list.clone();
|
||||
chat.model = entry.model.clone();
|
||||
}
|
||||
chat.state = Some(State::Starting);
|
||||
chat.auth.clear();
|
||||
chat.auth_status = None;
|
||||
@@ -496,17 +560,44 @@ pub fn shutdown(app: &mut SolveApp) {
|
||||
app.agent_chat.stop_backend(true);
|
||||
}
|
||||
|
||||
/// Send what the user typed: now if the agent is ready, else once it is (starting it if needed).
|
||||
/// Most messages waiting in the queue.
|
||||
const MAX_PENDING: usize = 20;
|
||||
/// Said to the agent before a message that interrupted it, so it knows its last step may be
|
||||
/// incomplete.
|
||||
const INTERRUPT_NOTE: &str = "[The user interrupted your previous step.]";
|
||||
|
||||
/// Send what the user typed: now if the agent is ready, queued behind the running message if it
|
||||
/// is working, else once it is ready (starting it if needed).
|
||||
pub fn send(app: &mut SolveApp, ctx: &egui::Context, text: String) {
|
||||
let text: String = text.chars().take(MAX_PROMPT).collect();
|
||||
if text.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
let image = app.agent_chat.attach_view && app.agent_chat.images;
|
||||
if app.agent_chat.state() == State::Busy {
|
||||
let chat = &mut app.agent_chat;
|
||||
if chat.pending.len() >= MAX_PENDING {
|
||||
chat.notice(tr("ui.agent_chat_queue_full"), false);
|
||||
} else {
|
||||
chat.pending.push(Pending { text, image });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Messages left waiting by Stop go first, in one prompt with this one.
|
||||
if !app.agent_chat.pending.is_empty() {
|
||||
app.agent_chat.pending.push(Pending { text, image });
|
||||
send_queue(app, ctx, false);
|
||||
return;
|
||||
}
|
||||
submit(app, ctx, text, image, false);
|
||||
}
|
||||
|
||||
/// Put `text` in the conversation and send it (`interrupted`: the agent is told it cut in on its
|
||||
/// previous step).
|
||||
fn submit(app: &mut SolveApp, ctx: &egui::Context, text: String, image: bool, interrupted: bool) {
|
||||
app.agent_chat.push(Item::User { text: text.clone(), image });
|
||||
match app.agent_chat.state() {
|
||||
State::Ready => start_turn(app, text, image),
|
||||
State::Busy => app.agent_chat.notice(tr("ui.agent_chat_wait_turn"), false),
|
||||
State::Ready => start_turn_with(app, text, image, interrupted.then_some(INTERRUPT_NOTE)),
|
||||
s => {
|
||||
app.agent_chat.queued = Some((text, image));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -519,8 +610,105 @@ pub fn send(app: &mut SolveApp, ctx: &egui::Context, text: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Send the queued messages as one prompt, in order.
|
||||
fn send_queue(app: &mut SolveApp, ctx: &egui::Context, interrupted: bool) {
|
||||
let chat = &mut app.agent_chat;
|
||||
chat.pending_edit = None;
|
||||
if chat.pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
let all = std::mem::take(&mut chat.pending);
|
||||
let image = all.iter().any(|p| p.image);
|
||||
let text = all.into_iter().map(|p| p.text).collect::<Vec<_>>().join("\n\n");
|
||||
let text: String = text.chars().take(MAX_PROMPT).collect();
|
||||
submit(app, ctx, text, image, interrupted);
|
||||
}
|
||||
|
||||
/// Send now: stop the running step and send the queue as the next prompt once the agent has said
|
||||
/// it stopped (its note tells the agent it was interrupted). With nothing running, sends the queue.
|
||||
pub fn send_now(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if app.agent_chat.pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
if app.agent_chat.state() == State::Busy {
|
||||
app.agent_chat.steering = true;
|
||||
app.agent_chat.stopped = false;
|
||||
interrupt(app);
|
||||
} else {
|
||||
send_queue(app, ctx, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// New chat: stop what runs (as Stop does), clear the conversation and the queue, and start a
|
||||
/// fresh session on the same agent (no restart); the agent and its mode stay, the design's undo
|
||||
/// history is untouched.
|
||||
pub fn new_chat(app: &mut SolveApp) {
|
||||
let running = app.agent_chat.state() == State::Busy;
|
||||
if running {
|
||||
cancel(app);
|
||||
}
|
||||
decline_all(app, "the chat was cleared");
|
||||
end_turn(app);
|
||||
let chat = &mut app.agent_chat;
|
||||
chat.confirm_new = false;
|
||||
chat.items.clear();
|
||||
chat.pending.clear();
|
||||
chat.pending_edit = None;
|
||||
chat.queued = None;
|
||||
chat.last = None;
|
||||
chat.steering = false;
|
||||
chat.stopped = false;
|
||||
chat.ended = None;
|
||||
chat.discard_end = running;
|
||||
if matches!(chat.state(), State::Ready | State::Busy) {
|
||||
chat.restore_mode = Some(chat.mode.clone()).filter(|m| !m.is_empty());
|
||||
chat.state = Some(State::Starting);
|
||||
if let Some(b) = chat.backend.as_mut() {
|
||||
b.new_session();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether New chat would throw something away that the user may want (so it asks first).
|
||||
pub fn new_chat_needs_confirm(app: &SolveApp) -> bool {
|
||||
app.agent_chat.state() == State::Busy || !app.agent_chat.pending.is_empty()
|
||||
}
|
||||
|
||||
/// Take a queued message out of the queue.
|
||||
pub fn remove_pending(app: &mut SolveApp, index: usize) {
|
||||
let chat = &mut app.agent_chat;
|
||||
if index < chat.pending.len() {
|
||||
chat.pending.remove(index);
|
||||
}
|
||||
chat.pending_edit = None;
|
||||
}
|
||||
|
||||
/// The turn ended: the queue goes on its own after a normal end, and after a Send now; not after
|
||||
/// Stop, a sign-in request or an error.
|
||||
fn after_turn(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let reason = app.agent_chat.ended.take().unwrap_or_default();
|
||||
let steering = std::mem::take(&mut app.agent_chat.steering);
|
||||
if app.agent_chat.state() != State::Ready || app.agent_chat.pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
if steering && !matches!(reason.as_str(), "auth" | "error") {
|
||||
send_queue(app, ctx, reason == "cancelled");
|
||||
} else if !app.agent_chat.stopped && reason.is_empty() {
|
||||
send_queue(app, ctx, false);
|
||||
}
|
||||
}
|
||||
|
||||
fn start_turn(app: &mut SolveApp, text: String, image: bool) {
|
||||
let mut parts = vec![PromptPart::Text(text.clone())];
|
||||
start_turn_with(app, text, image, None);
|
||||
}
|
||||
|
||||
/// Start a message; `note` goes before the text for the agent, not into the conversation.
|
||||
fn start_turn_with(app: &mut SolveApp, text: String, image: bool, note: Option<&str>) {
|
||||
let sent = match note {
|
||||
Some(n) => format!("{n}\n\n{text}"),
|
||||
None => text.clone(),
|
||||
};
|
||||
let mut parts = vec![PromptPart::Text(sent)];
|
||||
if image {
|
||||
match crate::control::view_png(app, VIEW_IMAGE.0, VIEW_IMAGE.1) {
|
||||
Ok(png) => parts.push(PromptPart::Image { mime: "image/png".into(), base64: backend::base64(&png) }),
|
||||
@@ -532,13 +720,24 @@ fn start_turn(app: &mut SolveApp, text: String, image: bool) {
|
||||
Some(Turn { start_doc: app.session.doc.clone(), start_undo: app.session.undo.len(), changes: 0, requests: 0, limit_told: false, label });
|
||||
app.agent_chat.last = Some((text, image));
|
||||
app.agent_chat.state = Some(State::Busy);
|
||||
app.agent_chat.stopped = false;
|
||||
app.agent_chat.steering = false;
|
||||
app.agent_chat.ended = None;
|
||||
if let Some(b) = app.agent_chat.backend.as_mut() {
|
||||
b.prompt(&parts);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the agent to stop the message it is on; requests waiting for approval are declined.
|
||||
/// Stop: ask the agent to stop the message it is on; requests waiting for approval are declined.
|
||||
/// Messages queued meanwhile stay queued (they wait for Send).
|
||||
pub fn cancel(app: &mut SolveApp) {
|
||||
app.agent_chat.steering = false;
|
||||
app.agent_chat.stopped = true;
|
||||
interrupt(app);
|
||||
}
|
||||
|
||||
/// Ask the agent to stop the message it is on and decline what waits for approval.
|
||||
fn interrupt(app: &mut SolveApp) {
|
||||
decline_all(app, "the user stopped the message");
|
||||
if let Some(b) = app.agent_chat.backend.as_mut() {
|
||||
b.cancel();
|
||||
@@ -775,10 +974,16 @@ pub fn poll(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
for e in events {
|
||||
let ready = matches!(e, Event::Ready { .. });
|
||||
match app.agent_chat.on_event(e) {
|
||||
Some(Outcome::TurnEnd) => end_turn(app),
|
||||
Some(Outcome::TurnEnd) => {
|
||||
end_turn(app);
|
||||
after_turn(app, ctx);
|
||||
}
|
||||
Some(Outcome::Permission { key, title, options }) => on_permission(app, key, title, options),
|
||||
None => {}
|
||||
}
|
||||
if ready {
|
||||
apply_saved_model(app);
|
||||
}
|
||||
if ready && let Some((text, image)) = app.agent_chat.queued.take() {
|
||||
start_turn(app, text, image);
|
||||
}
|
||||
@@ -804,6 +1009,37 @@ pub fn refresh(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
app.agent_chat.detecting = Some(rx);
|
||||
}
|
||||
|
||||
/// Choose the model for this chat (the header's model picker): the agent switches to it, and it is
|
||||
/// remembered for this agent; an API endpoint's agent also keeps it as its default model.
|
||||
pub fn set_model(app: &mut SolveApp, model: &str) {
|
||||
if !app.agent_chat.models.iter().any(|m| m.id == model) {
|
||||
return;
|
||||
}
|
||||
if let Some(b) = app.agent_chat.backend.as_mut() {
|
||||
b.set_model(model);
|
||||
}
|
||||
app.agent_chat.model = model.to_string();
|
||||
let agent = app.agent_chat.running_id.clone();
|
||||
if let Some(a) = app.preferences.agents.iter_mut().find(|a| a.id == agent && a.kind == agents::Kind::Api) {
|
||||
a.model = model.to_string();
|
||||
}
|
||||
app.preferences.agent_models.insert(agent, model.to_string());
|
||||
}
|
||||
|
||||
/// A new session is ready: put back the model last chosen for this agent, when it offers it.
|
||||
fn apply_saved_model(app: &mut SolveApp) {
|
||||
let chat = &app.agent_chat;
|
||||
let Some(saved) = app.preferences.agent_models.get(&chat.running_id).cloned() else { return };
|
||||
if saved != chat.model && chat.models.iter().any(|m| m.id == saved) {
|
||||
set_model(app, &saved);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the model picker shows: only for an agent that reports models.
|
||||
pub fn models_shown(app: &SolveApp) -> bool {
|
||||
!app.agent_chat.models.is_empty() && app.agent_chat.state().running()
|
||||
}
|
||||
|
||||
/// Switch the agent to one of its own modes (the header's mode picker).
|
||||
pub fn set_mode(app: &mut SolveApp, mode: &str) {
|
||||
if app.agent_chat.modes.iter().any(|(id, _)| id == mode)
|
||||
@@ -817,7 +1053,7 @@ pub fn set_mode(app: &mut SolveApp, mode: &str) {
|
||||
/// `ui.agent_chat` on the control channel (tests and screenshots): `enabled`, `command`, `open`,
|
||||
/// `side`, `minimized`, `width`, `refresh`, `connect`, `stop`, `send`, `attach_view`,
|
||||
/// `permission` (an option id, or `"cancel"`), `approve` (`true`/`false`, the oldest held
|
||||
/// request), `cancel`, `undo` (the newest message's changes); returns the panel's state.
|
||||
/// request), `cancel` (Stop), `send_now` (steer: stop, then send the queue), `remove_pending` (index), `undo` (the newest message's changes); returns the panel's state.
|
||||
pub fn control(app: &mut SolveApp, ctx: &egui::Context, p: &Value) -> Result<Value, String> {
|
||||
if let Some(e) = p.get("features").and_then(Value::as_bool) {
|
||||
app.preferences.ai_features = e;
|
||||
@@ -875,6 +1111,9 @@ pub fn control(app: &mut SolveApp, ctx: &egui::Context, p: &Value) -> Result<Val
|
||||
if let Some(m) = p.get("mode").and_then(Value::as_str) {
|
||||
set_mode(app, m);
|
||||
}
|
||||
if let Some(m) = p.get("model").and_then(Value::as_str) {
|
||||
set_model(app, m);
|
||||
}
|
||||
if !shown(app) {
|
||||
return Ok(status(app));
|
||||
}
|
||||
@@ -932,6 +1171,15 @@ pub fn control(app: &mut SolveApp, ctx: &egui::Context, p: &Value) -> Result<Val
|
||||
if p.get("cancel").and_then(Value::as_bool) == Some(true) {
|
||||
cancel(app);
|
||||
}
|
||||
if p.get("new_chat").and_then(Value::as_bool) == Some(true) {
|
||||
new_chat(app);
|
||||
}
|
||||
if p.get("send_now").and_then(Value::as_bool) == Some(true) {
|
||||
send_now(app, ctx);
|
||||
}
|
||||
if let Some(n) = p.get("remove_pending").and_then(Value::as_u64) {
|
||||
remove_pending(app, n as usize);
|
||||
}
|
||||
if p.get("undo").and_then(Value::as_bool) == Some(true) {
|
||||
let Some(n) = app.agent_chat.items.iter().rposition(|i| matches!(i, Item::Changes { .. })) else { return Err("no changes to undo".into()) };
|
||||
undo_changes(app, n);
|
||||
@@ -976,6 +1224,8 @@ pub fn status(app: &SolveApp) -> Value {
|
||||
"permissions": app.preferences.ai_permissions.id(),
|
||||
"agents": agents::entries(app).iter().map(|e| json!({"id": e.id, "name": e.name, "kind": if e.kind == agents::Kind::Api { "api" } else { "acp" }, "found": e.found, "version": e.version, "login": e.login})).collect::<Vec<_>>(),
|
||||
"currentAgent": agents::current(app).map(|e| e.id),
|
||||
"models": c.models.iter().map(|m| json!({"id": m.id, "name": m.name, "description": m.description})).collect::<Vec<_>>(),
|
||||
"model": c.model,
|
||||
"modes": c.modes.iter().map(|(id, name)| json!({"id": id, "name": name})).collect::<Vec<_>>(),
|
||||
"mode": c.mode,
|
||||
"open": c.open,
|
||||
@@ -990,6 +1240,8 @@ pub fn status(app: &SolveApp) -> Value {
|
||||
"toolsPort": c.tools_port,
|
||||
"pid": pid,
|
||||
"waiting": c.waiting(),
|
||||
"pending": c.pending.iter().map(|p| json!({"text": p.text, "image": p.image})).collect::<Vec<_>>(),
|
||||
"stopped": c.stopped,
|
||||
"turn": c.turn.as_ref().map(|t| json!({"changes": t.changes, "requests": t.requests})),
|
||||
"detection": detection,
|
||||
"items": items,
|
||||
|
||||
@@ -58,6 +58,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if ctx.input_mut(|i| i.consume_shortcut(&SHORTCUT)) {
|
||||
toggle(app);
|
||||
}
|
||||
// New chat, when the chat has focus (its input, or the pointer over the panel).
|
||||
let in_chat = ctx.memory(|m| m.has_focus(Id::new("sc_agent_chat_input")))
|
||||
|| ctx.pointer_hover_pos().is_some_and(|p| app.agent_chat.panel_rect.is_some_and(|r| r.contains(p)));
|
||||
if app.agent_chat.open && in_chat && ctx.input_mut(|i| i.consume_shortcut(&NEW_CHAT_SHORTCUT)) {
|
||||
request_new_chat(app);
|
||||
}
|
||||
if !app.agent_chat.open {
|
||||
return;
|
||||
}
|
||||
@@ -81,13 +87,27 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 6)))
|
||||
.show(ui, |ui| body(app, ui, left));
|
||||
crate::scenario::publish_rect("agent_chat:panel", r.response.rect);
|
||||
app.agent_chat.panel_rect = Some(r.response.rect);
|
||||
let w = r.response.rect.width();
|
||||
if w.is_finite() && (MIN_WIDTH..=MAX_WIDTH).contains(&w) {
|
||||
app.preferences.agent_chat_width = w;
|
||||
}
|
||||
}
|
||||
|
||||
/// A small drawn button: `kind` is `minimize`, `close` or `expand`.
|
||||
/// New chat (when the chat has focus).
|
||||
pub const NEW_CHAT_SHORTCUT: egui::KeyboardShortcut =
|
||||
egui::KeyboardShortcut::new(egui::Modifiers::COMMAND.plus(egui::Modifiers::SHIFT), egui::Key::L);
|
||||
|
||||
/// Clear the chat, asking first when something would be lost.
|
||||
fn request_new_chat(app: &mut SolveApp) {
|
||||
if new_chat_needs_confirm(app) {
|
||||
app.agent_chat.confirm_new = true;
|
||||
} else {
|
||||
new_chat(app);
|
||||
}
|
||||
}
|
||||
|
||||
/// A small drawn button: `kind` is `minimize`, `close`, `stopturn` or `expand`.
|
||||
fn glyph_button(ui: &mut egui::Ui, kind: &str, tip: &str) -> egui::Response {
|
||||
let t = Tokens::get();
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
|
||||
@@ -104,6 +124,15 @@ fn glyph_button(ui: &mut egui::Ui, kind: &str, tip: &str) -> egui::Response {
|
||||
ui.painter().line_segment([c + vec2(-4.5, -4.5), c + vec2(4.5, 4.5)], s);
|
||||
ui.painter().line_segment([c + vec2(-4.5, 4.5), c + vec2(4.5, -4.5)], s);
|
||||
}
|
||||
"newchat" => {
|
||||
// A speech bubble with a plus.
|
||||
ui.painter().rect_stroke(Rect::from_center_size(c, vec2(14.0, 11.0)), 2.5, s, egui::StrokeKind::Inside);
|
||||
ui.painter().line_segment([c + vec2(-2.5, 0.0), c + vec2(2.5, 0.0)], Stroke::new(1.2, t.text));
|
||||
ui.painter().line_segment([c + vec2(0.0, -2.5), c + vec2(0.0, 2.5)], Stroke::new(1.2, t.text));
|
||||
}
|
||||
"stopturn" => {
|
||||
ui.painter().rect_filled(Rect::from_center_size(c, vec2(9.0, 9.0)), 1.5, t.error);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
crate::scenario::publish_handle(&format!("agent_chat:{kind}"), r.center());
|
||||
@@ -185,6 +214,16 @@ fn header(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) {
|
||||
app.preferences.agent_chat_minimized = true;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if glyph_button(
|
||||
ui,
|
||||
"newchat",
|
||||
&format!("{} ({})", tr("ui.agent_chat_new"), if cfg!(target_os = "macos") { "Cmd+Shift+L" } else { "Ctrl+Shift+L" }),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
request_new_chat(app);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let b = if state.running() {
|
||||
ui.button(tr("ui.agent_chat_stop")).on_hover_text(tr("ui.agent_chat_stop_tip"))
|
||||
@@ -261,6 +300,23 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
refresh(app, ui.ctx());
|
||||
}
|
||||
let state = app.agent_chat.state();
|
||||
if app.agent_chat.confirm_new {
|
||||
let t = Tokens::get();
|
||||
egui::Frame::NONE.fill(t.accent_soft).corner_radius(6.0).inner_margin(egui::Margin::same(6)).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.label(tr("ui.agent_chat_new_confirm"));
|
||||
ui.horizontal(|ui| {
|
||||
let yes = ui.button(tr("ui.agent_chat_new_yes"));
|
||||
crate::scenario::publish_handle("agent_chat:new_confirm", yes.rect.center());
|
||||
if yes.clicked() {
|
||||
new_chat(app);
|
||||
}
|
||||
if ui.button(tr("ui.agent_chat_cancel")).clicked() {
|
||||
app.agent_chat.confirm_new = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
picker(app, ui);
|
||||
if state == State::NeedsAuth {
|
||||
auth_box(app, ui);
|
||||
@@ -275,21 +331,38 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui.separator();
|
||||
let busy = state == State::Busy;
|
||||
let edit = egui::TextEdit::multiline(&mut app.agent_chat.input)
|
||||
.hint_text(tr("ui.agent_chat_placeholder"))
|
||||
.hint_text(tr(if busy { "ui.agent_chat_placeholder_busy" } else { "ui.agent_chat_placeholder" }))
|
||||
.desired_rows(2)
|
||||
.desired_width(f32::INFINITY)
|
||||
.id_salt("sc_agent_chat_input");
|
||||
// Enter sends (queues while the agent works); Ctrl/Cmd+Enter sends now; Esc stops. They are
|
||||
// read before the field sees them so no newline is typed.
|
||||
let focused = ui.memory(|m| m.has_focus(Id::new("sc_agent_chat_input")));
|
||||
let (steer, esc) = if focused {
|
||||
ui.input_mut(|i| (i.consume_key(egui::Modifiers::COMMAND, egui::Key::Enter), busy && i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)))
|
||||
} else {
|
||||
(false, false)
|
||||
};
|
||||
let r = ui.add(edit);
|
||||
crate::scenario::publish_rect("agent_chat:input", r.rect);
|
||||
let enter = r.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && !i.modifiers.shift);
|
||||
if esc {
|
||||
cancel(app);
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let send_b = ui.add_enabled(!busy, egui::Button::new(tr("ui.agent_chat_send")));
|
||||
let send_b = ui.button(tr(if busy { "ui.agent_chat_queue" } else { "ui.agent_chat_send" }));
|
||||
crate::scenario::publish_handle("agent_chat:send", send_b.rect.center());
|
||||
if (send_b.clicked() || (enter && !busy)) && !app.agent_chat.input.trim().is_empty() {
|
||||
if (send_b.clicked() || enter || steer) && !app.agent_chat.input.trim().is_empty() {
|
||||
let text = std::mem::take(&mut app.agent_chat.input).trim_end_matches('\n').to_string();
|
||||
send(app, ui.ctx(), text);
|
||||
if steer {
|
||||
send_now(app, ui.ctx());
|
||||
}
|
||||
} else if steer {
|
||||
// Nothing typed: Ctrl+Enter sends what is already queued.
|
||||
send_now(app, ui.ctx());
|
||||
}
|
||||
if busy && ui.button(tr("ui.agent_chat_cancel")).clicked() {
|
||||
if busy && glyph_button(ui, "stopturn", tr("ui.agent_chat_stop_turn_tip")).clicked() {
|
||||
cancel(app);
|
||||
}
|
||||
// A picture of the view goes with the message to agents that take pictures.
|
||||
@@ -350,7 +423,9 @@ fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
|| tr(if app.agent_chat.detecting.is_some() { "ui.agent_chat_detecting" } else { "ui.agent_chat_none_found_short" }).to_string(),
|
||||
|e| e.name.clone(),
|
||||
);
|
||||
let w = (ui.available_width() - if app.agent_chat.modes.is_empty() { 4.0 } else { 120.0 }).clamp(120.0, 260.0);
|
||||
let models = models_shown(app);
|
||||
let w =
|
||||
(ui.available_width() - if app.agent_chat.modes.is_empty() { 4.0 } else { 116.0 } - if models { 146.0 } else { 0.0 }).clamp(90.0, 260.0);
|
||||
let mut picked: Option<String> = None;
|
||||
let mut add = false;
|
||||
let combo = egui::ComboBox::from_id_salt("sc_agent_switch").width(w).selected_text(RichText::new(label).strong()).show_ui(ui, |ui| {
|
||||
@@ -405,6 +480,27 @@ fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
set_mode(app, &m);
|
||||
}
|
||||
}
|
||||
// The agent's models (ACP `models`, or an endpoint's list): shown only when it reports some.
|
||||
if models {
|
||||
let list = app.agent_chat.models.clone();
|
||||
let cur = list.iter().find(|m| m.id == app.agent_chat.model).map_or_else(|| app.agent_chat.model.clone(), |m| m.name.clone());
|
||||
let mut pick: Option<String> = None;
|
||||
let r = egui::ComboBox::from_id_salt("sc_agent_model").width(140.0).truncate().selected_text(cur).show_ui(ui, |ui| {
|
||||
for m in &list {
|
||||
let row = ui.selectable_label(m.id == app.agent_chat.model, &m.name);
|
||||
let row = if m.description.is_empty() { row } else { row.on_hover_text(&m.description) };
|
||||
crate::scenario::publish_handle(&format!("agent_chat:model:{}", m.id), row.rect.center());
|
||||
if row.clicked() {
|
||||
pick = Some(m.id.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
crate::scenario::publish_handle("agent_chat:model", r.response.rect.center());
|
||||
r.response.on_hover_text(tr("ui.agent_chat_model_tip"));
|
||||
if let Some(m) = pick {
|
||||
set_model(app, &m);
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(a) = &app.agent_chat.auth_status {
|
||||
ui.label(RichText::new(a).color(t.text_dim).size(11.0));
|
||||
@@ -641,12 +737,97 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
if app.agent_chat.items.is_empty() {
|
||||
let (mut send_now_clicked, mut send_queue_clicked) = (false, false);
|
||||
let mut remove: Option<usize> = None;
|
||||
let mut edit_done: Option<(usize, Option<String>)> = None;
|
||||
let busy = app.agent_chat.state() == State::Busy;
|
||||
let stopped = app.agent_chat.stopped;
|
||||
let editing = app.agent_chat.pending_edit.clone();
|
||||
let mut start_edit: Option<(usize, String)> = None;
|
||||
let mut edit_buf = editing.as_ref().map(|(_, b)| b.clone());
|
||||
for (n, p) in app.agent_chat.pending.iter().enumerate() {
|
||||
ui.add_space(6.0);
|
||||
egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.accent)).corner_radius(6.0).inner_margin(egui::Margin::same(6)).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
let tag = if stopped && !busy { "ui.agent_chat_queued_stopped" } else { "ui.agent_chat_queued" };
|
||||
ui.label(RichText::new(tr(tag)).color(t.accent).strong().size(11.0));
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
let x = ui.add(egui::Button::new(RichText::new("×").size(14.0)).frame(false)).on_hover_text(tr("ui.agent_chat_remove"));
|
||||
crate::scenario::publish_handle(&format!("agent_chat:pending_remove:{n}"), x.rect.center());
|
||||
if x.clicked() {
|
||||
remove = Some(n);
|
||||
}
|
||||
if editing.as_ref().is_none_or(|(i, _)| *i != n) {
|
||||
let e = ui.add(egui::Button::new(RichText::new(tr("ui.agent_chat_edit")).size(11.5)).frame(false));
|
||||
crate::scenario::publish_handle(&format!("agent_chat:pending_edit:{n}"), e.rect.center());
|
||||
if e.clicked() {
|
||||
start_edit = Some((n, p.text.clone()));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
match (&editing, edit_buf.as_mut()) {
|
||||
(Some((i, _)), Some(buf)) if *i == n => {
|
||||
let te = ui.add(egui::TextEdit::multiline(buf).desired_rows(2).desired_width(f32::INFINITY));
|
||||
crate::scenario::publish_rect("agent_chat:pending_input", te.rect);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(tr("ui.agent_chat_save")).clicked() {
|
||||
edit_done = Some((n, Some(buf.clone())));
|
||||
}
|
||||
if ui.button(tr("ui.agent_chat_cancel")).clicked() {
|
||||
edit_done = Some((n, None));
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
ui.add(egui::Label::new(RichText::new(&p.text).color(t.text)).selectable(true).wrap());
|
||||
if p.image {
|
||||
ui.label(RichText::new(tr("ui.agent_chat_view_attached")).color(t.text_dim).size(11.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if !app.agent_chat.pending.is_empty() && editing.is_none() {
|
||||
ui.horizontal(|ui| {
|
||||
if busy {
|
||||
let b = ui.button(tr("ui.agent_chat_send_now")).on_hover_text(tr("ui.agent_chat_send_now_tip"));
|
||||
crate::scenario::publish_handle("agent_chat:send_now", b.rect.center());
|
||||
send_now_clicked = b.clicked();
|
||||
} else {
|
||||
let b = ui.add(egui::Button::new(tr("ui.agent_chat_send")).fill(t.accent_soft));
|
||||
crate::scenario::publish_handle("agent_chat:send_queue", b.rect.center());
|
||||
send_queue_clicked = b.clicked();
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(start) = start_edit {
|
||||
app.agent_chat.pending_edit = Some(start);
|
||||
} else if let (Some(buf), Some(slot)) = (edit_buf, app.agent_chat.pending_edit.as_mut()) {
|
||||
slot.1 = buf;
|
||||
}
|
||||
if app.agent_chat.items.is_empty() && app.agent_chat.pending.is_empty() {
|
||||
ui.add_space(12.0);
|
||||
ui.label(RichText::new(tr("ui.agent_chat_intro")).color(t.text_dim));
|
||||
ui.add_space(6.0);
|
||||
ui.label(RichText::new(tr("ui.agent_chat_privacy")).color(t.text_dim).size(11.0));
|
||||
}
|
||||
if let Some((n, text)) = edit_done {
|
||||
if let (Some(text), Some(p)) = (text.filter(|t| !t.trim().is_empty()), app.agent_chat.pending.get_mut(n)) {
|
||||
p.text = text;
|
||||
}
|
||||
app.agent_chat.pending_edit = None;
|
||||
}
|
||||
if let Some(n) = remove {
|
||||
remove_pending(app, n);
|
||||
}
|
||||
if send_now_clicked {
|
||||
send_now(app, ui.ctx());
|
||||
}
|
||||
if send_queue_clicked {
|
||||
send_now(app, ui.ctx());
|
||||
}
|
||||
if let Some((n, o)) = answer {
|
||||
answer_permission(app, n, o.as_deref());
|
||||
}
|
||||
|
||||
@@ -303,3 +303,456 @@ fn the_agents_own_questions_are_answered_by_the_mode_or_asked() {
|
||||
on_permission(&mut app, 3, "Run ls".into(), opts());
|
||||
assert!(matches!(app.agent_chat.items.last(), Some(Item::Notice { .. })), "remembered");
|
||||
}
|
||||
|
||||
// ---- Messages typed while the agent works ----
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A backend that records what the panel asks of it, and reports what the test pushes.
|
||||
#[derive(Clone, Default)]
|
||||
struct Script {
|
||||
calls: Arc<Mutex<Vec<String>>>,
|
||||
events: Arc<Mutex<Vec<Event>>>,
|
||||
}
|
||||
|
||||
impl Script {
|
||||
fn calls(&self) -> Vec<String> {
|
||||
self.calls.lock().unwrap().clone()
|
||||
}
|
||||
fn report(&self, e: Event) {
|
||||
self.events.lock().unwrap().push(e);
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatBackend for Script {
|
||||
fn poll(&mut self) -> Vec<Event> {
|
||||
std::mem::take(&mut *self.events.lock().unwrap())
|
||||
}
|
||||
fn prompt(&mut self, parts: &[PromptPart]) {
|
||||
let text = parts.iter().find_map(|p| if let PromptPart::Text(t) = p { Some(t.clone()) } else { None }).unwrap_or_default();
|
||||
self.calls.lock().unwrap().push(format!("prompt: {text}"));
|
||||
}
|
||||
fn cancel(&mut self) {
|
||||
self.calls.lock().unwrap().push("cancel".into());
|
||||
}
|
||||
fn new_session(&mut self) {
|
||||
self.calls.lock().unwrap().push("new_session".into());
|
||||
}
|
||||
fn set_model(&mut self, model: &str) {
|
||||
self.calls.lock().unwrap().push(format!("set_model: {model}"));
|
||||
}
|
||||
fn answer_permission(&mut self, _: u64, _: Option<&str>) {}
|
||||
fn authenticate(&mut self, _: &str) {}
|
||||
fn shutdown(&mut self, _: bool) {}
|
||||
}
|
||||
|
||||
/// An app whose agent is scripted and ready.
|
||||
fn app_with_script() -> (SolveApp, Script, egui::Context) {
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
|
||||
let script = Script::default();
|
||||
app.agent_chat.backend = Some(Box::new(script.clone()));
|
||||
app.agent_chat.state = Some(State::Ready);
|
||||
(app, script, egui::Context::default())
|
||||
}
|
||||
|
||||
fn ended(script: &Script, reason: &str) {
|
||||
script.report(Event::TurnEnded { reason: reason.into() });
|
||||
}
|
||||
|
||||
fn texts(app: &SolveApp) -> Vec<String> {
|
||||
app.agent_chat.pending.iter().map(|p| p.text.clone()).collect()
|
||||
}
|
||||
|
||||
/// While the agent works a message is queued, not dropped and not sent; it is not in the
|
||||
/// transcript as a sent message, but waits as a pending one that can be edited and removed.
|
||||
#[test]
|
||||
fn a_message_typed_while_busy_is_queued() {
|
||||
let (mut app, script, ctx) = app_with_script();
|
||||
send(&mut app, &ctx, "make a box".into());
|
||||
assert_eq!(app.agent_chat.state(), State::Busy);
|
||||
send(&mut app, &ctx, "then a cylinder".into());
|
||||
send(&mut app, &ctx, " ".into());
|
||||
send(&mut app, &ctx, "and a sphere".into());
|
||||
assert_eq!(texts(&app), ["then a cylinder", "and a sphere"]);
|
||||
assert_eq!(script.calls(), ["prompt: make a box"], "nothing else was sent");
|
||||
let users = app.agent_chat.items.iter().filter(|i| matches!(i, Item::User { .. })).count();
|
||||
assert_eq!(users, 1, "queued messages are not sent messages yet");
|
||||
// Remove one; edit the other.
|
||||
remove_pending(&mut app, 0);
|
||||
assert_eq!(texts(&app), ["and a sphere"]);
|
||||
app.agent_chat.pending[0].text = "and a cone".into();
|
||||
assert_eq!(app.agent_chat.waiting(), 0);
|
||||
assert!(status(&app)["pending"][0]["text"] == "and a cone");
|
||||
}
|
||||
|
||||
/// When the turn ends normally the queue goes as the next prompt, all messages in order in one.
|
||||
#[test]
|
||||
fn the_queue_is_sent_when_the_turn_ends() {
|
||||
let (mut app, script, ctx) = app_with_script();
|
||||
send(&mut app, &ctx, "first".into());
|
||||
send(&mut app, &ctx, "second".into());
|
||||
send(&mut app, &ctx, "third".into());
|
||||
ended(&script, "");
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(script.calls(), ["prompt: first", "prompt: second\n\nthird"]);
|
||||
assert!(app.agent_chat.pending.is_empty());
|
||||
assert_eq!(app.agent_chat.state(), State::Busy, "the next turn is under way");
|
||||
assert!(matches!(app.agent_chat.items.last(), Some(Item::User { text, .. }) if text == "second\n\nthird"));
|
||||
// Nothing queued: the end of the turn just ends it.
|
||||
ended(&script, "");
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(app.agent_chat.state(), State::Ready);
|
||||
assert_eq!(script.calls().len(), 2);
|
||||
}
|
||||
|
||||
/// Stop does not send the queue by itself; it stays, with a Send that sends it later.
|
||||
#[test]
|
||||
fn stop_keeps_the_queue() {
|
||||
let (mut app, script, ctx) = app_with_script();
|
||||
send(&mut app, &ctx, "first".into());
|
||||
send(&mut app, &ctx, "later".into());
|
||||
cancel(&mut app);
|
||||
assert_eq!(script.calls(), ["prompt: first", "cancel"]);
|
||||
ended(&script, "cancelled");
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(app.agent_chat.state(), State::Ready);
|
||||
assert_eq!(texts(&app), ["later"], "still queued");
|
||||
assert_eq!(script.calls().len(), 2, "not sent");
|
||||
// Send (the bubble's button) sends it, without the interruption note.
|
||||
send_now(&mut app, &ctx);
|
||||
assert_eq!(script.calls(), ["prompt: first", "cancel", "prompt: later"]);
|
||||
assert!(app.agent_chat.pending.is_empty());
|
||||
}
|
||||
|
||||
/// Send now: cancel first, the prompt only after the agent has ended the cancelled turn, with a
|
||||
/// note that the previous step was interrupted (shown without it in the conversation).
|
||||
#[test]
|
||||
fn send_now_cancels_then_sends_with_a_note() {
|
||||
let (mut app, script, ctx) = app_with_script();
|
||||
send(&mut app, &ctx, "build a gearbox".into());
|
||||
send(&mut app, &ctx, "use 12 teeth instead".into());
|
||||
send_now(&mut app, &ctx);
|
||||
assert_eq!(script.calls(), ["prompt: build a gearbox", "cancel"], "cancel alone, the prompt waits for the end");
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(script.calls().len(), 2);
|
||||
ended(&script, "cancelled");
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(script.calls(), ["prompt: build a gearbox".to_string(), "cancel".into(), format!("prompt: {INTERRUPT_NOTE}\n\nuse 12 teeth instead")]);
|
||||
assert!(matches!(app.agent_chat.items.last(), Some(Item::User { text, .. }) if text == "use 12 teeth instead"));
|
||||
// Stop after Send now wins: nothing more is sent by itself.
|
||||
send(&mut app, &ctx, "one more".into());
|
||||
send_now(&mut app, &ctx);
|
||||
cancel(&mut app);
|
||||
ended(&script, "cancelled");
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(texts(&app), ["one more"]);
|
||||
}
|
||||
|
||||
/// The steered message also goes after a turn that ended by itself just before the cancel arrived.
|
||||
#[test]
|
||||
fn send_now_after_the_turn_already_ended_still_sends() {
|
||||
let (mut app, script, ctx) = app_with_script();
|
||||
send(&mut app, &ctx, "a".into());
|
||||
send(&mut app, &ctx, "b".into());
|
||||
send_now(&mut app, &ctx);
|
||||
ended(&script, "");
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(script.calls().last().map(String::as_str), Some("prompt: b"), "no note: nothing was interrupted");
|
||||
}
|
||||
|
||||
/// What an interrupted turn changed undoes as one step, and the steered message starts its own.
|
||||
#[test]
|
||||
fn an_interrupted_turns_changes_undo_as_one_step() {
|
||||
let (mut app, script, ctx) = app_with_script();
|
||||
let (tx, rx) = channel();
|
||||
app.agent_chat.tools_rx = Some(rx);
|
||||
let before = app.session.doc.clone();
|
||||
let depth = app.session.undo.len();
|
||||
send(&mut app, &ctx, "two boxes".into());
|
||||
let replies: Vec<_> = [0.0, 20.0].into_iter().map(|x| ask(&tx, "engine.execute", boxed(x))).collect();
|
||||
frame(&mut app);
|
||||
for r in replies {
|
||||
assert_eq!(r.recv_timeout(std::time::Duration::from_secs(30)).unwrap()["ok"], true);
|
||||
}
|
||||
send(&mut app, &ctx, "stop, make it one".into());
|
||||
send_now(&mut app, &ctx);
|
||||
ended(&script, "cancelled");
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(app.session.undo.len(), depth + 1, "the interrupted message is one step");
|
||||
let n = app.agent_chat.items.iter().rposition(|i| matches!(i, Item::Changes { steps: 2, .. })).expect("its changes are listed");
|
||||
// The steered message is on its own turn.
|
||||
assert!(app.agent_chat.turn.as_ref().is_some_and(|t| t.label.contains("stop, make it one") && t.changes == 0));
|
||||
undo_changes(&mut app, n);
|
||||
assert!(Arc::ptr_eq(&app.session.doc, &before));
|
||||
}
|
||||
|
||||
/// The same with a real agent process speaking ACP: `session/cancel` goes out, the agent answers
|
||||
/// the held prompt with the cancelled stop reason, and only then does the queued text go as a new
|
||||
/// `session/prompt`.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn send_now_with_an_acp_agent_sends_cancel_then_the_prompt() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-agent-chat-steer-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let log = dir.join("received.log");
|
||||
// A fake agent: answers initialize and session/new; holds a prompt whose text says "slow"
|
||||
// until it gets session/cancel (then answers it "cancelled"); answers other prompts at once.
|
||||
let script = r#"
|
||||
log="$1"; held=""
|
||||
while IFS= read -r line; do
|
||||
printf '%s\n' "$line" >> "$log"
|
||||
id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p')
|
||||
case "$line" in
|
||||
*'"method":"initialize"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentInfo":{"name":"fake","version":"1"}}}\n' "$id" ;;
|
||||
*'"method":"session/new"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"s1"}}\n' "$id" ;;
|
||||
*'"method":"session/prompt"'*)
|
||||
case "$line" in
|
||||
*slow*) held="$id" ;;
|
||||
*) printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn"}}\n' "$id" ;;
|
||||
esac ;;
|
||||
*'"method":"session/cancel"'*)
|
||||
if [ -n "$held" ]; then printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"cancelled"}}\n' "$held"; held=""; fi ;;
|
||||
esac
|
||||
done
|
||||
"#;
|
||||
let argv = vec!["sh".to_string(), "-c".to_string(), script.to_string(), "fake-agent".to_string(), log.to_string_lossy().into_owned()];
|
||||
let start = backend::StartContext { cwd: dir.clone(), bridge: None, tools: None, wake: Box::new(|| {}) };
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
|
||||
app.agent_chat.backend = Some(Box::new(acp_client::AcpBackend::start(&argv, start).unwrap()));
|
||||
app.agent_chat.state = Some(State::Starting);
|
||||
let ctx = egui::Context::default();
|
||||
let until = |app: &mut SolveApp, what: &str, done: &dyn Fn(&SolveApp) -> bool| {
|
||||
let t = std::time::Instant::now();
|
||||
while !done(app) {
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(20), "timed out waiting for {what}");
|
||||
poll(app, &ctx);
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
};
|
||||
until(&mut app, "the agent to be ready", &|a| a.agent_chat.state() == State::Ready);
|
||||
send(&mut app, &ctx, "slow job".into());
|
||||
send(&mut app, &ctx, "do this instead".into());
|
||||
assert_eq!(texts(&app), ["do this instead"]);
|
||||
send_now(&mut app, &ctx);
|
||||
until(&mut app, "the steered turn to finish", &|a| a.agent_chat.state() == State::Ready && a.agent_chat.pending.is_empty());
|
||||
let methods: Vec<String> = std::fs::read_to_string(&log)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.filter_map(|l| serde_json::from_str::<Value>(l).ok())
|
||||
.filter_map(|v| {
|
||||
let m = v["method"].as_str()?.to_string();
|
||||
let text = v.pointer("/params/prompt/0/text").and_then(Value::as_str).unwrap_or("");
|
||||
Some(if text.is_empty() { m } else { format!("{m}: {text}") })
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
methods,
|
||||
[
|
||||
"initialize".to_string(),
|
||||
"session/new".into(),
|
||||
"session/prompt: slow job".into(),
|
||||
"session/cancel".into(),
|
||||
format!("session/prompt: {INTERRUPT_NOTE}\n\ndo this instead"),
|
||||
]
|
||||
);
|
||||
disconnect(&mut app);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// New chat: the running message is stopped, the transcript and the queue are cleared, a fresh
|
||||
/// session starts on the same agent, the mode stays, and the design's undo history is untouched.
|
||||
#[test]
|
||||
fn new_chat_clears_everything_and_keeps_the_agent_and_mode() {
|
||||
let (mut app, script, ctx) = app_with_script();
|
||||
app.agent_chat.modes = vec![("plan".into(), "Plan".into()), ("code".into(), "Code".into())];
|
||||
app.agent_chat.mode = "plan".into();
|
||||
app.agent_chat.agent_id = Some("claude".into());
|
||||
let depth = app.session.undo.len();
|
||||
assert!(!new_chat_needs_confirm(&app));
|
||||
send(&mut app, &ctx, "first".into());
|
||||
send(&mut app, &ctx, "queued".into());
|
||||
assert!(new_chat_needs_confirm(&app), "a running message and a queue ask first");
|
||||
new_chat(&mut app);
|
||||
assert_eq!(script.calls(), ["prompt: first", "cancel", "new_session"]);
|
||||
assert!(app.agent_chat.items.is_empty() && app.agent_chat.pending.is_empty());
|
||||
assert_eq!(app.agent_chat.state(), State::Starting);
|
||||
assert_eq!(app.agent_chat.agent_id.as_deref(), Some("claude"));
|
||||
// The cancelled message's late end is not reported; the fresh session is ready again.
|
||||
ended(&script, "cancelled");
|
||||
script.report(Event::Modes { current: "code".into(), available: vec![] });
|
||||
script.report(Event::Ready { agent: "fake".into(), images: false });
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(app.agent_chat.state(), State::Ready);
|
||||
assert!(app.agent_chat.items.is_empty(), "no notice about the old message");
|
||||
assert_eq!(app.agent_chat.mode, "plan", "the mode is kept");
|
||||
assert_eq!(app.session.undo.len(), depth);
|
||||
assert!(!new_chat_needs_confirm(&app));
|
||||
}
|
||||
|
||||
/// With a real ACP agent, New chat sends `session/new` on the same process (no restart).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn new_chat_with_an_acp_agent_opens_a_new_session_on_the_same_process() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-agent-chat-new-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let log = dir.join("received.log");
|
||||
let script = r#"
|
||||
log="$1"; n=0
|
||||
while IFS= read -r line; do
|
||||
printf '%s\n' "$line" >> "$log"
|
||||
id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p')
|
||||
case "$line" in
|
||||
*'"method":"initialize"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"agentInfo":{"name":"fake","version":"1"}}}\n' "$id" ;;
|
||||
*'"method":"session/new"'*) n=$((n+1)); printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"s%s"}}\n' "$id" "$n" ;;
|
||||
*'"method":"session/prompt"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn"}}\n' "$id" ;;
|
||||
esac
|
||||
done
|
||||
"#;
|
||||
let argv = vec!["sh".to_string(), "-c".to_string(), script.to_string(), "fake-agent".to_string(), log.to_string_lossy().into_owned()];
|
||||
let start = backend::StartContext { cwd: dir.clone(), bridge: None, tools: None, wake: Box::new(|| {}) };
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
|
||||
app.agent_chat.backend = Some(Box::new(acp_client::AcpBackend::start(&argv, start).unwrap()));
|
||||
app.agent_chat.state = Some(State::Starting);
|
||||
let ctx = egui::Context::default();
|
||||
let until = |app: &mut SolveApp, done: &dyn Fn(&SolveApp) -> bool| {
|
||||
let t = std::time::Instant::now();
|
||||
while !done(app) {
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(20), "timed out");
|
||||
poll(app, &ctx);
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
};
|
||||
until(&mut app, &|a| a.agent_chat.state() == State::Ready);
|
||||
send(&mut app, &ctx, "hello".into());
|
||||
until(&mut app, &|a| a.agent_chat.state() == State::Ready);
|
||||
assert!(!app.agent_chat.items.is_empty());
|
||||
let pid = app.agent_chat.backend.as_ref().and_then(|b| b.pid());
|
||||
new_chat(&mut app);
|
||||
assert!(app.agent_chat.items.is_empty());
|
||||
until(&mut app, &|a| a.agent_chat.state() == State::Ready);
|
||||
assert_eq!(app.agent_chat.backend.as_ref().and_then(|b| b.pid()), pid, "same process");
|
||||
send(&mut app, &ctx, "again".into());
|
||||
until(&mut app, &|a| a.agent_chat.state() == State::Ready);
|
||||
let log_text = std::fs::read_to_string(&log).unwrap();
|
||||
let methods: Vec<String> =
|
||||
log_text.lines().filter_map(|l| serde_json::from_str::<Value>(l).ok()).filter_map(|v| v["method"].as_str().map(String::from)).collect();
|
||||
assert_eq!(methods, ["initialize", "session/new", "session/prompt", "session/new", "session/prompt"]);
|
||||
assert!(log_text.lines().last().is_some_and(|l| l.contains("\"s2\"")), "the prompt goes to the new session: {log_text}");
|
||||
disconnect(&mut app);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn models_reply(current: &str) -> Event {
|
||||
let m = |id: &str| acp::ModelInfo { id: id.into(), name: id.to_uppercase(), description: String::new() };
|
||||
Event::Models { current: current.into(), available: vec![m("fast"), m("smart")] }
|
||||
}
|
||||
|
||||
/// Choosing a model tells the agent, shows it as current and remembers it for this agent; a model
|
||||
/// the agent did not offer is ignored.
|
||||
#[test]
|
||||
fn choosing_a_model_sets_it_and_remembers_it() {
|
||||
let (mut app, script, ctx) = app_with_script();
|
||||
app.agent_chat.running_id = "claude".into();
|
||||
script.report(models_reply("fast"));
|
||||
poll(&mut app, &ctx);
|
||||
assert!(models_shown(&app));
|
||||
assert_eq!(app.agent_chat.model, "fast");
|
||||
set_model(&mut app, "smart");
|
||||
set_model(&mut app, "nonsense");
|
||||
assert_eq!(script.calls(), ["set_model: smart"]);
|
||||
assert_eq!(app.agent_chat.model, "smart");
|
||||
assert_eq!(app.preferences.agent_models.get("claude").map(String::as_str), Some("smart"));
|
||||
assert_eq!(status(&app)["model"], "smart");
|
||||
// The agent switching its own model is shown too.
|
||||
script.report(Event::Models { current: "fast".into(), available: vec![] });
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(app.agent_chat.model, "fast");
|
||||
assert_eq!(app.agent_chat.models.len(), 2, "the list stays");
|
||||
}
|
||||
|
||||
/// The next session puts back the model chosen last time, when the agent offers it.
|
||||
#[test]
|
||||
fn the_remembered_model_is_set_on_the_next_session() {
|
||||
let (mut app, script, ctx) = app_with_script();
|
||||
app.agent_chat.running_id = "claude".into();
|
||||
app.preferences.agent_models.insert("claude".into(), "smart".into());
|
||||
script.report(models_reply("fast"));
|
||||
script.report(Event::Ready { agent: "fake".into(), images: false });
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(script.calls(), ["set_model: smart"]);
|
||||
assert_eq!(app.agent_chat.model, "smart");
|
||||
// Already on it, or no longer offered: nothing is sent.
|
||||
script.report(models_reply("smart"));
|
||||
script.report(Event::Ready { agent: "fake".into(), images: false });
|
||||
poll(&mut app, &ctx);
|
||||
app.preferences.agent_models.insert("claude".into(), "gone".into());
|
||||
script.report(models_reply("fast"));
|
||||
script.report(Event::Ready { agent: "fake".into(), images: false });
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(script.calls().len(), 1);
|
||||
}
|
||||
|
||||
/// An agent that reports no models gets no picker; another agent's choice is not applied to it.
|
||||
#[test]
|
||||
fn an_agent_without_models_shows_no_picker() {
|
||||
let (mut app, script, ctx) = app_with_script();
|
||||
app.agent_chat.running_id = "other".into();
|
||||
app.preferences.agent_models.insert("claude".into(), "smart".into());
|
||||
script.report(Event::Ready { agent: "fake".into(), images: false });
|
||||
poll(&mut app, &ctx);
|
||||
assert!(!models_shown(&app));
|
||||
assert!(script.calls().is_empty());
|
||||
assert_eq!(status(&app)["models"], json!([]));
|
||||
}
|
||||
|
||||
/// With a real ACP agent the choice goes out as `session/set_model` with the session and model.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn set_model_goes_to_an_acp_agent() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-agent-chat-model-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let log = dir.join("received.log");
|
||||
let script = r#"
|
||||
log="$1"
|
||||
while IFS= read -r line; do
|
||||
printf '%s\n' "$line" >> "$log"
|
||||
id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p')
|
||||
case "$line" in
|
||||
*'"method":"initialize"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"agentInfo":{"name":"fake","version":"1"}}}\n' "$id" ;;
|
||||
*'"method":"session/new"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"s1","models":{"currentModelId":"fast","availableModels":[{"modelId":"fast","name":"Fast"},{"modelId":"smart","name":"Smart"}]}}}\n' "$id" ;;
|
||||
*'"method":"session/set_model"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" ;;
|
||||
esac
|
||||
done
|
||||
"#;
|
||||
let argv = vec!["sh".to_string(), "-c".to_string(), script.to_string(), "fake-agent".to_string(), log.to_string_lossy().into_owned()];
|
||||
let start = backend::StartContext { cwd: dir.clone(), bridge: None, tools: None, wake: Box::new(|| {}) };
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
|
||||
app.agent_chat.backend = Some(Box::new(acp_client::AcpBackend::start(&argv, start).unwrap()));
|
||||
app.agent_chat.state = Some(State::Starting);
|
||||
app.agent_chat.running_id = "fake".into();
|
||||
app.preferences.agent_models.insert("fake".into(), "smart".into());
|
||||
let ctx = egui::Context::default();
|
||||
let t = std::time::Instant::now();
|
||||
loop {
|
||||
poll(&mut app, &ctx);
|
||||
let logged = std::fs::read_to_string(&log).unwrap_or_default();
|
||||
if logged.contains("session/set_model") {
|
||||
break;
|
||||
}
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(20), "no set_model: {logged}");
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
assert!(models_shown(&app) && app.agent_chat.model == "smart", "the remembered model was put back");
|
||||
let logged = std::fs::read_to_string(&log).unwrap();
|
||||
let set: Value = logged.lines().filter_map(|l| serde_json::from_str::<Value>(l).ok()).find(|v| v["method"] == "session/set_model").unwrap();
|
||||
assert_eq!(set["params"], json!({"sessionId": "s1", "modelId": "smart"}));
|
||||
// A choice made in the picker goes out too.
|
||||
set_model(&mut app, "fast");
|
||||
let t = std::time::Instant::now();
|
||||
while std::fs::read_to_string(&log).unwrap_or_default().matches("session/set_model").count() < 2 {
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(20));
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
disconnect(&mut app);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -348,6 +348,8 @@ pub struct Prefs {
|
||||
/// How much the agent may do without asking (`ask`, `edits`, `all`), and per agent.
|
||||
pub ai_permissions: crate::agent_chat::agents::Mode,
|
||||
pub agent_permissions: std::collections::BTreeMap<String, crate::agent_chat::agents::Mode>,
|
||||
/// The model last chosen in the chat, per agent: put back when the agent offers it again.
|
||||
pub agent_models: std::collections::BTreeMap<String, String>,
|
||||
/// "Always allow" answers: per agent, the tools it may use without asking.
|
||||
pub agent_always_allow: std::collections::BTreeMap<String, Vec<String>>,
|
||||
/// The side the panel docks on (`left` or `right`), its width, and whether it is folded to a strip.
|
||||
@@ -435,6 +437,7 @@ impl Default for Prefs {
|
||||
agent_default: String::new(),
|
||||
ai_permissions: crate::agent_chat::agents::Mode::Edits,
|
||||
agent_permissions: Default::default(),
|
||||
agent_models: Default::default(),
|
||||
agent_always_allow: Default::default(),
|
||||
agent_chat_side: "right".into(),
|
||||
agent_chat_width: 380.0,
|
||||
|
||||
+22
-1
@@ -41,7 +41,28 @@ when you send a message or press **Connect**.
|
||||
unfold it. **Close** (×) hides the panel. Neither stops the agent; **Stop** does.
|
||||
- **The agent switcher** under the title picks the agent for this conversation; switching stops
|
||||
the one running. Next to it, when the agent has modes of its own (Claude Code has *Default*,
|
||||
*Accept Edits*, *Plan*…), a second drop-down sets the agent's mode.
|
||||
*Accept Edits*, *Plan*…), a second drop-down sets the agent's mode. When the agent reports its models (an ACP agent's
|
||||
model list, or an endpoint's `/models`), a model drop-down shows the one in use and changes it;
|
||||
SolidCraft remembers the last choice for each agent and puts it back on the next session. An
|
||||
endpoint's agent also keeps it as its default model. An agent that reports no models shows no
|
||||
drop-down.
|
||||
|
||||
## Typing while the agent works
|
||||
|
||||
The input stays enabled during a turn. **Enter** queues the message: it shows in the conversation
|
||||
as a **Queued** bubble that you can **Edit** or remove (×) until it is sent. Several queued
|
||||
messages go as one prompt, in order, when the turn ends normally.
|
||||
|
||||
- **Send now** (on the bubble, or **Ctrl+Enter** / **Cmd+Enter** in the input) stops the current
|
||||
step, waits for the agent to say it stopped, then sends the queue as the next prompt, with a
|
||||
short note that the agent's previous step was interrupted. What the interrupted message changed
|
||||
undoes as one step, like any other message.
|
||||
- **Stop** (the square next to the input, or **Esc** in the input) stops the agent and keeps
|
||||
the queue: the bubbles wait for **Send**.
|
||||
- **New chat** (the header button, **Ctrl+Shift+L** / **Cmd+Shift+L** with the chat focused)
|
||||
stops what runs, clears the conversation and the queue and starts a fresh session on the same
|
||||
agent, keeping the agent and its mode. It asks first when a message is running or queued. The
|
||||
design's undo history is untouched.
|
||||
|
||||
## Agents
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ labels; `prefs:measure_axis_distances` identifies the checkbox.
|
||||
| `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits |
|
||||
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first |
|
||||
| `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it |
|
||||
| `ui.agent_chat` | `features?`, `toolbar_button?`, `permissions?` (`ask`, `edits`, `all`), `add_agent?` ({`name`, `kind`: `acp` or `api`, `command` or `base_url` + `model`}; no keys), `default_agent?`, `agent?` (the conversation's agent, by id), `mode?` (the agent's own mode), `open?`, `side?` (`left`, `right`), `minimized?`, `width?`, `picker?`, `refresh?`, `connect?`, `stop?`, `send?`: text, `attach_view?`, `permission?`: an option id or `"cancel"` (the newest question of the agent's own), `approve?`: true, false or `"always"` (the oldest request held for Allow/Deny), `cancel?`, `undo?` (the last message's changes) | the chat's state: `features`, `agents` (found and added), `currentAgent`, `state` (off, starting, ready, busy, auth, exited), `agent`, `modes`, `mode`, `authMethods`, `toolsPort`, `pid`, `waiting`, `turn`, `detection` and the conversation (`items`). With `features` off only the settings apply. Desktop only; see [Agent chat](agent-chat.md) |
|
||||
| `ui.agent_chat` | `features?`, `toolbar_button?`, `permissions?` (`ask`, `edits`, `all`), `add_agent?` ({`name`, `kind`: `acp` or `api`, `command` or `base_url` + `model`}; no keys), `default_agent?`, `agent?` (the conversation's agent, by id), `mode?` (the agent's own mode), `model?` (one of its models), `open?`, `side?` (`left`, `right`), `minimized?`, `width?`, `picker?`, `refresh?`, `connect?`, `stop?`, `send?`: text, `attach_view?`, `permission?`: an option id or `"cancel"` (the newest question of the agent's own), `approve?`: true, false or `"always"` (the oldest request held for Allow/Deny), `cancel?` (Stop: the queue stays), `send_now?` (stop the running step, then send the queued messages), `remove_pending?` (index), `new_chat?` (clear the chat, new session), `undo?` (the last message's changes) | the chat's state: `features`, `agents` (found and added), `currentAgent`, `state` (off, starting, ready, busy, auth, exited), `agent`, `modes`, `mode`, `models`, `model`, `authMethods`, `toolsPort`, `pid`, `waiting`, `turn`, `pending` (queued messages), `stopped`, `detection` and the conversation (`items`). With `features` off only the settings apply. Desktop only; see [Agent chat](agent-chat.md) |
|
||||
| `ui.resize` | `width`, `height` | |
|
||||
| `app.quit` | `force?` (quit without asking about unsaved designs) | `quitting` (the window closes), `asking` (a "save changes?" prompt is open) |
|
||||
|
||||
|
||||
Reference in new issue
Block a user