chore(lint): clean up frontend and Rust lint issues

This commit is contained in:
xintaofei
2026-04-23 15:56:41 +08:00
parent 1dd40d0baf
commit 022172a9ea
69 changed files with 1138 additions and 1248 deletions
+124 -94
View File
@@ -156,9 +156,7 @@ async fn build_agent(
debug_assert_eq!(meta.agent_type, agent_type); debug_assert_eq!(meta.agent_type, agent_type);
match meta.distribution { match meta.distribution {
AgentDistribution::Npx { AgentDistribution::Npx { cmd, args, env, .. } => {
cmd, args, env, ..
} => {
let merged_env = merge_agent_env(env, runtime_env); let merged_env = merge_agent_env(env, runtime_env);
let mut parts: Vec<String> = Vec::new(); let mut parts: Vec<String> = Vec::new();
for (k, v) in &merged_env { for (k, v) in &merged_env {
@@ -251,10 +249,7 @@ async fn build_agent(
)) ))
})?; })?;
if cached_version == registry_version { if cached_version == registry_version {
eprintln!( eprintln!("[ACP][{}] Using cached binary {cached_version}", meta.name);
"[ACP][{}] Using cached binary {cached_version}",
meta.name
);
} else { } else {
eprintln!( eprintln!(
"[ACP][{}] Using cached binary {cached_version} (registry recommends {registry_version})", "[ACP][{}] Using cached binary {cached_version} (registry recommends {registry_version})",
@@ -273,8 +268,7 @@ async fn build_agent(
server = server.args(cmd_args); server = server.args(cmd_args);
} }
let merged_env = merge_agent_env(env, runtime_env); let merged_env = merge_agent_env(env, runtime_env);
let env_key_list: Vec<&str> = let env_key_list: Vec<&str> = merged_env.iter().map(|(k, _)| k.as_str()).collect();
merged_env.iter().map(|(k, _)| k.as_str()).collect();
if !merged_env.is_empty() { if !merged_env.is_empty() {
let env_vars: Vec<sacp::schema::EnvVariable> = merged_env let env_vars: Vec<sacp::schema::EnvVariable> = merged_env
.iter() .iter()
@@ -314,34 +308,36 @@ async fn build_agent(
.map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false); .unwrap_or(false);
let agent_name = meta.name.to_string(); let agent_name = meta.name.to_string();
Ok(AcpAgent::new(sacp::schema::McpServer::Stdio(server)).with_debug( Ok(
move |line, dir| { AcpAgent::new(sacp::schema::McpServer::Stdio(server)).with_debug(
let (tag, enabled) = match dir { move |line, dir| {
sacp_tokio::LineDirection::Stderr => ("stderr", true), let (tag, enabled) = match dir {
sacp_tokio::LineDirection::Stdout => ("stdout", stdio_debug_enabled), sacp_tokio::LineDirection::Stderr => ("stderr", true),
sacp_tokio::LineDirection::Stdin => ("stdin", stdio_debug_enabled), sacp_tokio::LineDirection::Stdout => ("stdout", stdio_debug_enabled),
}; sacp_tokio::LineDirection::Stdin => ("stdin", stdio_debug_enabled),
if !enabled { };
return; if !enabled {
} return;
const MAX: usize = 256; }
if line.len() > MAX { const MAX: usize = 256;
let head = line if line.len() > MAX {
.char_indices() let head = line
.take_while(|(i, _)| *i < MAX) .char_indices()
.last() .take_while(|(i, _)| *i < MAX)
.map(|(i, c)| i + c.len_utf8()) .last()
.unwrap_or(MAX); .map(|(i, c)| i + c.len_utf8())
eprintln!( .unwrap_or(MAX);
"[ACP][{agent_name}][{tag}] {}... <truncated {} bytes>", eprintln!(
&line[..head], "[ACP][{agent_name}][{tag}] {}... <truncated {} bytes>",
line.len() - head &line[..head],
); line.len() - head
} else { );
eprintln!("[ACP][{agent_name}][{tag}] {line}"); } else {
} eprintln!("[ACP][{agent_name}][{tag}] {line}");
}, }
)) },
),
)
} }
} }
} }
@@ -574,37 +570,40 @@ fn ensure_codex_mode_option(options: &mut Vec<SessionConfigOptionInfo>) {
if options.iter().any(|o| o.id == "mode") { if options.iter().any(|o| o.id == "mode") {
return; return;
} }
options.insert(0, SessionConfigOptionInfo { options.insert(
id: "mode".to_string(), 0,
name: "Approval Preset".to_string(), SessionConfigOptionInfo {
description: Some( id: "mode".to_string(),
"Choose an approval and sandboxing preset for your session".to_string(), name: "Approval Preset".to_string(),
), description: Some(
category: Some("mode".to_string()), "Choose an approval and sandboxing preset for your session".to_string(),
kind: SessionConfigKindInfo::Select(SessionConfigSelectInfo { ),
current_value: "auto".to_string(), category: Some("mode".to_string()),
options: vec![ kind: SessionConfigKindInfo::Select(SessionConfigSelectInfo {
SessionConfigSelectOptionInfo { current_value: "auto".to_string(),
value: "read-only".to_string(), options: vec![
name: "Read Only".to_string(), SessionConfigSelectOptionInfo {
description: Some("Codex can only read files".to_string()), value: "read-only".to_string(),
}, name: "Read Only".to_string(),
SessionConfigSelectOptionInfo { description: Some("Codex can only read files".to_string()),
value: "auto".to_string(), },
name: "Default".to_string(), SessionConfigSelectOptionInfo {
description: Some( value: "auto".to_string(),
"Codex can edit files, but asks before running commands".to_string(), name: "Default".to_string(),
), description: Some(
}, "Codex can edit files, but asks before running commands".to_string(),
SessionConfigSelectOptionInfo { ),
value: "full-access".to_string(), },
name: "Full Access".to_string(), SessionConfigSelectOptionInfo {
description: Some("Codex runs without asking for approval".to_string()), value: "full-access".to_string(),
}, name: "Full Access".to_string(),
], description: Some("Codex runs without asking for approval".to_string()),
groups: vec![], },
}), ],
}); groups: vec![],
}),
},
);
} }
fn emit_session_config_options_values( fn emit_session_config_options_values(
@@ -758,7 +757,15 @@ async fn run_connection(
async move |req: RequestPermissionRequest, async move |req: RequestPermissionRequest,
responder: Responder<RequestPermissionResponse>, responder: Responder<RequestPermissionResponse>,
_cx: ConnectionTo<Agent>| { _cx: ConnectionTo<Agent>| {
handle_permission_request(&conn_id, &emitter_inner, &perms, &perm_cwd, req, responder).await; handle_permission_request(
&conn_id,
&emitter_inner,
&perms,
&perm_cwd,
req,
responder,
)
.await;
Ok(()) Ok(())
} }
}, },
@@ -979,7 +986,13 @@ async fn run_connection(
notif.update, notif.update,
SessionUpdate::AvailableCommandsUpdate(_) SessionUpdate::AvailableCommandsUpdate(_)
) { ) {
emit_conversation_update(&cid, &h, agent_type, notif.update, None); emit_conversation_update(
&cid,
&h,
agent_type,
notif.update,
None,
);
} }
Ok(()) Ok(())
}) })
@@ -1004,7 +1017,12 @@ async fn run_connection(
}, },
); );
emit_session_modes(&conn_id, &emitter_clone, session.modes()); emit_session_modes(&conn_id, &emitter_clone, session.modes());
emit_session_config_options(&conn_id, &emitter_clone, agent_type, &initial_config_options); emit_session_config_options(
&conn_id,
&emitter_clone,
agent_type,
&initial_config_options,
);
emit_selectors_ready(&conn_id, &emitter_clone); emit_selectors_ready(&conn_id, &emitter_clone);
let loop_result = run_conversation_loop( let loop_result = run_conversation_loop(
@@ -1058,9 +1076,7 @@ async fn run_connection(
"acp://event", "acp://event",
AcpEvent::Error { AcpEvent::Error {
connection_id: conn_id.clone(), connection_id: conn_id.clone(),
message: format!( message: format!("Failed to load session, starting new: {e}"),
"Failed to load session, starting new: {e}"
),
agent_type: agent_type.to_string(), agent_type: agent_type.to_string(),
code: None, code: None,
}, },
@@ -1072,8 +1088,7 @@ async fn run_connection(
.await?; .await?;
let fallback_sid = new_resp.session_id.0.to_string(); let fallback_sid = new_resp.session_id.0.to_string();
let initial_config_options = new_resp.config_options.clone(); let initial_config_options = new_resp.config_options.clone();
let mut session = let mut session = cx.attach_session(new_resp, Default::default())?;
cx.attach_session(new_resp, Default::default())?;
crate::web::event_bridge::emit_event( crate::web::event_bridge::emit_event(
&emitter_clone, &emitter_clone,
"acp://event", "acp://event",
@@ -1139,7 +1154,12 @@ async fn run_connection(
}, },
); );
emit_session_modes(&conn_id, &emitter_clone, session.modes()); emit_session_modes(&conn_id, &emitter_clone, session.modes());
emit_session_config_options(&conn_id, &emitter_clone, agent_type, &initial_config_options); emit_session_config_options(
&conn_id,
&emitter_clone,
agent_type,
&initial_config_options,
);
emit_selectors_ready(&conn_id, &emitter_clone); emit_selectors_ready(&conn_id, &emitter_clone);
let loop_result = run_conversation_loop( let loop_result = run_conversation_loop(
@@ -1229,8 +1249,7 @@ async fn handle_permission_request(
obj.insert(key.to_string(), parsed); obj.insert(key.to_string(), parsed);
} }
} else if text.contains("@@\n") || text.contains("@@\r\n") { } else if text.contains("@@\n") || text.contains("@@\r\n") {
if let Some(resolved) = if let Some(resolved) = crate::parsers::resolve_patch_text(&text, Some(cwd))
crate::parsers::resolve_patch_text(&text, Some(cwd))
{ {
obj.insert(key.to_string(), serde_json::Value::String(resolved)); obj.insert(key.to_string(), serde_json::Value::String(resolved));
} }
@@ -1759,7 +1778,14 @@ async fn handle_fork_or_exit(
// Recursively handle nested forks // Recursively handle nested forks
Box::pin(handle_fork_or_exit( Box::pin(handle_fork_or_exit(
loop_result, conn_id, emitter, agent_type, perms, cmd_rx, terminal_runtime, _cwd, loop_result,
conn_id,
emitter,
agent_type,
perms,
cmd_rx,
terminal_runtime,
_cwd,
cwd_string, cwd_string,
)) ))
.await .await
@@ -2169,8 +2195,10 @@ async fn run_conversation_loop<'a>(
}) => { }) => {
let cx = session.connection(); let cx = session.connection();
let sid = session.session_id().clone(); let sid = session.session_id().clone();
if let Err(e) = if let Err(e) = set_session_config_option(
set_session_config_option(&cx, &sid, conn_id, emitter, agent_type, config_id, value_id).await &cx, &sid, conn_id, emitter, agent_type, config_id, value_id,
)
.await
{ {
crate::web::event_bridge::emit_event( crate::web::event_bridge::emit_event(
emitter, emitter,
@@ -2499,10 +2527,10 @@ fn emit_conversation_update(
} }
SessionUpdate::ToolCall(tc) => { SessionUpdate::ToolCall(tc) => {
let content = serialize_tool_call_content(&tc.content); let content = serialize_tool_call_content(&tc.content);
let raw_input = json_value_to_text(&tc.raw_input) let raw_input =
.map(|text| resolve_live_tool_input(&text, cwd)); json_value_to_text(&tc.raw_input).map(|text| resolve_live_tool_input(&text, cwd));
let raw_output = json_value_to_text(&tc.raw_output) let raw_output =
.map(|text| structurize_live_output(&text)); json_value_to_text(&tc.raw_output).map(|text| structurize_live_output(&text));
let locations = if tc.locations.is_empty() { let locations = if tc.locations.is_empty() {
None None
} else { } else {
@@ -2581,7 +2609,12 @@ fn emit_conversation_update(
); );
} }
SessionUpdate::ConfigOptionUpdate(update) => { SessionUpdate::ConfigOptionUpdate(update) => {
emit_session_config_options_values(connection_id, emitter, agent_type, update.config_options); emit_session_config_options_values(
connection_id,
emitter,
agent_type,
update.config_options,
);
} }
SessionUpdate::AvailableCommandsUpdate(update) => { SessionUpdate::AvailableCommandsUpdate(update) => {
let commands: Vec<AvailableCommandInfo> = update let commands: Vec<AvailableCommandInfo> = update
@@ -2661,8 +2694,8 @@ mod tests {
) )
.unwrap(); .unwrap();
let event = map_claude_sdk_ext_notification("conn-1", &raw) let event =
.expect("valid sdk payload should map"); map_claude_sdk_ext_notification("conn-1", &raw).expect("valid sdk payload should map");
match event { match event {
AcpEvent::ClaudeSdkMessage { AcpEvent::ClaudeSdkMessage {
@@ -2700,11 +2733,8 @@ mod tests {
.unwrap(); .unwrap();
assert!(map_claude_sdk_ext_notification("conn-1", &wrong_method).is_none()); assert!(map_claude_sdk_ext_notification("conn-1", &wrong_method).is_none());
let missing_fields = UntypedMessage::new( let missing_fields =
"_claude/sdkMessage", UntypedMessage::new("_claude/sdkMessage", serde_json::json!({"sessionId": 1})).unwrap();
serde_json::json!({"sessionId": 1}),
)
.unwrap();
assert!(map_claude_sdk_ext_notification("conn-1", &missing_fields).is_none()); assert!(map_claude_sdk_ext_notification("conn-1", &missing_fields).is_none());
} }
+1 -4
View File
@@ -219,10 +219,7 @@ impl ConnectionManager {
pub async fn disconnect_all(&self) -> usize { pub async fn disconnect_all(&self) -> usize {
let cmd_txs: Vec<_> = { let cmd_txs: Vec<_> = {
let mut connections = self.connections.lock().await; let mut connections = self.connections.lock().await;
connections connections.drain().map(|(_, conn)| conn.cmd_tx).collect()
.drain()
.map(|(_, conn)| conn.cmd_tx)
.collect()
}; };
let disconnected = cmd_txs.len(); let disconnected = cmd_txs.len();
for cmd_tx in cmd_txs { for cmd_tx in cmd_txs {
+2 -2
View File
@@ -1,11 +1,11 @@
pub mod binary_cache; pub mod binary_cache;
pub mod connection; pub mod connection;
pub mod error; pub mod error;
pub mod fork;
pub mod file_system_runtime; pub mod file_system_runtime;
pub mod fork;
pub mod manager; pub mod manager;
pub mod opencode_plugins;
pub mod preflight; pub mod preflight;
pub mod registry; pub mod registry;
pub mod terminal_runtime; pub mod terminal_runtime;
pub mod types; pub mod types;
pub mod opencode_plugins;
+19 -29
View File
@@ -88,9 +88,7 @@ fn has_project_opencode_config(project_root: &Path) -> bool {
/// Inspect `~/.config/opencode/opencode.json` and `~/.cache/opencode/node_modules/` /// Inspect `~/.config/opencode/opencode.json` and `~/.cache/opencode/node_modules/`
/// to determine which declared plugins are installed and which are missing. /// to determine which declared plugins are installed and which are missing.
pub fn check_opencode_plugins( pub fn check_opencode_plugins(project_root: Option<&Path>) -> Result<PluginCheckSummary, String> {
project_root: Option<&Path>,
) -> Result<PluginCheckSummary, String> {
let config_path = opencode_config_path() let config_path = opencode_config_path()
.ok_or_else(|| "Cannot determine opencode config directory".to_string())?; .ok_or_else(|| "Cannot determine opencode config directory".to_string())?;
let cache_dir = opencode_cache_dir() let cache_dir = opencode_cache_dir()
@@ -257,12 +255,7 @@ fn write_backup_and_prune(path: &Path, content: &str, keep: usize) -> Result<(),
let mut backups: Vec<_> = fs::read_dir(parent) let mut backups: Vec<_> = fs::read_dir(parent)
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.filter_map(|entry| entry.ok()) .filter_map(|entry| entry.ok())
.filter(|entry| { .filter(|entry| entry.file_name().to_string_lossy().starts_with(&prefix))
entry
.file_name()
.to_string_lossy()
.starts_with(&prefix)
})
.collect(); .collect();
// Sort by name descending (timestamp in name → newest first) // Sort by name descending (timestamp in name → newest first)
@@ -280,8 +273,8 @@ pub(crate) fn atomic_rewrite_opencode_json(
path: &Path, path: &Path,
mutator: impl FnOnce(&mut serde_json::Value) -> Result<(), String>, mutator: impl FnOnce(&mut serde_json::Value) -> Result<(), String>,
) -> Result<(), String> { ) -> Result<(), String> {
let raw = fs::read_to_string(path) let raw =
.map_err(|e| format!("Failed to read {}: {e}", path.display()))?; fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
// Try parsing first. If serde_json succeeds the file is valid JSON // Try parsing first. If serde_json succeeds the file is valid JSON
// and any "//" or "/*" sequences live inside string values — not real // and any "//" or "/*" sequences live inside string values — not real
@@ -302,14 +295,12 @@ pub(crate) fn atomic_rewrite_opencode_json(
mutator(&mut doc)?; mutator(&mut doc)?;
let new_raw = serde_json::to_string_pretty(&doc) let new_raw =
.map_err(|e| format!("Failed to serialize JSON: {e}"))?; serde_json::to_string_pretty(&doc).map_err(|e| format!("Failed to serialize JSON: {e}"))?;
let tmp_path = path.with_extension("json.tmp"); let tmp_path = path.with_extension("json.tmp");
fs::write(&tmp_path, &new_raw) fs::write(&tmp_path, &new_raw).map_err(|e| format!("Failed to write temp file: {e}"))?;
.map_err(|e| format!("Failed to write temp file: {e}"))?; fs::rename(&tmp_path, path).map_err(|e| format!("Failed to rename temp file: {e}"))?;
fs::rename(&tmp_path, path)
.map_err(|e| format!("Failed to rename temp file: {e}"))?;
Ok(()) Ok(())
} }
@@ -366,12 +357,9 @@ fn pin_latest_specs(
for item in arr.iter_mut() { for item in arr.iter_mut() {
if let Some(spec_str) = item.as_str() { if let Some(spec_str) = item.as_str() {
if let Some((parsed_name, _)) = parse_plugin_spec(spec_str) { if let Some((parsed_name, _)) = parse_plugin_spec(spec_str) {
if let Some((_, version)) = if let Some((_, version)) = pin_map.iter().find(|(n, _)| *n == parsed_name)
pin_map.iter().find(|(n, _)| *n == parsed_name)
{ {
*item = serde_json::Value::String(format!( *item = serde_json::Value::String(format!("{parsed_name}@{version}"));
"{parsed_name}@{version}"
));
pinned += 1; pinned += 1;
} }
} }
@@ -417,9 +405,9 @@ pub async fn install_missing_plugins(
task_id: String, task_id: String,
emitter: &EventEmitter, emitter: &EventEmitter,
) -> Result<(), String> { ) -> Result<(), String> {
let _guard = PLUGIN_OP_LOCK.try_lock().map_err(|_| { let _guard = PLUGIN_OP_LOCK
"Another plugin operation is in progress".to_string() .try_lock()
})?; .map_err(|_| "Another plugin operation is in progress".to_string())?;
emit_plugin_event(emitter, &task_id, PluginInstallEventKind::Started, ""); emit_plugin_event(emitter, &task_id, PluginInstallEventKind::Started, "");
@@ -597,12 +585,14 @@ pub async fn install_missing_plugins(
/// Uninstall a single plugin: remove from opencode.json, then `bun remove` from cache. /// Uninstall a single plugin: remove from opencode.json, then `bun remove` from cache.
pub async fn uninstall_plugin(name: String) -> Result<PluginCheckSummary, String> { pub async fn uninstall_plugin(name: String) -> Result<PluginCheckSummary, String> {
let _guard = PLUGIN_OP_LOCK.try_lock().map_err(|_| { let _guard = PLUGIN_OP_LOCK
"Another plugin operation is in progress".to_string() .try_lock()
})?; .map_err(|_| "Another plugin operation is in progress".to_string())?;
if is_protected_package(&name) { if is_protected_package(&name) {
return Err(format!("Cannot uninstall {name}: it is an internal opencode package")); return Err(format!(
"Cannot uninstall {name}: it is an internal opencode package"
));
} }
let config_path = opencode_config_path() let config_path = opencode_config_path()
+53 -41
View File
@@ -102,14 +102,30 @@ async fn check_npm_environment(node_required: Option<&str>) -> Vec<CheckItem> {
let (node_result, npm_result) = tokio::join!( let (node_result, npm_result) = tokio::join!(
async { async {
match &node_path { match &node_path {
Some(p) => crate::process::tokio_command(p).arg("--version").output().await, Some(p) => {
None => Err(std::io::Error::new(std::io::ErrorKind::NotFound, "node not found in PATH")), crate::process::tokio_command(p)
.arg("--version")
.output()
.await
}
None => Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"node not found in PATH",
)),
} }
}, },
async { async {
match &npm_path { match &npm_path {
Some(p) => crate::process::tokio_command(p).arg("--version").output().await, Some(p) => {
None => Err(std::io::Error::new(std::io::ErrorKind::NotFound, "npm not found in PATH")), crate::process::tokio_command(p)
.arg("--version")
.output()
.await
}
None => Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"npm not found in PATH",
)),
} }
}, },
); );
@@ -310,47 +326,44 @@ async fn check_binary_environment(
// but still pass — the Settings page's version-badge flow is the // but still pass — the Settings page's version-badge flow is the
// canonical place to surface "upgrade available". // canonical place to surface "upgrade available".
if platform_supported { if platform_supported {
let cache_check = let cache_check = match binary_cache::find_best_cached_binary_for_agent(agent_type, cmd) {
match binary_cache::find_best_cached_binary_for_agent(agent_type, cmd) { Ok(Some((_, cached_version))) => {
Ok(Some((_, cached_version))) => { let message = if cached_version == version {
let message = if cached_version == version { "Binary is cached locally".to_string()
"Binary is cached locally".to_string() } else {
} else { format!("Binary {cached_version} is cached locally (recommended: {version})")
format!( };
"Binary {cached_version} is cached locally (recommended: {version})" CheckItem {
) check_id: "binary_cached".into(),
}; label: "Binary cache".into(),
CheckItem { status: CheckStatus::Pass,
check_id: "binary_cached".into(), message,
label: "Binary cache".into(), fixes: vec![],
status: CheckStatus::Pass,
message,
fixes: vec![],
}
} }
Ok(None) => CheckItem { }
check_id: "binary_cached".into(), Ok(None) => CheckItem {
label: "Binary cache".into(), check_id: "binary_cached".into(),
status: CheckStatus::Warn, label: "Binary cache".into(),
message: status: CheckStatus::Warn,
"Binary is not installed. Download it from Agent Settings before connecting." message:
.into(), "Binary is not installed. Download it from Agent Settings before connecting."
fixes: vec![], .into(),
}, fixes: vec![],
Err(_) => CheckItem { },
check_id: "binary_cached".into(), Err(_) => CheckItem {
label: "Binary cache".into(), check_id: "binary_cached".into(),
status: CheckStatus::Warn, label: "Binary cache".into(),
message: "Cannot determine binary cache path".into(), status: CheckStatus::Warn,
fixes: vec![], message: "Cannot determine binary cache path".into(),
}, fixes: vec![],
}; },
};
checks.push(cache_check); checks.push(cache_check);
} }
// OpenCode plugin checks // OpenCode plugin checks
if agent_type == AgentType::OpenCode { if agent_type == AgentType::OpenCode {
use crate::acp::opencode_plugins::{self, PluginStatus, spec_has_floating_version}; use crate::acp::opencode_plugins::{self, spec_has_floating_version, PluginStatus};
match opencode_plugins::check_opencode_plugins(None) { match opencode_plugins::check_opencode_plugins(None) {
Ok(summary) => { Ok(summary) => {
let missing: Vec<_> = summary let missing: Vec<_> = summary
@@ -376,8 +389,7 @@ async fn check_binary_environment(
fixes: vec![], fixes: vec![],
}); });
} else { } else {
let names: Vec<&str> = let names: Vec<&str> = missing.iter().map(|p| p.name.as_str()).collect();
missing.iter().map(|p| p.name.as_str()).collect();
checks.push(CheckItem { checks.push(CheckItem {
check_id: "opencode_plugins".into(), check_id: "opencode_plugins".into(),
label: "OpenCode plugins".into(), label: "OpenCode plugins".into(),
+3 -2
View File
@@ -38,8 +38,9 @@ pub struct AcpAgentMeta {
impl AcpAgentMeta { impl AcpAgentMeta {
pub fn registry_version(&self) -> Option<&'static str> { pub fn registry_version(&self) -> Option<&'static str> {
match &self.distribution { match &self.distribution {
AgentDistribution::Npx { version, .. } AgentDistribution::Npx { version, .. } | AgentDistribution::Binary { version, .. } => {
| AgentDistribution::Binary { version, .. } => Some(*version), Some(*version)
}
} }
} }
} }
+3 -4
View File
@@ -3,10 +3,9 @@ use std::process::Stdio;
use std::sync::Arc; use std::sync::Arc;
use sacp::schema::{ use sacp::schema::{
CreateTerminalRequest, CreateTerminalResponse, KillTerminalRequest, CreateTerminalRequest, CreateTerminalResponse, KillTerminalRequest, KillTerminalResponse,
KillTerminalResponse, ReleaseTerminalRequest, ReleaseTerminalResponse, ReleaseTerminalRequest, ReleaseTerminalResponse, TerminalExitStatus, TerminalOutputRequest,
TerminalExitStatus, TerminalOutputRequest, TerminalOutputResponse, WaitForTerminalExitRequest, TerminalOutputResponse, WaitForTerminalExitRequest, WaitForTerminalExitResponse,
WaitForTerminalExitResponse,
}; };
use tokio::io::{AsyncRead, AsyncReadExt}; use tokio::io::{AsyncRead, AsyncReadExt};
use tokio::sync::Mutex; use tokio::sync::Mutex;
+2 -7
View File
@@ -202,9 +202,7 @@ impl LarkBackend {
let token = result let token = result
.tenant_access_token .tenant_access_token
.ok_or_else(|| { .ok_or_else(|| ChatChannelError::AuthenticationFailed("No token in response".into()))?;
ChatChannelError::AuthenticationFailed("No token in response".into())
})?;
let expire_secs = result.expire.unwrap_or(7200); let expire_secs = result.expire.unwrap_or(7200);
let expires_at = Instant::now() let expires_at = Instant::now()
@@ -252,10 +250,7 @@ impl LarkBackend {
))); )));
} }
let message_id = result let message_id = result.data.and_then(|d| d.message_id).unwrap_or_default();
.data
.and_then(|d| d.message_id)
.unwrap_or_default();
Ok(SentMessageId(message_id)) Ok(SentMessageId(message_id))
} }
@@ -34,10 +34,7 @@ impl TelegramBackend {
} }
fn api_url(&self, method: &str) -> String { fn api_url(&self, method: &str) -> String {
format!( format!("https://api.telegram.org/bot{}/{}", self.bot_token, method)
"https://api.telegram.org/bot{}/{}",
self.bot_token, method
)
} }
async fn send_text( async fn send_text(
@@ -171,9 +168,8 @@ impl ChatChannelBackend for TelegramBackend {
{ {
offset = uid + 1; offset = uid + 1;
} }
if let Some(text) = update if let Some(text) =
.pointer("/message/text") update.pointer("/message/text").and_then(|t| t.as_str())
.and_then(|t| t.as_str())
{ {
// Group chat filtering: only process if @bot is mentioned // Group chat filtering: only process if @bot is mentioned
let chat_type = update let chat_type = update
@@ -184,8 +180,7 @@ impl ChatChannelBackend for TelegramBackend {
if (chat_type == "group" || chat_type == "supergroup") if (chat_type == "group" || chat_type == "supergroup")
&& !bot_username.is_empty() && !bot_username.is_empty()
{ {
let at_bot = let at_bot = format!("@{}", bot_username);
format!("@{}", bot_username);
if !text.to_lowercase().contains(&at_bot) { if !text.to_lowercase().contains(&at_bot) {
eprintln!("[Telegram] skipped group msg without @bot: {text}"); eprintln!("[Telegram] skipped group msg without @bot: {text}");
continue; continue;
+59 -76
View File
@@ -57,6 +57,18 @@ pub struct WeixinQrcodeStatusPublic {
pub status: String, pub status: String,
} }
struct SendRequest<'a> {
client: &'a reqwest::Client,
base_url: &'a str,
bot_token: &'a str,
wechat_uin: &'a str,
to_user_id: &'a str,
context_token: &'a str,
text: &'a str,
reply_context: &'a Mutex<Option<WeixinReplyContext>>,
pending_messages: &'a Mutex<Vec<String>>,
}
// ── QR code auth functions (called before backend exists) ── // ── QR code auth functions (called before backend exists) ──
pub async fn weixin_get_qrcode() -> Result<WeixinQrcodeInfo, ChatChannelError> { pub async fn weixin_get_qrcode() -> Result<WeixinQrcodeInfo, ChatChannelError> {
@@ -186,16 +198,16 @@ fn generate_qrcode_data_uri(content: &str) -> Result<String, ChatChannelError> {
Ok(format!("data:image/png;base64,{b64}")) Ok(format!("data:image/png;base64,{b64}"))
} }
pub async fn weixin_check_qrcode( pub async fn weixin_check_qrcode(qrcode: &str) -> Result<WeixinQrcodeStatus, ChatChannelError> {
qrcode: &str,
) -> Result<WeixinQrcodeStatus, ChatChannelError> {
let client = qr_client(); let client = qr_client();
let resp = client let resp = client
.get(format!("{ILINK_BASE_URL}/ilink/bot/get_qrcode_status")) .get(format!("{ILINK_BASE_URL}/ilink/bot/get_qrcode_status"))
.query(&[("qrcode", qrcode)]) .query(&[("qrcode", qrcode)])
.send() .send()
.await .await
.map_err(|e| ChatChannelError::ConnectionFailed(format!("QR status request failed: {e}")))?; .map_err(|e| {
ChatChannelError::ConnectionFailed(format!("QR status request failed: {e}"))
})?;
let body: serde_json::Value = resp let body: serde_json::Value = resp
.json() .json()
@@ -289,11 +301,7 @@ impl WeixinBackend {
} }
/// Build the JSON body for the iLink sendmessage API. /// Build the JSON body for the iLink sendmessage API.
fn build_send_body( fn build_send_body(to_user_id: &str, context_token: &str, text: &str) -> serde_json::Value {
to_user_id: &str,
context_token: &str,
text: &str,
) -> serde_json::Value {
serde_json::json!({ serde_json::json!({
"msg": { "msg": {
"from_user_id": "", "from_user_id": "",
@@ -313,23 +321,14 @@ impl WeixinBackend {
/// Send a message via the iLink API and handle the response. /// Send a message via the iLink API and handle the response.
/// Returns `Ok(true)` if sent, `Ok(false)` if buffered due to expired context. /// Returns `Ok(true)` if sent, `Ok(false)` if buffered due to expired context.
async fn do_send( async fn do_send(req: SendRequest<'_>) -> Result<bool, ChatChannelError> {
client: &reqwest::Client, let body = Self::build_send_body(req.to_user_id, req.context_token, req.text);
base_url: &str, let url = format!("{}/ilink/bot/sendmessage", req.base_url);
bot_token: &str,
wechat_uin: &str,
to_user_id: &str,
context_token: &str,
text: &str,
reply_context: &Mutex<Option<WeixinReplyContext>>,
pending_messages: &Mutex<Vec<String>>,
) -> Result<bool, ChatChannelError> {
let body = Self::build_send_body(to_user_id, context_token, text);
let url = format!("{base_url}/ilink/bot/sendmessage");
let resp = client let resp = req
.client
.post(&url) .post(&url)
.headers(Self::build_headers(bot_token, wechat_uin)) .headers(Self::build_headers(req.bot_token, req.wechat_uin))
.json(&body) .json(&body)
.send() .send()
.await .await
@@ -356,20 +355,18 @@ impl WeixinBackend {
if ret == -2 { if ret == -2 {
// Context token expired — mark stale and buffer // Context token expired — mark stale and buffer
if let Some(ref mut c) = *reply_context.lock().await { if let Some(ref mut c) = *req.reply_context.lock().await {
c.expired = true; c.expired = true;
} }
let mut buf = pending_messages.lock().await; let mut buf = req.pending_messages.lock().await;
if buf.len() < MAX_PENDING_MESSAGES { if buf.len() < MAX_PENDING_MESSAGES {
buf.push(text.to_string()); buf.push(req.text.to_string());
} }
eprintln!("[Weixin] context_token expired (ret=-2), buffered message"); eprintln!("[Weixin] context_token expired (ret=-2), buffered message");
return Ok(false); return Ok(false);
} }
return Err(ChatChannelError::SendFailed(format!( return Err(ChatChannelError::SendFailed(format!("ret={ret}: {errmsg}")));
"ret={ret}: {errmsg}"
)));
} }
} }
} }
@@ -377,10 +374,7 @@ impl WeixinBackend {
Ok(true) Ok(true)
} }
async fn send_text( async fn send_text(&self, text: &str) -> Result<SentMessageId, ChatChannelError> {
&self,
text: &str,
) -> Result<SentMessageId, ChatChannelError> {
// Extract context data under lock, then release // Extract context data under lock, then release
let (to_user_id, context_token, expired) = { let (to_user_id, context_token, expired) = {
let guard = self.reply_context.lock().await; let guard = self.reply_context.lock().await;
@@ -418,17 +412,17 @@ impl WeixinBackend {
text.len() text.len()
); );
Self::do_send( Self::do_send(SendRequest {
&self.client, client: &self.client,
&self.base_url, base_url: &self.base_url,
&self.bot_token, bot_token: &self.bot_token,
&self.wechat_uin, wechat_uin: &self.wechat_uin,
&to_user_id, to_user_id: &to_user_id,
&context_token, context_token: &context_token,
text, text,
&self.reply_context, reply_context: &self.reply_context,
&self.pending_messages, pending_messages: &self.pending_messages,
) })
.await?; .await?;
Ok(SentMessageId(String::new())) Ok(SentMessageId(String::new()))
@@ -478,10 +472,8 @@ impl ChatChannelBackend for WeixinBackend {
eprintln!("[Weixin] verify response status={status_code}, body={resp_text}"); eprintln!("[Weixin] verify response status={status_code}, body={resp_text}");
let verify_result: serde_json::Value = let verify_result: serde_json::Value = serde_json::from_str(&resp_text)
serde_json::from_str(&resp_text).map_err(|e| { .map_err(|e| ChatChannelError::ConnectionFailed(format!("JSON parse failed: {e}")))?;
ChatChannelError::ConnectionFailed(format!("JSON parse failed: {e}"))
})?;
// iLink API auth failures come back as `{"errcode":-14,"errmsg":"session timeout"}` // iLink API auth failures come back as `{"errcode":-14,"errmsg":"session timeout"}`
// (no `ret` field). Treat any non-zero errcode as authentication failure. // (no `ret` field). Treat any non-zero errcode as authentication failure.
@@ -606,8 +598,7 @@ impl ChatChannelBackend for WeixinBackend {
for msg in msgs { for msg in msgs {
// Only handle user messages (message_type=1), // Only handle user messages (message_type=1),
// skip bot echo (message_type=2) // skip bot echo (message_type=2)
let msg_type = let msg_type = msg.get("message_type").and_then(|v| v.as_i64());
msg.get("message_type").and_then(|v| v.as_i64());
if msg_type != Some(1) { if msg_type != Some(1) {
continue; continue;
} }
@@ -654,10 +645,8 @@ impl ChatChannelBackend for WeixinBackend {
if !from_user_id.is_empty() && !context_token.is_empty() { if !from_user_id.is_empty() && !context_token.is_empty() {
let was_expired = { let was_expired = {
let mut guard = reply_context.lock().await; let mut guard = reply_context.lock().await;
let was = guard let was =
.as_ref() guard.as_ref().map(|c| c.expired).unwrap_or(false);
.map(|c| c.expired)
.unwrap_or(false);
*guard = Some(WeixinReplyContext { *guard = Some(WeixinReplyContext {
to_user_id: from_user_id.to_string(), to_user_id: from_user_id.to_string(),
context_token: context_token.to_string(), context_token: context_token.to_string(),
@@ -676,23 +665,22 @@ impl ChatChannelBackend for WeixinBackend {
buffered.len() buffered.len()
); );
for pending_text in &buffered { for pending_text in &buffered {
let ok = WeixinBackend::do_send( let ok = WeixinBackend::do_send(SendRequest {
&client, client: &client,
&base_url, base_url: &base_url,
&bot_token, bot_token: &bot_token,
&wechat_uin, wechat_uin: &wechat_uin,
from_user_id, to_user_id: from_user_id,
context_token, context_token,
pending_text, text: pending_text,
&reply_context, reply_context: &reply_context,
&pending_messages, pending_messages: &pending_messages,
) })
.await; .await;
if let Err(e) = ok { if let Err(e) = ok {
eprintln!("[Weixin] resend error: {e}"); eprintln!("[Weixin] resend error: {e}");
// Re-buffer remaining on hard error // Re-buffer remaining on hard error
let mut buf = let mut buf = pending_messages.lock().await;
pending_messages.lock().await;
if buf.len() < MAX_PENDING_MESSAGES { if buf.len() < MAX_PENDING_MESSAGES {
buf.push(pending_text.clone()); buf.push(pending_text.clone());
} }
@@ -724,15 +712,11 @@ impl ChatChannelBackend for WeixinBackend {
} }
Err(e) => { Err(e) => {
consecutive_errors += 1; consecutive_errors += 1;
eprintln!( eprintln!("[Weixin] polling error ({consecutive_errors}): {e}");
"[Weixin] polling error ({consecutive_errors}): {e}"
);
*status.lock().await = ChannelConnectionStatus::Error; *status.lock().await = ChannelConnectionStatus::Error;
// Exponential backoff: 5s, 10s, 20s, capped at 30s // Exponential backoff: 5s, 10s, 20s, capped at 30s
let delay = std::cmp::min( let delay =
5 * 2u64.saturating_pow(consecutive_errors - 1), std::cmp::min(5 * 2u64.saturating_pow(consecutive_errors - 1), 30);
30,
);
tokio::time::sleep(Duration::from_secs(delay)).await; tokio::time::sleep(Duration::from_secs(delay)).await;
} }
} }
@@ -791,9 +775,8 @@ impl ChatChannelBackend for WeixinBackend {
eprintln!("[Weixin] test_connection: status={status_code}, body={resp_text}"); eprintln!("[Weixin] test_connection: status={status_code}, body={resp_text}");
let resp_json: serde_json::Value = serde_json::from_str(&resp_text).map_err(|e| { let resp_json: serde_json::Value = serde_json::from_str(&resp_text)
ChatChannelError::ConnectionFailed(format!("Not valid JSON: {e}")) .map_err(|e| ChatChannelError::ConnectionFailed(format!("Not valid JSON: {e}")))?;
})?;
if !status_code.is_success() { if !status_code.is_success() {
return Err(ChatChannelError::AuthenticationFailed(format!( return Err(ChatChannelError::AuthenticationFailed(format!(
@@ -155,9 +155,16 @@ async fn dispatch_command(
guard.find_by_sender(channel_id, sender_id).is_some() guard.find_by_sender(channel_id, sender_id).is_some()
}; };
if has_session { if has_session {
return session_commands::handle_followup( return session_commands::handle_followup(session_commands::FollowupRequest {
db, text, channel_id, sender_id, conn_mgr, bridge, lang, prefix, db,
) text,
channel_id,
sender_id,
conn_mgr,
bridge,
lang,
prefix,
})
.await; .await;
} }
return command_handlers::handle_help(prefix, lang); return command_handlers::handle_help(prefix, lang);
@@ -205,8 +212,7 @@ async fn dispatch_command(
.await .await
} }
"cancel" => { "cancel" => {
session_commands::handle_cancel(db, channel_id, sender_id, conn_mgr, bridge, lang) session_commands::handle_cancel(db, channel_id, sender_id, conn_mgr, bridge, lang).await
.await
} }
"approve" => { "approve" => {
let always = args.eq_ignore_ascii_case("always"); let always = args.eq_ignore_ascii_case("always");
+4 -18
View File
@@ -6,11 +6,7 @@ use super::manager::ChatChannelManager;
use super::types::{MessageLevel, RichMessage}; use super::types::{MessageLevel, RichMessage};
use crate::db::entities::conversation; use crate::db::entities::conversation;
pub async fn handle_search( pub async fn handle_search(db: &DatabaseConnection, keyword: &str, lang: Lang) -> RichMessage {
db: &DatabaseConnection,
keyword: &str,
lang: Lang,
) -> RichMessage {
let matched = match conversation::Entity::find() let matched = match conversation::Entity::find()
.filter(conversation::Column::DeletedAt.is_null()) .filter(conversation::Column::DeletedAt.is_null())
.filter(conversation::Column::Title.contains(keyword)) .filter(conversation::Column::Title.contains(keyword))
@@ -40,10 +36,7 @@ pub async fn handle_search(
let title = conv.title.as_deref().unwrap_or(i18n::untitled(lang)); let title = conv.title.as_deref().unwrap_or(i18n::untitled(lang));
let agent = &conv.agent_type; let agent = &conv.agent_type;
let time = conv.created_at.format("%m-%d %H:%M"); let time = conv.created_at.format("%m-%d %H:%M");
body.push_str(&format!( body.push_str(&format!("#{} [{}] {} ({})\n", conv.id, agent, title, time,));
"#{} [{}] {} ({})\n",
conv.id, agent, title, time,
));
} }
RichMessage::info(body.trim_end()).with_title(i18n::search_results_count_title( RichMessage::info(body.trim_end()).with_title(i18n::search_results_count_title(
@@ -55,11 +48,7 @@ pub async fn handle_search(
pub async fn handle_today(db: &DatabaseConnection, lang: Lang) -> RichMessage { pub async fn handle_today(db: &DatabaseConnection, lang: Lang) -> RichMessage {
let now = Utc::now(); let now = Utc::now();
let today_start = now let today_start = now.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc();
.date_naive()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc();
let rows = match conversation::Entity::find() let rows = match conversation::Entity::find()
.filter(conversation::Column::DeletedAt.is_null()) .filter(conversation::Column::DeletedAt.is_null())
@@ -99,10 +88,7 @@ pub async fn handle_today(db: &DatabaseConnection, lang: Lang) -> RichMessage {
let mut body = i18n::total_sessions(lang, rows.len() as u32); let mut body = i18n::total_sessions(lang, rows.len() as u32);
body.push_str(&format!("\n\n{}", i18n::by_agent_label(lang))); body.push_str(&format!("\n\n{}", i18n::by_agent_label(lang)));
for (agent, count) in &by_agent { for (agent, count) in &by_agent {
body.push_str(&format!( body.push_str(&format!("\n {}", i18n::agent_count(lang, agent, *count)));
"\n {}",
i18n::agent_count(lang, agent, *count)
));
} }
if !titles.is_empty() { if !titles.is_empty() {
@@ -9,7 +9,9 @@ use super::i18n::Lang;
use super::manager::ChatChannelManager; use super::manager::ChatChannelManager;
use super::message_formatter; use super::message_formatter;
use super::types::RichMessage; use super::types::RichMessage;
use crate::db::service::{app_metadata_service, chat_channel_message_log_service, chat_channel_service}; use crate::db::service::{
app_metadata_service, chat_channel_message_log_service, chat_channel_service,
};
use crate::web::event_bridge::WebEventBroadcaster; use crate::web::event_bridge::WebEventBroadcaster;
/// Minimum interval between pushes for the same event type per channel (debounce). /// Minimum interval between pushes for the same event type per channel (debounce).
+56 -41
View File
@@ -340,7 +340,6 @@ pub fn search_results_count_title(lang: Lang, keyword: &str, count: usize) -> St
} }
} }
pub fn no_activity_today(lang: Lang) -> &'static str { pub fn no_activity_today(lang: Lang) -> &'static str {
match lang { match lang {
Lang::ZhCn => "今日暂无编码活动", Lang::ZhCn => "今日暂无编码活动",
@@ -667,36 +666,26 @@ pub fn search_usage(lang: Lang, prefix: &str) -> String {
pub fn unknown_command(lang: Lang, prefix: &str, command: &str) -> String { pub fn unknown_command(lang: Lang, prefix: &str, command: &str) -> String {
match lang { match lang {
Lang::ZhCn => format!( Lang::ZhCn => format!("未知命令: {prefix}{command}\n输入 {prefix}help 查看可用命令"),
"未知命令: {prefix}{command}\n输入 {prefix}help 查看可用命令" Lang::ZhTw => format!("未知命令: {prefix}{command}\n輸入 {prefix}help 查看可用命令"),
), Lang::Ja => format!("不明なコマンド: {prefix}{command}\n{prefix}help でヘルプを表示"),
Lang::ZhTw => format!( Lang::Ko => format!("알 수 없는 명령: {prefix}{command}\n{prefix}help 로 도움말 보기"),
"未知命令: {prefix}{command}\n輸入 {prefix}help 查看可用命令"
),
Lang::Ja => format!(
"不明なコマンド: {prefix}{command}\n{prefix}help でヘルプを表示"
),
Lang::Ko => format!(
"알 수 없는 명령: {prefix}{command}\n{prefix}help 로 도움말 보기"
),
Lang::Es => format!( Lang::Es => format!(
"Comando desconocido: {prefix}{command}\nEscriba {prefix}help para ver los comandos" "Comando desconocido: {prefix}{command}\nEscriba {prefix}help para ver los comandos"
), ),
Lang::De => format!( Lang::De => {
"Unbekannter Befehl: {prefix}{command}\n{prefix}help für Hilfe eingeben" format!("Unbekannter Befehl: {prefix}{command}\n{prefix}help für Hilfe eingeben")
), }
Lang::Fr => format!( Lang::Fr => {
"Commande inconnue : {prefix}{command}\nTapez {prefix}help pour l'aide" format!("Commande inconnue : {prefix}{command}\nTapez {prefix}help pour l'aide")
), }
Lang::Pt => format!( Lang::Pt => {
"Comando desconhecido: {prefix}{command}\nDigite {prefix}help para ajuda" format!("Comando desconhecido: {prefix}{command}\nDigite {prefix}help para ajuda")
), }
Lang::Ar => format!( Lang::Ar => format!("أمر غير معروف: {prefix}{command}\nاكتب {prefix}help لعرض المساعدة"),
"أمر غير معروف: {prefix}{command}\nاكتب {prefix}help لعرض المساعدة" Lang::En => {
), format!("Unknown command: {prefix}{command}\nType {prefix}help for available commands")
Lang::En => format!( }
"Unknown command: {prefix}{command}\nType {prefix}help for available commands"
),
} }
} }
@@ -782,10 +771,14 @@ pub fn folder_index_out_of_range(lang: Lang, prefix: &str) -> String {
match lang { match lang {
Lang::ZhCn => format!("序号超出范围,请使用 {prefix}folder 查看列表。"), Lang::ZhCn => format!("序号超出范围,请使用 {prefix}folder 查看列表。"),
Lang::ZhTw => format!("序號超出範圍,請使用 {prefix}folder 查看列表。"), Lang::ZhTw => format!("序號超出範圍,請使用 {prefix}folder 查看列表。"),
Lang::Ja => format!("インデックスが範囲外です。{prefix}folder でリストを確認してください。"), Lang::Ja => {
format!("インデックスが範囲外です。{prefix}folder でリストを確認してください。")
}
Lang::Ko => format!("인덱스가 범위를 벗어났습니다. {prefix}folder로 목록을 확인하세요."), Lang::Ko => format!("인덱스가 범위를 벗어났습니다. {prefix}folder로 목록을 확인하세요."),
Lang::Es => format!("Índice fuera de rango. Usa {prefix}folder para ver la lista."), Lang::Es => format!("Índice fuera de rango. Usa {prefix}folder para ver la lista."),
Lang::De => format!("Index außerhalb des Bereichs. {prefix}folder verwenden, um aufzulisten."), Lang::De => {
format!("Index außerhalb des Bereichs. {prefix}folder verwenden, um aufzulisten.")
}
Lang::Fr => format!("Index hors limites. Utilisez {prefix}folder pour lister."), Lang::Fr => format!("Index hors limites. Utilisez {prefix}folder pour lister."),
Lang::Pt => format!("Índice fora de intervalo. Use {prefix}folder para listar."), Lang::Pt => format!("Índice fora de intervalo. Use {prefix}folder para listar."),
Lang::Ar => format!("الفهرس خارج النطاق. استخدم {prefix}folder لعرض القائمة."), Lang::Ar => format!("الفهرس خارج النطاق. استخدم {prefix}folder لعرض القائمة."),
@@ -842,7 +835,9 @@ pub fn no_folder_selected(lang: Lang, prefix: &str) -> String {
match lang { match lang {
Lang::ZhCn => format!("未选择工作目录,请先使用 {prefix}folder 选择。"), Lang::ZhCn => format!("未选择工作目录,请先使用 {prefix}folder 选择。"),
Lang::ZhTw => format!("未選擇工作目錄,請先使用 {prefix}folder 選擇。"), Lang::ZhTw => format!("未選擇工作目錄,請先使用 {prefix}folder 選擇。"),
Lang::Ja => format!("フォルダが選択されていません。先に {prefix}folder を使用してください。"), Lang::Ja => {
format!("フォルダが選択されていません。先に {prefix}folder を使用してください。")
}
Lang::Ko => format!("폴더가 선택되지 않았습니다. 먼저 {prefix}folder를 사용하세요."), Lang::Ko => format!("폴더가 선택되지 않았습니다. 먼저 {prefix}folder를 사용하세요."),
Lang::Es => format!("Ninguna carpeta seleccionada. Usa {prefix}folder primero."), Lang::Es => format!("Ninguna carpeta seleccionada. Usa {prefix}folder primero."),
Lang::De => format!("Kein Ordner ausgewählt. Zuerst {prefix}folder verwenden."), Lang::De => format!("Kein Ordner ausgewählt. Zuerst {prefix}folder verwenden."),
@@ -873,12 +868,22 @@ pub fn agent_select_hint(lang: Lang, prefix: &str) -> String {
match lang { match lang {
Lang::ZhCn => format!("回复 {prefix}agent <数字> 或 {prefix}agent <名称> 选择。"), Lang::ZhCn => format!("回复 {prefix}agent <数字> 或 {prefix}agent <名称> 选择。"),
Lang::ZhTw => format!("回覆 {prefix}agent <數字> 或 {prefix}agent <名稱> 選擇。"), Lang::ZhTw => format!("回覆 {prefix}agent <數字> 或 {prefix}agent <名稱> 選擇。"),
Lang::Ja => format!("{prefix}agent <番号> または {prefix}agent <名前> で選択してください。"), Lang::Ja => {
format!("{prefix}agent <番号> または {prefix}agent <名前> で選択してください。")
}
Lang::Ko => format!("{prefix}agent <번호> 또는 {prefix}agent <이름>으로 선택하세요."), Lang::Ko => format!("{prefix}agent <번호> 또는 {prefix}agent <이름>으로 선택하세요."),
Lang::Es => format!("Responde {prefix}agent <número> o {prefix}agent <nombre> para seleccionar."), Lang::Es => {
Lang::De => format!("Antworte {prefix}agent <Nummer> oder {prefix}agent <Name> zur Auswahl."), format!("Responde {prefix}agent <número> o {prefix}agent <nombre> para seleccionar.")
Lang::Fr => format!("Répondez {prefix}agent <numéro> ou {prefix}agent <nom> pour sélectionner."), }
Lang::Pt => format!("Responda {prefix}agent <número> ou {prefix}agent <nome> para selecionar."), Lang::De => {
format!("Antworte {prefix}agent <Nummer> oder {prefix}agent <Name> zur Auswahl.")
}
Lang::Fr => {
format!("Répondez {prefix}agent <numéro> ou {prefix}agent <nom> pour sélectionner.")
}
Lang::Pt => {
format!("Responda {prefix}agent <número> ou {prefix}agent <nome> para selecionar.")
}
Lang::Ar => format!("أجب بـ {prefix}agent <رقم> أو {prefix}agent <اسم> للاختيار."), Lang::Ar => format!("أجب بـ {prefix}agent <رقم> أو {prefix}agent <اسم> للاختيار."),
Lang::En => format!("Reply {prefix}agent <number> or {prefix}agent <name> to select."), Lang::En => format!("Reply {prefix}agent <number> or {prefix}agent <name> to select."),
} }
@@ -891,7 +896,9 @@ pub fn agent_index_out_of_range(lang: Lang, prefix: &str) -> String {
Lang::Ja => format!("インデックスが範囲外です。{prefix}agent でリストを確認してください。"), Lang::Ja => format!("インデックスが範囲外です。{prefix}agent でリストを確認してください。"),
Lang::Ko => format!("인덱스가 범위를 벗어났습니다. {prefix}agent로 목록을 확인하세요."), Lang::Ko => format!("인덱스가 범위를 벗어났습니다. {prefix}agent로 목록을 확인하세요."),
Lang::Es => format!("Índice fuera de rango. Usa {prefix}agent para ver la lista."), Lang::Es => format!("Índice fuera de rango. Usa {prefix}agent para ver la lista."),
Lang::De => format!("Index außerhalb des Bereichs. {prefix}agent verwenden, um aufzulisten."), Lang::De => {
format!("Index außerhalb des Bereichs. {prefix}agent verwenden, um aufzulisten.")
}
Lang::Fr => format!("Index hors limites. Utilisez {prefix}agent pour lister."), Lang::Fr => format!("Index hors limites. Utilisez {prefix}agent pour lister."),
Lang::Pt => format!("Índice fora de intervalo. Use {prefix}agent para listar."), Lang::Pt => format!("Índice fora de intervalo. Use {prefix}agent para listar."),
Lang::Ar => format!("الفهرس خارج النطاق. استخدم {prefix}agent لعرض القائمة."), Lang::Ar => format!("الفهرس خارج النطاق. استخدم {prefix}agent لعرض القائمة."),
@@ -1299,7 +1306,9 @@ pub fn no_active_session_use_task(lang: Lang, prefix: &str) -> String {
match lang { match lang {
Lang::ZhCn => format!("没有活跃的会话,请使用 {prefix}task 开始新任务。"), Lang::ZhCn => format!("没有活跃的会话,请使用 {prefix}task 开始新任务。"),
Lang::ZhTw => format!("沒有活躍的對話,請使用 {prefix}task 開始新任務。"), Lang::ZhTw => format!("沒有活躍的對話,請使用 {prefix}task 開始新任務。"),
Lang::Ja => format!("アクティブなセッションがありません。{prefix}task で開始してください。"), Lang::Ja => {
format!("アクティブなセッションがありません。{prefix}task で開始してください。")
}
Lang::Ko => format!("활성 세션이 없습니다. {prefix}task로 시작하세요."), Lang::Ko => format!("활성 세션이 없습니다. {prefix}task로 시작하세요."),
Lang::Es => format!("No hay sesión activa. Usa {prefix}task para iniciar una."), Lang::Es => format!("No hay sesión activa. Usa {prefix}task para iniciar una."),
Lang::De => format!("Keine aktive Sitzung. {prefix}task zum Starten verwenden."), Lang::De => format!("Keine aktive Sitzung. {prefix}task zum Starten verwenden."),
@@ -1314,11 +1323,17 @@ pub fn session_connection_lost(lang: Lang, prefix: &str) -> String {
match lang { match lang {
Lang::ZhCn => format!("会话连接已断开,请使用 {prefix}task 开始新任务。"), Lang::ZhCn => format!("会话连接已断开,请使用 {prefix}task 开始新任务。"),
Lang::ZhTw => format!("對話連線已斷開,請使用 {prefix}task 開始新任務。"), Lang::ZhTw => format!("對話連線已斷開,請使用 {prefix}task 開始新任務。"),
Lang::Ja => format!("セッション接続が切断されました。{prefix}task で新しく開始してください。"), Lang::Ja => {
format!("セッション接続が切断されました。{prefix}task で新しく開始してください。")
}
Lang::Ko => format!("세션 연결이 끊어졌습니다. {prefix}task로 새로 시작하세요."), Lang::Ko => format!("세션 연결이 끊어졌습니다. {prefix}task로 새로 시작하세요."),
Lang::Es => format!("Conexión de sesión perdida. Usa {prefix}task para iniciar una nueva."), Lang::Es => format!("Conexión de sesión perdida. Usa {prefix}task para iniciar una nueva."),
Lang::De => format!("Sitzungsverbindung verloren. {prefix}task für neue Sitzung verwenden."), Lang::De => {
Lang::Fr => format!("Connexion de session perdue. Utilisez {prefix}task pour en démarrer une nouvelle."), format!("Sitzungsverbindung verloren. {prefix}task für neue Sitzung verwenden.")
}
Lang::Fr => format!(
"Connexion de session perdue. Utilisez {prefix}task pour en démarrer une nouvelle."
),
Lang::Pt => format!("Conexão da sessão perdida. Use {prefix}task para iniciar uma nova."), Lang::Pt => format!("Conexão da sessão perdida. Use {prefix}task para iniciar uma nova."),
Lang::Ar => format!("انقطع اتصال الجلسة. استخدم {prefix}task لبدء جلسة جديدة."), Lang::Ar => format!("انقطع اتصال الجلسة. استخدم {prefix}task لبدء جلسة جديدة."),
Lang::En => format!("Session connection lost. Use {prefix}task to start a new one."), Lang::En => format!("Session connection lost. Use {prefix}task to start a new one."),
+29 -31
View File
@@ -268,28 +268,26 @@ impl ChatChannelManager {
} }
async fn auto_connect_channels(&self, db_conn: &DatabaseConnection) { async fn auto_connect_channels(&self, db_conn: &DatabaseConnection) {
let channels = let channels = match crate::db::service::chat_channel_service::list_enabled(db_conn).await {
match crate::db::service::chat_channel_service::list_enabled(db_conn).await { Ok(c) => c,
Ok(c) => c, Err(e) => {
Err(e) => { eprintln!("[ChatChannel] failed to load enabled channels: {e}");
eprintln!("[ChatChannel] failed to load enabled channels: {e}"); return;
return; }
} };
};
for ch in channels { for ch in channels {
let channel_type: ChannelType = match serde_json::from_value( let channel_type: ChannelType =
serde_json::Value::String(ch.channel_type.clone()), match serde_json::from_value(serde_json::Value::String(ch.channel_type.clone())) {
) { Ok(t) => t,
Ok(t) => t, Err(_) => {
Err(_) => { eprintln!(
eprintln!( "[ChatChannel] unknown channel type '{}' for '{}' (id={}), skipping",
"[ChatChannel] unknown channel type '{}' for '{}' (id={}), skipping", ch.channel_type, ch.name, ch.id
ch.channel_type, ch.name, ch.id );
); continue;
continue; }
} };
};
let config: serde_json::Value = match serde_json::from_str(&ch.config_json) { let config: serde_json::Value = match serde_json::from_str(&ch.config_json) {
Ok(v) => v, Ok(v) => v,
@@ -313,17 +311,17 @@ impl ChatChannelManager {
} }
}; };
let backend = let backend = match super::backends::create_backend(ch.id, channel_type, &config, token)
match super::backends::create_backend(ch.id, channel_type, &config, token) { {
Ok(b) => b, Ok(b) => b,
Err(e) => { Err(e) => {
eprintln!( eprintln!(
"[ChatChannel] failed to create backend for '{}' (id={}): {e}", "[ChatChannel] failed to create backend for '{}' (id={}): {e}",
ch.name, ch.id ch.name, ch.id
); );
continue; continue;
} }
}; };
if let Err(e) = self if let Err(e) = self
.add_channel(ch.id, ch.name.clone(), channel_type, backend) .add_channel(ch.id, ch.name.clone(), channel_type, backend)
+5 -7
View File
@@ -8,7 +8,9 @@ use super::i18n::Lang;
use super::manager::ChatChannelManager; use super::manager::ChatChannelManager;
use super::message_formatter::{self, DailyReportData}; use super::message_formatter::{self, DailyReportData};
use crate::db::entities::conversation; use crate::db::entities::conversation;
use crate::db::service::{app_metadata_service, chat_channel_message_log_service, chat_channel_service}; use crate::db::service::{
app_metadata_service, chat_channel_message_log_service, chat_channel_service,
};
const MESSAGE_LANGUAGE_KEY: &str = "chat_message_language"; const MESSAGE_LANGUAGE_KEY: &str = "chat_message_language";
/// Days to retain message logs before cleanup. /// Days to retain message logs before cleanup.
@@ -60,10 +62,7 @@ pub fn spawn_daily_report_scheduler(
continue; continue;
} }
let report_time = ch let report_time = ch.daily_report_time.as_deref().unwrap_or("18:00");
.daily_report_time
.as_deref()
.unwrap_or("18:00");
if current_time != report_time { if current_time != report_time {
continue; continue;
@@ -124,8 +123,7 @@ async fn generate_daily_report(db: &DatabaseConnection) -> DailyReportData {
.await .await
.unwrap_or_default(); .unwrap_or_default();
let mut by_agent: std::collections::HashMap<String, u32> = let mut by_agent: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
std::collections::HashMap::new();
let mut folder_ids: HashSet<i32> = HashSet::new(); let mut folder_ids: HashSet<i32> = HashSet::new();
let mut activities: Vec<String> = Vec::new(); let mut activities: Vec<String> = Vec::new();
+6 -6
View File
@@ -54,9 +54,9 @@ impl SessionBridge {
} }
pub fn find_by_sender(&self, channel_id: i32, sender_id: &str) -> Option<&ActiveSession> { pub fn find_by_sender(&self, channel_id: i32, sender_id: &str) -> Option<&ActiveSession> {
self.sessions.values().find(|s| { self.sessions
s.channel_id == channel_id && s.sender_id == sender_id .values()
}) .find(|s| s.channel_id == channel_id && s.sender_id == sender_id)
} }
pub fn find_by_sender_mut( pub fn find_by_sender_mut(
@@ -64,9 +64,9 @@ impl SessionBridge {
channel_id: i32, channel_id: i32,
sender_id: &str, sender_id: &str,
) -> Option<&mut ActiveSession> { ) -> Option<&mut ActiveSession> {
self.sessions.values_mut().find(|s| { self.sessions
s.channel_id == channel_id && s.sender_id == sender_id .values_mut()
}) .find(|s| s.channel_id == channel_id && s.sender_id == sender_id)
} }
pub fn all_sessions(&self) -> impl Iterator<Item = &ActiveSession> { pub fn all_sessions(&self) -> impl Iterator<Item = &ActiveSession> {
+55 -71
View File
@@ -16,6 +16,17 @@ use crate::db::service::{conversation_service, folder_service, sender_context_se
use crate::models::agent::AgentType; use crate::models::agent::AgentType;
use crate::web::event_bridge::EventEmitter; use crate::web::event_bridge::EventEmitter;
pub struct FollowupRequest<'a> {
pub db: &'a DatabaseConnection,
pub text: &'a str,
pub channel_id: i32,
pub sender_id: &'a str,
pub conn_mgr: &'a ConnectionManager,
pub bridge: &'a Arc<Mutex<SessionBridge>>,
pub lang: Lang,
pub prefix: &'a str,
}
// ── /folder ── // ── /folder ──
pub async fn handle_folder( pub async fn handle_folder(
@@ -70,13 +81,7 @@ async fn list_folders(
.map(|id| id == f.id) .map(|id| id == f.id)
.unwrap_or(false); .unwrap_or(false);
let marker = if current { " [*]" } else { "" }; let marker = if current { " [*]" } else { "" };
body.push_str(&format!( body.push_str(&format!("{}. {}{} ({})\n", i + 1, f.name, marker, f.path));
"{}. {}{} ({})\n",
i + 1,
f.name,
marker,
f.path
));
} }
body.push_str(&format!("\n{}", i18n::folder_select_hint(lang, prefix))); body.push_str(&format!("\n{}", i18n::folder_select_hint(lang, prefix)));
@@ -107,8 +112,7 @@ async fn select_folder_by_index(
return RichMessage::info(i18n::folder_index_out_of_range(lang, prefix)); return RichMessage::info(i18n::folder_index_out_of_range(lang, prefix));
}; };
let _ = sender_context_service::update_folder(db, channel_id, sender_id, Some(folder.id)) let _ = sender_context_service::update_folder(db, channel_id, sender_id, Some(folder.id)).await;
.await;
RichMessage::info(format!("{} ({})", folder.name, folder.path)) RichMessage::info(format!("{} ({})", folder.name, folder.path))
.with_title(i18n::folder_selected_title(lang)) .with_title(i18n::folder_selected_title(lang))
@@ -128,8 +132,7 @@ async fn select_folder_by_path(
} }
}; };
let _ = let _ = sender_context_service::update_folder(db, channel_id, sender_id, Some(entry.id)).await;
sender_context_service::update_folder(db, channel_id, sender_id, Some(entry.id)).await;
RichMessage::info(format!("{} ({})", entry.name, entry.path)) RichMessage::info(format!("{} ({})", entry.name, entry.path))
.with_title(i18n::folder_selected_title(lang)) .with_title(i18n::folder_selected_title(lang))
@@ -317,10 +320,7 @@ pub async fn handle_task(
conversation::ConversationStatus::Cancelled, conversation::ConversationStatus::Cancelled,
) )
.await; .await;
return RichMessage::error(format!( return RichMessage::error(format!("{}{e}", i18n::failed_to_start_agent_label(lang)));
"{}{e}",
i18n::failed_to_start_agent_label(lang)
));
} }
}; };
@@ -352,11 +352,8 @@ pub async fn handle_task(
) )
.await; .await;
RichMessage::info(format!( RichMessage::info(format!("[{}] #{} @ {}", agent_type, conv.id, folder.name,))
"[{}] #{} @ {}", .with_title(i18n::task_started_title(lang))
agent_type, conv.id, folder.name,
))
.with_title(i18n::task_started_title(lang))
} }
// ── /sessions ── // ── /sessions ──
@@ -401,16 +398,16 @@ pub async fn handle_sessions(
{ {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
return RichMessage::error(format!( return RichMessage::error(format!("{}{e}", i18n::failed_to_list_sessions_label(lang)));
"{}{e}",
i18n::failed_to_list_sessions_label(lang)
));
} }
}; };
if convs.is_empty() { if convs.is_empty() {
return RichMessage::info(i18n::no_active_sessions_in_folder(lang)) return RichMessage::info(i18n::no_active_sessions_in_folder(lang)).with_title(format!(
.with_title(format!("{} - {}", i18n::sessions_title(lang), folder.name)); "{} - {}",
i18n::sessions_title(lang),
folder.name
));
} }
let mut body = String::new(); let mut body = String::new();
@@ -433,8 +430,11 @@ pub async fn handle_sessions(
body.push_str(&format!("\n{}", i18n::sessions_resume_hint(lang, prefix))); body.push_str(&format!("\n{}", i18n::sessions_resume_hint(lang, prefix)));
RichMessage::info(body.trim_end()) RichMessage::info(body.trim_end()).with_title(format!(
.with_title(format!("{} - {}", i18n::sessions_title(lang), folder.name)) "{} - {}",
i18n::sessions_title(lang),
folder.name
))
} }
// ── /resume ── // ── /resume ──
@@ -491,10 +491,7 @@ pub async fn handle_resume(
{ {
Ok(id) => id, Ok(id) => id,
Err(e) => { Err(e) => {
return RichMessage::error(format!( return RichMessage::error(format!("{}{e}", i18n::failed_to_start_agent_label(lang)));
"{}{e}",
i18n::failed_to_start_agent_label(lang)
));
} }
}; };
@@ -579,8 +576,7 @@ pub async fn handle_cancel(
// Clear session from context // Clear session from context
let _ = sender_context_service::clear_session(db, channel_id, sender_id).await; let _ = sender_context_service::clear_session(db, channel_id, sender_id).await;
RichMessage::info(i18n::task_cancelled_body(lang)) RichMessage::info(i18n::task_cancelled_body(lang)).with_title(i18n::task_cancelled_title(lang))
.with_title(i18n::task_cancelled_title(lang))
} }
// ── /approve, /deny ── // ── /approve, /deny ──
@@ -661,8 +657,7 @@ pub async fn handle_permission_response(
// Update auto_approve if requested // Update auto_approve if requested
if always && approve { if always && approve {
let _ = let _ = sender_context_service::update_auto_approve(db, channel_id, sender_id, true).await;
sender_context_service::update_auto_approve(db, channel_id, sender_id, true).await;
} }
let action = if approve { let action = if approve {
@@ -680,66 +675,58 @@ pub async fn handle_permission_response(
// ── follow-up (non-command text) ── // ── follow-up (non-command text) ──
pub async fn handle_followup( pub async fn handle_followup(req: FollowupRequest<'_>) -> RichMessage {
db: &DatabaseConnection, let ctx =
text: &str, match sender_context_service::get_or_create(req.db, req.channel_id, req.sender_id).await {
channel_id: i32, Ok(c) => c,
sender_id: &str, Err(e) => {
conn_mgr: &ConnectionManager, return RichMessage::error(format!(
bridge: &Arc<Mutex<SessionBridge>>, "{}{e}",
lang: Lang, i18n::failed_to_load_context_label(req.lang)
prefix: &str, ));
) -> RichMessage { }
let ctx = match sender_context_service::get_or_create(db, channel_id, sender_id).await { };
Ok(c) => c,
Err(e) => {
return RichMessage::error(format!("{}{e}", i18n::failed_to_load_context_label(lang)));
}
};
let connection_id = match &ctx.current_connection_id { let connection_id = match &ctx.current_connection_id {
Some(id) => id.clone(), Some(id) => id.clone(),
None => { None => {
return RichMessage::info(i18n::no_active_session_use_task(lang, prefix)); return RichMessage::info(i18n::no_active_session_use_task(req.lang, req.prefix));
} }
}; };
// Check connection exists in bridge // Check connection exists in bridge
{ {
let bridge_guard = bridge.lock().await; let bridge_guard = req.bridge.lock().await;
if bridge_guard.get(&connection_id).is_none() { if bridge_guard.get(&connection_id).is_none() {
// Connection lost, clear context // Connection lost, clear context
drop(bridge_guard); drop(bridge_guard);
let _ = sender_context_service::clear_session(db, channel_id, sender_id).await; let _ =
return RichMessage::info(i18n::session_connection_lost(lang, prefix)); sender_context_service::clear_session(req.db, req.channel_id, req.sender_id).await;
return RichMessage::info(i18n::session_connection_lost(req.lang, req.prefix));
} }
} }
// Send prompt to agent // Send prompt to agent
let blocks = vec![PromptInputBlock::Text { let blocks = vec![PromptInputBlock::Text {
text: text.to_string(), text: req.text.to_string(),
}]; }];
if let Err(e) = conn_mgr.send_prompt(&connection_id, blocks).await { if let Err(e) = req.conn_mgr.send_prompt(&connection_id, blocks).await {
// Connection may have died // Connection may have died
bridge.lock().await.remove(&connection_id); req.bridge.lock().await.remove(&connection_id);
let _ = sender_context_service::clear_session(db, channel_id, sender_id).await; let _ = sender_context_service::clear_session(req.db, req.channel_id, req.sender_id).await;
return RichMessage::error(format!( return RichMessage::error(format!(
"{}{e}", "{}{e}",
i18n::failed_to_send_message_label(lang) i18n::failed_to_send_message_label(req.lang)
)); ));
} }
RichMessage::info(i18n::message_sent(lang)) RichMessage::info(i18n::message_sent(req.lang))
} }
// ── /resume (list recent) ── // ── /resume (list recent) ──
async fn list_recent_sessions( async fn list_recent_sessions(db: &DatabaseConnection, lang: Lang, prefix: &str) -> RichMessage {
db: &DatabaseConnection,
lang: Lang,
prefix: &str,
) -> RichMessage {
let recent = match conversation::Entity::find() let recent = match conversation::Entity::find()
.filter(conversation::Column::DeletedAt.is_null()) .filter(conversation::Column::DeletedAt.is_null())
.order_by_desc(conversation::Column::CreatedAt) .order_by_desc(conversation::Column::CreatedAt)
@@ -768,10 +755,7 @@ async fn list_recent_sessions(
let title = conv.title.as_deref().unwrap_or(i18n::untitled(lang)); let title = conv.title.as_deref().unwrap_or(i18n::untitled(lang));
let agent = &conv.agent_type; let agent = &conv.agent_type;
let time = conv.created_at.format("%m-%d %H:%M"); let time = conv.created_at.format("%m-%d %H:%M");
body.push_str(&format!( body.push_str(&format!("#{} [{}] {} ({})\n", conv.id, agent, title, time,));
"#{} [{}] {} ({})\n",
conv.id, agent, title, time,
));
} }
body.push_str(&format!("\n{}", i18n::recent_resume_hint(lang, prefix))); body.push_str(&format!("\n{}", i18n::recent_resume_hint(lang, prefix)));
@@ -10,9 +10,7 @@ use super::session_bridge::{PendingPermission, SessionBridge};
use super::types::{MessageLevel, RichMessage}; use super::types::{MessageLevel, RichMessage};
use crate::acp::manager::ConnectionManager; use crate::acp::manager::ConnectionManager;
use crate::acp::types::PromptInputBlock; use crate::acp::types::PromptInputBlock;
use crate::db::service::{ use crate::db::service::{app_metadata_service, conversation_service, sender_context_service};
app_metadata_service, conversation_service, sender_context_service,
};
use crate::web::event_bridge::WebEventBroadcaster; use crate::web::event_bridge::WebEventBroadcaster;
use super::manager::ChatChannelManager; use super::manager::ChatChannelManager;
@@ -132,10 +130,7 @@ async fn handle_acp_event_payload(
} }
"content_delta" => { "content_delta" => {
let text = payload let text = payload.get("text").and_then(|v| v.as_str()).unwrap_or("");
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("");
// Collect flush info under the lock, then release before any IO. // Collect flush info under the lock, then release before any IO.
let flush_info: Option<(i32, String, Option<String>)> = { let flush_info: Option<(i32, String, Option<String>)> = {
@@ -245,14 +240,11 @@ async fn handle_acp_event_payload(
let channel_id = session.channel_id; let channel_id = session.channel_id;
let sender_id = session.sender_id.clone(); let sender_id = session.sender_id.clone();
let auto_approve = sender_context_service::get_or_create( let auto_approve =
db, sender_context_service::get_or_create(db, channel_id, &sender_id)
channel_id, .await
&sender_id, .map(|ctx| ctx.auto_approve)
) .unwrap_or(false);
.await
.map(|ctx| ctx.auto_approve)
.unwrap_or(false);
if auto_approve { if auto_approve {
let option_id = options let option_id = options
@@ -286,8 +278,7 @@ async fn handle_acp_event_payload(
serde_json::Value::Null => None, serde_json::Value::Null => None,
other => Some(other.to_string()), other => Some(other.to_string()),
}); });
let tool_desc = let tool_desc = format_tool_call_detail(tool_title, raw_input_str.as_deref());
format_tool_call_detail(tool_title, raw_input_str.as_deref());
session.permission_pending = Some(PendingPermission { session.permission_pending = Some(PendingPermission {
request_id: request_id.to_string(), request_id: request_id.to_string(),
@@ -407,16 +398,12 @@ async fn handle_acp_event_payload(
crate::db::entities::conversation::ConversationStatus::Cancelled, crate::db::entities::conversation::ConversationStatus::Cancelled,
) )
.await; .await;
let _ = let _ = sender_context_service::clear_session(db, channel_id, &sender_id).await;
sender_context_service::clear_session(db, channel_id, &sender_id).await;
} }
} }
"status_changed" => { "status_changed" => {
let status = payload let status = payload.get("status").and_then(|v| v.as_str()).unwrap_or("");
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("");
if status == "disconnected" || status == "error" { if status == "disconnected" || status == "error" {
let mut guard = bridge.lock().await; let mut guard = bridge.lock().await;
@@ -425,8 +412,7 @@ async fn handle_acp_event_payload(
let sender_id = session.sender_id.clone(); let sender_id = session.sender_id.clone();
drop(guard); drop(guard);
let _ = let _ = sender_context_service::clear_session(db, channel_id, &sender_id).await;
sender_context_service::clear_session(db, channel_id, &sender_id).await;
} }
} }
} }
@@ -653,7 +639,11 @@ fn format_tool_call_detail(title: &str, raw_input: Option<&str>) -> String {
if let Some(pattern) = obj.get("pattern").and_then(|v| v.as_str()) { if let Some(pattern) = obj.get("pattern").and_then(|v| v.as_str()) {
let path = obj.get("path").and_then(|v| v.as_str()); let path = obj.get("path").and_then(|v| v.as_str());
return if let Some(p) = path { return if let Some(p) = path {
format!("Grep: \"{}\" in {}", truncate_str(pattern, 40), short_path(p)) format!(
"Grep: \"{}\" in {}",
truncate_str(pattern, 40),
short_path(p)
)
} else { } else {
format!("Grep: \"{}\"", truncate_str(pattern, 60)) format!("Grep: \"{}\"", truncate_str(pattern, 60))
}; };
+2 -1
View File
@@ -147,7 +147,8 @@ impl InteractiveMessage {
.iter() .iter()
.map(|b| format!("[{}]", b.label)) .map(|b| format!("[{}]", b.label))
.collect(); .collect();
msg.body.push_str(&format!("\n\n{}", button_text.join(" "))); msg.body
.push_str(&format!("\n\n{}", button_text.join(" ")));
} }
msg msg
} }
+179 -126
View File
@@ -8,9 +8,9 @@ use tauri::State;
use crate::acp::binary_cache; use crate::acp::binary_cache;
use crate::acp::error::AcpError; use crate::acp::error::AcpError;
use crate::acp::opencode_plugins::{self, PluginCheckSummary};
#[cfg(feature = "tauri-runtime")] #[cfg(feature = "tauri-runtime")]
use crate::acp::manager::ConnectionManager; use crate::acp::manager::ConnectionManager;
use crate::acp::opencode_plugins::{self, PluginCheckSummary};
use crate::acp::preflight::{self, PreflightResult}; use crate::acp::preflight::{self, PreflightResult};
use crate::acp::registry; use crate::acp::registry;
use crate::acp::types::{ use crate::acp::types::{
@@ -154,9 +154,7 @@ pub(crate) fn verify_agent_installed(agent_type: AgentType) -> Result<(), AcpErr
} }
Ok(()) Ok(())
} }
registry::AgentDistribution::Binary { registry::AgentDistribution::Binary { cmd, platforms, .. } => {
cmd, platforms, ..
} => {
let platform = registry::current_platform(); let platform = registry::current_platform();
if !platforms.iter().any(|p| p.platform == platform) { if !platforms.iter().any(|p| p.platform == platform) {
return Err(AcpError::PlatformNotSupported(format!( return Err(AcpError::PlatformNotSupported(format!(
@@ -212,7 +210,11 @@ async fn npm_list_version(
prefix: Option<&std::path::Path>, prefix: Option<&std::path::Path>,
) -> Option<String> { ) -> Option<String> {
let mut cmd = crate::process::tokio_command(npm_path); let mut cmd = crate::process::tokio_command(npm_path);
cmd.arg("list").arg("-g").arg(package_name).arg("--json").arg("--depth=0"); cmd.arg("list")
.arg("-g")
.arg(package_name)
.arg("--json")
.arg("--depth=0");
if let Some(p) = prefix { if let Some(p) = prefix {
cmd.arg(format!("--prefix={}", p.display())); cmd.arg(format!("--prefix={}", p.display()));
} }
@@ -266,9 +268,9 @@ async fn run_npm_streaming(
cmd.stdout(std::process::Stdio::piped()) cmd.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped()); .stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().map_err(|e| { let mut child = cmd
AcpError::protocol(format!("failed to spawn npm: {e}")) .spawn()
})?; .map_err(|e| AcpError::protocol(format!("failed to spawn npm: {e}")))?;
let stdout = child.stdout.take(); let stdout = child.stdout.take();
let stderr = child.stderr.take(); let stderr = child.stderr.take();
@@ -283,9 +285,7 @@ async fn run_npm_streaming(
let reader = BufReader::new(out); let reader = BufReader::new(out);
let mut lines = reader.lines(); let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await { while let Ok(Some(line)) = lines.next_line().await {
emit_agent_install_event( emit_agent_install_event(&emitter, &task_id, AgentInstallEventKind::Log, &line);
&emitter, &task_id, AgentInstallEventKind::Log, &line,
);
} }
} }
} }
@@ -300,9 +300,7 @@ async fn run_npm_streaming(
let reader = BufReader::new(err); let reader = BufReader::new(err);
let mut lines = reader.lines(); let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await { while let Ok(Some(line)) = lines.next_line().await {
emit_agent_install_event( emit_agent_install_event(&emitter, &task_id, AgentInstallEventKind::Log, &line);
&emitter, &task_id, AgentInstallEventKind::Log, &line,
);
if !collected.is_empty() { if !collected.is_empty() {
collected.push('\n'); collected.push('\n');
} }
@@ -316,9 +314,10 @@ async fn run_npm_streaming(
let (_, stderr_result) = tokio::join!(stdout_handle, stderr_handle); let (_, stderr_result) = tokio::join!(stdout_handle, stderr_handle);
let collected_stderr = stderr_result.unwrap_or_default(); let collected_stderr = stderr_result.unwrap_or_default();
let status = child.wait().await.map_err(|e| { let status = child
AcpError::protocol(format!("failed to wait for npm process: {e}")) .wait()
})?; .await
.map_err(|e| AcpError::protocol(format!("failed to wait for npm process: {e}")))?;
Ok((status.success(), collected_stderr)) Ok((status.success(), collected_stderr))
} }
@@ -331,49 +330,58 @@ async fn install_npm_global_package_streaming(
let registry_arg = format!("--registry={NPM_OFFICIAL_REGISTRY}"); let registry_arg = format!("--registry={NPM_OFFICIAL_REGISTRY}");
emit_agent_install_event( emit_agent_install_event(
emitter, task_id, AgentInstallEventKind::Log, emitter,
task_id,
AgentInstallEventKind::Log,
format!("$ npm install -g {package}"), format!("$ npm install -g {package}"),
); );
let (success, stderr) = run_npm_streaming( let (success, stderr) =
&["install", "-g", &registry_arg, package], run_npm_streaming(&["install", "-g", &registry_arg, package], task_id, emitter).await?;
task_id,
emitter,
).await?;
if !success { if !success {
// EACCES: permission denied — retry with a user-local --prefix so // EACCES: permission denied — retry with a user-local --prefix so
// we don't require root/sudo on macOS / Linux. // we don't require root/sudo on macOS / Linux.
if stderr.contains("EACCES") { if stderr.contains("EACCES") {
emit_agent_install_event( emit_agent_install_event(
emitter, task_id, AgentInstallEventKind::Log, emitter,
task_id,
AgentInstallEventKind::Log,
"Permission denied, retrying with user prefix...", "Permission denied, retrying with user prefix...",
); );
return install_npm_to_user_prefix_streaming( return install_npm_to_user_prefix_streaming(package, &registry_arg, task_id, emitter)
package, &registry_arg, task_id, emitter, .await;
).await;
} }
// EEXIST: file conflict — retry with --force to overwrite // EEXIST: file conflict — retry with --force to overwrite
if stderr.contains("EEXIST") { if stderr.contains("EEXIST") {
emit_agent_install_event( emit_agent_install_event(
emitter, task_id, AgentInstallEventKind::Log, emitter,
task_id,
AgentInstallEventKind::Log,
"File conflict, retrying with --force...", "File conflict, retrying with --force...",
); );
let (retry_success, retry_stderr) = run_npm_streaming( let (retry_success, retry_stderr) = run_npm_streaming(
&["install", "-g", "--force", &registry_arg, package], &["install", "-g", "--force", &registry_arg, package],
task_id, task_id,
emitter, emitter,
).await?; )
.await?;
if !retry_success { if !retry_success {
if retry_stderr.contains("EACCES") { if retry_stderr.contains("EACCES") {
emit_agent_install_event( emit_agent_install_event(
emitter, task_id, AgentInstallEventKind::Log, emitter,
task_id,
AgentInstallEventKind::Log,
"Permission denied on --force retry, falling back to user prefix...", "Permission denied on --force retry, falling back to user prefix...",
); );
return install_npm_to_user_prefix_streaming( return install_npm_to_user_prefix_streaming(
package, &registry_arg, task_id, emitter, package,
).await; &registry_arg,
task_id,
emitter,
)
.await;
} }
let err = retry_stderr.trim().to_string(); let err = retry_stderr.trim().to_string();
let msg = if err.is_empty() { let msg = if err.is_empty() {
@@ -424,7 +432,9 @@ async fn install_npm_to_user_prefix_streaming(
let prefix_arg = format!("--prefix={}", prefix.display()); let prefix_arg = format!("--prefix={}", prefix.display());
emit_agent_install_event( emit_agent_install_event(
emitter, task_id, AgentInstallEventKind::Log, emitter,
task_id,
AgentInstallEventKind::Log,
format!("$ npm install -g --prefix={} {package}", prefix.display()), format!("$ npm install -g --prefix={} {package}", prefix.display()),
); );
@@ -432,21 +442,32 @@ async fn install_npm_to_user_prefix_streaming(
&["install", "-g", &prefix_arg, registry_arg, package], &["install", "-g", &prefix_arg, registry_arg, package],
task_id, task_id,
emitter, emitter,
).await?; )
.await?;
if !success { if !success {
// EEXIST in the user prefix: retry with --force to overwrite stale files // EEXIST in the user prefix: retry with --force to overwrite stale files
// from a previous installation. // from a previous installation.
if stderr.contains("EEXIST") { if stderr.contains("EEXIST") {
emit_agent_install_event( emit_agent_install_event(
emitter, task_id, AgentInstallEventKind::Log, emitter,
task_id,
AgentInstallEventKind::Log,
"File conflict in user prefix, retrying with --force...", "File conflict in user prefix, retrying with --force...",
); );
let (force_success, force_stderr) = run_npm_streaming( let (force_success, force_stderr) = run_npm_streaming(
&["install", "-g", "--force", &prefix_arg, registry_arg, package], &[
"install",
"-g",
"--force",
&prefix_arg,
registry_arg,
package,
],
task_id, task_id,
emitter, emitter,
).await?; )
.await?;
if !force_success { if !force_success {
let err = force_stderr.trim().to_string(); let err = force_stderr.trim().to_string();
let msg = if err.is_empty() { let msg = if err.is_empty() {
@@ -854,10 +875,7 @@ fn persist_cline_local_config(config_patch_json: Option<&str>) -> Result<(), Acp
act_model_key.to_string(), act_model_key.to_string(),
serde_json::Value::String(model.clone()), serde_json::Value::String(model.clone()),
); );
gs_obj.insert( gs_obj.insert(plan_model_key.to_string(), serde_json::Value::String(model));
plan_model_key.to_string(),
serde_json::Value::String(model),
);
} }
None => { None => {
gs_obj.remove(act_model_key); gs_obj.remove(act_model_key);
@@ -888,9 +906,8 @@ fn persist_cline_local_config(config_patch_json: Option<&str>) -> Result<(), Acp
} }
if let Some(parent) = gs_path.parent() { if let Some(parent) = gs_path.parent() {
fs::create_dir_all(parent).map_err(|e| { fs::create_dir_all(parent)
AcpError::protocol(format!("create cline data directory failed: {e}")) .map_err(|e| AcpError::protocol(format!("create cline data directory failed: {e}")))?;
})?;
} }
let serialized_gs = serde_json::to_string_pretty(&gs) let serialized_gs = serde_json::to_string_pretty(&gs)
.map_err(|e| AcpError::protocol(format!("serialize cline globalState failed: {e}")))?; .map_err(|e| AcpError::protocol(format!("serialize cline globalState failed: {e}")))?;
@@ -917,10 +934,7 @@ fn persist_cline_local_config(config_patch_json: Option<&str>) -> Result<(), Acp
let key_field = cline_api_key_field_for_provider(&provider); let key_field = cline_api_key_field_for_provider(&provider);
match trim_non_empty(runtime.api_key) { match trim_non_empty(runtime.api_key) {
Some(api_key) => { Some(api_key) => {
secrets_obj.insert( secrets_obj.insert(key_field.to_string(), serde_json::Value::String(api_key));
key_field.to_string(),
serde_json::Value::String(api_key),
);
} }
None => { None => {
secrets_obj.remove(key_field); secrets_obj.remove(key_field);
@@ -928,9 +942,8 @@ fn persist_cline_local_config(config_patch_json: Option<&str>) -> Result<(), Acp
} }
if let Some(parent) = secrets_path.parent() { if let Some(parent) = secrets_path.parent() {
fs::create_dir_all(parent).map_err(|e| { fs::create_dir_all(parent)
AcpError::protocol(format!("create cline data directory failed: {e}")) .map_err(|e| AcpError::protocol(format!("create cline data directory failed: {e}")))?;
})?;
} }
let serialized_secrets = serde_json::to_string_pretty(&secrets) let serialized_secrets = serde_json::to_string_pretty(&secrets)
.map_err(|e| AcpError::protocol(format!("serialize cline secrets failed: {e}")))?; .map_err(|e| AcpError::protocol(format!("serialize cline secrets failed: {e}")))?;
@@ -1719,7 +1732,11 @@ fn trim_non_empty(value: Option<String>) -> Option<String> {
/// Shared by runtime env resolution, model-provider cascade, and config patching. /// Shared by runtime env resolution, model-provider cascade, and config patching.
fn agent_env_keys(agent_type: AgentType) -> (&'static str, &'static str, &'static str) { fn agent_env_keys(agent_type: AgentType) -> (&'static str, &'static str, &'static str) {
match agent_type { match agent_type {
AgentType::ClaudeCode => ("ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_MODEL"), AgentType::ClaudeCode => (
"ANTHROPIC_BASE_URL",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_MODEL",
),
AgentType::Gemini => ("GOOGLE_GEMINI_BASE_URL", "GEMINI_API_KEY", "GEMINI_MODEL"), AgentType::Gemini => ("GOOGLE_GEMINI_BASE_URL", "GEMINI_API_KEY", "GEMINI_MODEL"),
_ => ("OPENAI_BASE_URL", "OPENAI_API_KEY", "OPENAI_MODEL"), _ => ("OPENAI_BASE_URL", "OPENAI_API_KEY", "OPENAI_MODEL"),
} }
@@ -1814,11 +1831,17 @@ fn cascade_update_agent_config(
AgentType::ClaudeCode | AgentType::Gemini => { AgentType::ClaudeCode | AgentType::Gemini => {
// Write into config.env (not root-level) // Write into config.env (not root-level)
let mut env = serde_json::Map::new(); let mut env = serde_json::Map::new();
env.insert(url_key.to_string(), serde_json::Value::String(api_url.to_string())); env.insert(
env.insert(key_key.to_string(), serde_json::Value::String(api_key.to_string())); url_key.to_string(),
serde_json::Value::String(api_url.to_string()),
);
env.insert(
key_key.to_string(),
serde_json::Value::String(api_key.to_string()),
);
let patch = serde_json::json!({ "env": env }); let patch = serde_json::json!({ "env": env });
let patch_str = serde_json::to_string(&patch) let patch_str =
.map_err(|e| AcpError::protocol(e.to_string()))?; serde_json::to_string(&patch).map_err(|e| AcpError::protocol(e.to_string()))?;
persist_agent_local_config_json(agent_type, Some(&patch_str))?; persist_agent_local_config_json(agent_type, Some(&patch_str))?;
} }
AgentType::OpenClaw => { AgentType::OpenClaw => {
@@ -1855,7 +1878,10 @@ fn cascade_update_agent_config(
if api_url.trim().is_empty() { if api_url.trim().is_empty() {
table.remove("api_base_url"); table.remove("api_base_url");
} else { } else {
table.insert("api_base_url".to_string(), toml::Value::String(api_url.to_string())); table.insert(
"api_base_url".to_string(),
toml::Value::String(api_url.to_string()),
);
} }
} }
let toml_str = toml::to_string_pretty(&toml_value) let toml_str = toml::to_string_pretty(&toml_value)
@@ -1882,8 +1908,8 @@ fn cascade_update_agent_config(
persist_opencode_auth_json(&auth_str)?; persist_opencode_auth_json(&auth_str)?;
let patch = serde_json::json!({ "apiBaseUrl": api_url }); let patch = serde_json::json!({ "apiBaseUrl": api_url });
let patch_str = serde_json::to_string(&patch) let patch_str =
.map_err(|e| AcpError::protocol(e.to_string()))?; serde_json::to_string(&patch).map_err(|e| AcpError::protocol(e.to_string()))?;
persist_agent_local_config_json(agent_type, Some(&patch_str))?; persist_agent_local_config_json(agent_type, Some(&patch_str))?;
} }
AgentType::Cline => {} AgentType::Cline => {}
@@ -2106,17 +2132,11 @@ pub(crate) async fn acp_get_agent_status_core(
true, true,
setting.as_ref().and_then(|m| m.installed_version.clone()), setting.as_ref().and_then(|m| m.installed_version.clone()),
), ),
registry::AgentDistribution::Binary { registry::AgentDistribution::Binary { platforms, cmd, .. } => {
platforms, cmd, .. let detected = binary_cache::detect_installed_version(agent_type, cmd)
} => { .ok()
let detected = .flatten();
binary_cache::detect_installed_version(agent_type, cmd) (platforms.iter().any(|p| p.platform == platform), detected)
.ok()
.flatten();
(
platforms.iter().any(|p| p.platform == platform),
detected,
)
} }
}; };
@@ -2137,9 +2157,7 @@ pub async fn acp_get_agent_status(
acp_get_agent_status_core(agent_type, &db).await acp_get_agent_status_core(agent_type, &db).await
} }
pub(crate) async fn acp_list_agents_core( pub(crate) async fn acp_list_agents_core(db: &AppDatabase) -> Result<Vec<AcpAgentInfo>, AcpError> {
db: &AppDatabase,
) -> Result<Vec<AcpAgentInfo>, AcpError> {
let platform = registry::current_platform(); let platform = registry::current_platform();
let agent_types = registry::all_acp_agents(); let agent_types = registry::all_acp_agents();
@@ -2417,9 +2435,17 @@ pub async fn acp_update_agent_preferences(
) -> Result<(), AcpError> { ) -> Result<(), AcpError> {
let emitter = EventEmitter::Tauri(app); let emitter = EventEmitter::Tauri(app);
acp_update_agent_preferences_core( acp_update_agent_preferences_core(
agent_type, enabled, env, config_json, opencode_auth_json, agent_type,
codex_auth_json, codex_config_toml, &db, &emitter, enabled,
).await env,
config_json,
opencode_auth_json,
codex_auth_json,
codex_config_toml,
&db,
&emitter,
)
.await
} }
pub(crate) async fn acp_update_agent_env_core( pub(crate) async fn acp_update_agent_env_core(
@@ -2557,7 +2583,12 @@ pub async fn acp_update_agent_config(
) -> Result<(), AcpError> { ) -> Result<(), AcpError> {
let emitter = EventEmitter::Tauri(app); let emitter = EventEmitter::Tauri(app);
acp_update_agent_config_core( acp_update_agent_config_core(
agent_type, config_json, opencode_auth_json, codex_auth_json, codex_config_toml, &emitter, agent_type,
config_json,
opencode_auth_json,
codex_auth_json,
codex_config_toml,
&emitter,
) )
.await .await
} }
@@ -2589,17 +2620,25 @@ pub(crate) async fn acp_download_agent_binary_core(
})?; })?;
emit_agent_install_event( emit_agent_install_event(
emitter, &task_id, AgentInstallEventKind::Log, emitter,
&task_id,
AgentInstallEventKind::Log,
format!("Downloading {} v{version} for {platform}", meta.name), format!("Downloading {} v{version} for {platform}", meta.name),
); );
let emitter_clone = emitter.clone(); let emitter_clone = emitter.clone();
let task_id_clone = task_id.clone(); let task_id_clone = task_id.clone();
let _ = binary_cache::ensure_binary_for_agent_with_progress( let _ = binary_cache::ensure_binary_for_agent_with_progress(
agent_type, version, fallback.url, cmd, agent_type,
version,
fallback.url,
cmd,
move |msg| { move |msg| {
emit_agent_install_event( emit_agent_install_event(
&emitter_clone, &task_id_clone, AgentInstallEventKind::Log, msg, &emitter_clone,
&task_id_clone,
AgentInstallEventKind::Log,
msg,
); );
}, },
) )
@@ -2607,21 +2646,26 @@ pub(crate) async fn acp_download_agent_binary_core(
emit_acp_agents_updated(emitter, "binary_downloaded", Some(agent_type)); emit_acp_agents_updated(emitter, "binary_downloaded", Some(agent_type));
Ok(()) Ok(())
} }
registry::AgentDistribution::Npx { .. } => Err( registry::AgentDistribution::Npx { .. } => Err(AcpError::protocol(
AcpError::protocol("download is only supported for binary agents"), "download is only supported for binary agents",
), )),
}; };
match &result { match &result {
Ok(()) => { Ok(()) => {
emit_agent_install_event( emit_agent_install_event(
emitter, &task_id, AgentInstallEventKind::Completed, emitter,
&task_id,
AgentInstallEventKind::Completed,
format!("{} installed successfully", meta.name), format!("{} installed successfully", meta.name),
); );
} }
Err(e) => { Err(e) => {
emit_agent_install_event( emit_agent_install_event(
emitter, &task_id, AgentInstallEventKind::Failed, e.to_string(), emitter,
&task_id,
AgentInstallEventKind::Failed,
e.to_string(),
); );
} }
} }
@@ -2645,12 +2689,9 @@ pub(crate) async fn acp_detect_agent_local_version_core(
) -> Result<Option<String>, AcpError> { ) -> Result<Option<String>, AcpError> {
let detected = detect_local_version(agent_type).await; let detected = detect_local_version(agent_type).await;
if let Some(version) = detected.clone() { if let Some(version) = detected.clone() {
let _ = agent_setting_service::set_installed_version( let _ =
conn, agent_setting_service::set_installed_version(conn, agent_type, Some(version.clone()))
agent_type, .await;
Some(version.clone()),
)
.await;
return Ok(Some(version)); return Ok(Some(version));
} }
@@ -2699,13 +2740,17 @@ pub(crate) async fn acp_prepare_npx_agent_core(
.and_then(|m| m.installed_version); .and_then(|m| m.installed_version);
emit_agent_install_event( emit_agent_install_event(
emitter, &task_id, AgentInstallEventKind::Log, emitter,
&task_id,
AgentInstallEventKind::Log,
format!("Installing {} ({package})", meta.name), format!("Installing {} ({package})", meta.name),
); );
install_npm_global_package_streaming(package, &task_id, emitter).await?; install_npm_global_package_streaming(package, &task_id, emitter).await?;
emit_agent_install_event( emit_agent_install_event(
emitter, &task_id, AgentInstallEventKind::Log, emitter,
&task_id,
AgentInstallEventKind::Log,
"Detecting installed version...", "Detecting installed version...",
); );
let resolved = detect_local_version(agent_type) let resolved = detect_local_version(agent_type)
@@ -2741,13 +2786,18 @@ pub(crate) async fn acp_prepare_npx_agent_core(
match &result { match &result {
Ok(version) => { Ok(version) => {
emit_agent_install_event( emit_agent_install_event(
emitter, &task_id, AgentInstallEventKind::Completed, emitter,
&task_id,
AgentInstallEventKind::Completed,
format!("{} v{version} installed successfully", meta.name), format!("{} v{version} installed successfully", meta.name),
); );
} }
Err(e) => { Err(e) => {
emit_agent_install_event( emit_agent_install_event(
emitter, &task_id, AgentInstallEventKind::Failed, e.to_string(), emitter,
&task_id,
AgentInstallEventKind::Failed,
e.to_string(),
); );
} }
} }
@@ -2777,7 +2827,9 @@ pub(crate) async fn acp_uninstall_agent_core(
let meta = registry::get_agent_meta(agent_type); let meta = registry::get_agent_meta(agent_type);
emit_agent_install_event( emit_agent_install_event(
emitter, &task_id, AgentInstallEventKind::Log, emitter,
&task_id,
AgentInstallEventKind::Log,
format!("Uninstalling {}...", meta.name), format!("Uninstalling {}...", meta.name),
); );
@@ -2802,13 +2854,18 @@ pub(crate) async fn acp_uninstall_agent_core(
match &result { match &result {
Ok(()) => { Ok(()) => {
emit_agent_install_event( emit_agent_install_event(
emitter, &task_id, AgentInstallEventKind::Completed, emitter,
&task_id,
AgentInstallEventKind::Completed,
format!("{} uninstalled successfully", meta.name), format!("{} uninstalled successfully", meta.name),
); );
} }
Err(e) => { Err(e) => {
emit_agent_install_event( emit_agent_install_event(
emitter, &task_id, AgentInstallEventKind::Failed, e.to_string(), emitter,
&task_id,
AgentInstallEventKind::Failed,
e.to_string(),
); );
} }
} }
@@ -2868,9 +2925,7 @@ pub async fn acp_list_agent_skills(
let Some(spec) = skill_storage_spec(agent_type) else { let Some(spec) = skill_storage_spec(agent_type) else {
return Ok(AgentSkillsListResult { return Ok(AgentSkillsListResult {
supported: false, supported: false,
message: Some(format!( message: Some(format!("{agent_type} 暂不支持在设置页管理 Skills")),
"{agent_type} 暂不支持在设置页管理 Skills"
)),
locations: Vec::new(), locations: Vec::new(),
skills: Vec::new(), skills: Vec::new(),
}); });
@@ -3033,8 +3088,7 @@ pub async fn acp_delete_agent_skill(
} }
pub(crate) async fn opencode_list_plugins_core() -> Result<PluginCheckSummary, AcpError> { pub(crate) async fn opencode_list_plugins_core() -> Result<PluginCheckSummary, AcpError> {
opencode_plugins::check_opencode_plugins(None) opencode_plugins::check_opencode_plugins(None).map_err(AcpError::Protocol)
.map_err(|e| AcpError::Protocol(e))
} }
#[cfg_attr(feature = "tauri-runtime", tauri::command)] #[cfg_attr(feature = "tauri-runtime", tauri::command)]
@@ -3049,7 +3103,7 @@ pub(crate) async fn opencode_install_plugins_core(
) -> Result<(), AcpError> { ) -> Result<(), AcpError> {
opencode_plugins::install_missing_plugins(names, task_id, emitter) opencode_plugins::install_missing_plugins(names, task_id, emitter)
.await .await
.map_err(|e| AcpError::Protocol(e)) .map_err(AcpError::Protocol)
} }
#[cfg(feature = "tauri-runtime")] #[cfg(feature = "tauri-runtime")]
@@ -3068,13 +3122,11 @@ pub(crate) async fn opencode_uninstall_plugin_core(
) -> Result<PluginCheckSummary, AcpError> { ) -> Result<PluginCheckSummary, AcpError> {
opencode_plugins::uninstall_plugin(name) opencode_plugins::uninstall_plugin(name)
.await .await
.map_err(|e| AcpError::Protocol(e)) .map_err(AcpError::Protocol)
} }
#[cfg_attr(feature = "tauri-runtime", tauri::command)] #[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn opencode_uninstall_plugin( pub async fn opencode_uninstall_plugin(name: String) -> Result<PluginCheckSummary, AcpError> {
name: String,
) -> Result<PluginCheckSummary, AcpError> {
opencode_uninstall_plugin_core(name).await opencode_uninstall_plugin_core(name).await
} }
@@ -3108,7 +3160,10 @@ struct DeviceCodeUserCodeResp {
device_auth_id: String, device_auth_id: String,
#[serde(alias = "usercode")] #[serde(alias = "usercode")]
user_code: String, user_code: String,
#[serde(default = "default_interval", deserialize_with = "deserialize_interval")] #[serde(
default = "default_interval",
deserialize_with = "deserialize_interval"
)]
interval: u64, interval: u64,
} }
@@ -3118,11 +3173,8 @@ fn default_interval() -> u64 {
fn extract_jwt_account_id(jwt: &str) -> Option<String> { fn extract_jwt_account_id(jwt: &str) -> Option<String> {
let payload = jwt.split('.').nth(1)?; let payload = jwt.split('.').nth(1)?;
let decoded = base64::Engine::decode( let decoded =
&base64::engine::general_purpose::URL_SAFE_NO_PAD, base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, payload).ok()?;
payload,
)
.ok()?;
let value: serde_json::Value = serde_json::from_slice(&decoded).ok()?; let value: serde_json::Value = serde_json::from_slice(&decoded).ok()?;
value value
.get("https://api.openai.com/auth") .get("https://api.openai.com/auth")
@@ -3138,11 +3190,13 @@ where
use serde::de; use serde::de;
let value = serde_json::Value::deserialize(deserializer)?; let value = serde_json::Value::deserialize(deserializer)?;
match &value { match &value {
serde_json::Value::Number(n) => n.as_u64().ok_or_else(|| { serde_json::Value::Number(n) => n
de::Error::custom(format!("invalid interval number: {n}")) .as_u64()
}), .ok_or_else(|| de::Error::custom(format!("invalid interval number: {n}"))),
serde_json::Value::String(s) => s.trim().parse::<u64>().map_err(de::Error::custom), serde_json::Value::String(s) => s.trim().parse::<u64>().map_err(de::Error::custom),
_ => Err(de::Error::custom(format!("unexpected interval type: {value}"))), _ => Err(de::Error::custom(format!(
"unexpected interval type: {value}"
))),
} }
} }
@@ -3161,9 +3215,7 @@ struct OAuthTokenResp {
refresh_token: String, refresh_token: String,
} }
pub(crate) async fn codex_request_device_code_core() pub(crate) async fn codex_request_device_code_core() -> Result<CodexDeviceCodeResponse, AcpError> {
-> Result<CodexDeviceCodeResponse, AcpError>
{
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let url = format!("{CODEX_OAUTH_ISSUER}/api/accounts/deviceauth/usercode"); let url = format!("{CODEX_OAUTH_ISSUER}/api/accounts/deviceauth/usercode");
let body = serde_json::json!({ "client_id": CODEX_OAUTH_CLIENT_ID }); let body = serde_json::json!({ "client_id": CODEX_OAUTH_CLIENT_ID });
@@ -3187,8 +3239,11 @@ pub(crate) async fn codex_request_device_code_core()
.text() .text()
.await .await
.map_err(|e| AcpError::protocol(format!("read device code response failed: {e}")))?; .map_err(|e| AcpError::protocol(format!("read device code response failed: {e}")))?;
let uc: DeviceCodeUserCodeResp = serde_json::from_str(&raw_body) let uc: DeviceCodeUserCodeResp = serde_json::from_str(&raw_body).map_err(|e| {
.map_err(|e| AcpError::protocol(format!("parse device code response failed: {e} | body: {raw_body}")))?; AcpError::protocol(format!(
"parse device code response failed: {e} | body: {raw_body}"
))
})?;
Ok(CodexDeviceCodeResponse { Ok(CodexDeviceCodeResponse {
user_code: uc.user_code, user_code: uc.user_code,
@@ -3199,9 +3254,7 @@ pub(crate) async fn codex_request_device_code_core()
} }
#[cfg_attr(feature = "tauri-runtime", tauri::command)] #[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn codex_request_device_code() pub async fn codex_request_device_code() -> Result<CodexDeviceCodeResponse, AcpError> {
-> Result<CodexDeviceCodeResponse, AcpError>
{
codex_request_device_code_core().await codex_request_device_code_core().await
} }
+69 -49
View File
@@ -98,20 +98,19 @@ pub async fn connect_chat_channel_core(
.map_err(AppCommandError::from)? .map_err(AppCommandError::from)?
.ok_or_else(|| AppCommandError::not_found(format!("Chat channel {id} not found")))?; .ok_or_else(|| AppCommandError::not_found(format!("Chat channel {id} not found")))?;
let channel_type: ChannelType = let channel_type: ChannelType = serde_json::from_value(serde_json::Value::String(
serde_json::from_value(serde_json::Value::String(model.channel_type.clone())) model.channel_type.clone(),
.map_err(|_| { ))
AppCommandError::configuration_invalid(format!( .map_err(|_| {
"Invalid channel type: {}", AppCommandError::configuration_invalid(format!(
model.channel_type "Invalid channel type: {}",
)) model.channel_type
})?; ))
})?;
let config: serde_json::Value = let config: serde_json::Value = serde_json::from_str(&model.config_json).map_err(|e| {
serde_json::from_str(&model.config_json).map_err(|e| { AppCommandError::configuration_invalid("Invalid config JSON").with_detail(e.to_string())
AppCommandError::configuration_invalid("Invalid config JSON") })?;
.with_detail(e.to_string())
})?;
let token = crate::keyring_store::get_channel_token(id).ok_or_else(|| { let token = crate::keyring_store::get_channel_token(id).ok_or_else(|| {
eprintln!("[connect_chat_channel] channel {id}: Token not set in keyring"); eprintln!("[connect_chat_channel] channel {id}: Token not set in keyring");
@@ -138,29 +137,25 @@ pub async fn connect_chat_channel_core(
Ok(()) Ok(())
} }
pub async fn test_chat_channel_core( pub async fn test_chat_channel_core(db: &AppDatabase, id: i32) -> Result<(), AppCommandError> {
db: &AppDatabase,
id: i32,
) -> Result<(), AppCommandError> {
let model = chat_channel_service::get_by_id(&db.conn, id) let model = chat_channel_service::get_by_id(&db.conn, id)
.await .await
.map_err(AppCommandError::from)? .map_err(AppCommandError::from)?
.ok_or_else(|| AppCommandError::not_found(format!("Chat channel {id} not found")))?; .ok_or_else(|| AppCommandError::not_found(format!("Chat channel {id} not found")))?;
let channel_type: ChannelType = let channel_type: ChannelType = serde_json::from_value(serde_json::Value::String(
serde_json::from_value(serde_json::Value::String(model.channel_type.clone())) model.channel_type.clone(),
.map_err(|_| { ))
AppCommandError::configuration_invalid(format!( .map_err(|_| {
"Invalid channel type: {}", AppCommandError::configuration_invalid(format!(
model.channel_type "Invalid channel type: {}",
)) model.channel_type
})?; ))
})?;
let config: serde_json::Value = let config: serde_json::Value = serde_json::from_str(&model.config_json).map_err(|e| {
serde_json::from_str(&model.config_json).map_err(|e| { AppCommandError::configuration_invalid("Invalid config JSON").with_detail(e.to_string())
AppCommandError::configuration_invalid("Invalid config JSON") })?;
.with_detail(e.to_string())
})?;
let token = crate::keyring_store::get_channel_token(id) let token = crate::keyring_store::get_channel_token(id)
.ok_or_else(|| AppCommandError::configuration_missing("Token not set"))?; .ok_or_else(|| AppCommandError::configuration_missing("Token not set"))?;
@@ -215,18 +210,20 @@ pub async fn list_chat_channel_messages_core(
) -> Result<Vec<ChatChannelMessageLogInfo>, AppCommandError> { ) -> Result<Vec<ChatChannelMessageLogInfo>, AppCommandError> {
let limit = limit.unwrap_or(50); let limit = limit.unwrap_or(50);
let offset = offset.unwrap_or(0); let offset = offset.unwrap_or(0);
let rows = chat_channel_message_log_service::list_by_channel(&db.conn, channel_id, limit, offset) let rows =
.await chat_channel_message_log_service::list_by_channel(&db.conn, channel_id, limit, offset)
.map_err(AppCommandError::from)?; .await
Ok(rows.into_iter().map(ChatChannelMessageLogInfo::from).collect()) .map_err(AppCommandError::from)?;
Ok(rows
.into_iter()
.map(ChatChannelMessageLogInfo::from)
.collect())
} }
const COMMAND_PREFIX_KEY: &str = "chat_command_prefix"; const COMMAND_PREFIX_KEY: &str = "chat_command_prefix";
const DEFAULT_COMMAND_PREFIX: &str = "/"; const DEFAULT_COMMAND_PREFIX: &str = "/";
pub async fn get_chat_command_prefix_core( pub async fn get_chat_command_prefix_core(db: &AppDatabase) -> Result<String, AppCommandError> {
db: &AppDatabase,
) -> Result<String, AppCommandError> {
let val = crate::db::service::app_metadata_service::get_value(&db.conn, COMMAND_PREFIX_KEY) let val = crate::db::service::app_metadata_service::get_value(&db.conn, COMMAND_PREFIX_KEY)
.await .await
.map_err(AppCommandError::from)?; .map_err(AppCommandError::from)?;
@@ -238,10 +235,7 @@ pub async fn set_chat_command_prefix_core(
prefix: String, prefix: String,
) -> Result<(), AppCommandError> { ) -> Result<(), AppCommandError> {
let trimmed = prefix.trim(); let trimmed = prefix.trim();
if trimmed.is_empty() if trimmed.is_empty() || trimmed.len() > 3 || trimmed.chars().any(|c| c.is_alphanumeric()) {
|| trimmed.len() > 3
|| trimmed.chars().any(|c| c.is_alphanumeric())
{
return Err(AppCommandError::invalid_input( return Err(AppCommandError::invalid_input(
"Prefix must be 1-3 non-alphanumeric characters", "Prefix must be 1-3 non-alphanumeric characters",
)); ));
@@ -254,9 +248,7 @@ pub async fn set_chat_command_prefix_core(
const MESSAGE_LANGUAGE_KEY: &str = "chat_message_language"; const MESSAGE_LANGUAGE_KEY: &str = "chat_message_language";
pub async fn get_chat_message_language_core( pub async fn get_chat_message_language_core(db: &AppDatabase) -> Result<String, AppCommandError> {
db: &AppDatabase,
) -> Result<String, AppCommandError> {
let val = crate::db::service::app_metadata_service::get_value(&db.conn, MESSAGE_LANGUAGE_KEY) let val = crate::db::service::app_metadata_service::get_value(&db.conn, MESSAGE_LANGUAGE_KEY)
.await .await
.map_err(AppCommandError::from)?; .map_err(AppCommandError::from)?;
@@ -360,14 +352,20 @@ pub async fn weixin_check_qrcode_core(
if result.status == "confirmed" { if result.status == "confirmed" {
eprintln!( eprintln!(
"[Weixin] QR confirmed for channel {channel_id}, bot_token={}, base_url={}", "[Weixin] QR confirmed for channel {channel_id}, bot_token={}, base_url={}",
result.bot_token.as_deref().map(|t| if t.len() > 8 { &t[..8] } else { t }).unwrap_or("None"), result
.bot_token
.as_deref()
.map(|t| if t.len() > 8 { &t[..8] } else { t })
.unwrap_or("None"),
result.base_url.as_deref().unwrap_or("None"), result.base_url.as_deref().unwrap_or("None"),
); );
if let Some(ref token) = result.bot_token { if let Some(ref token) = result.bot_token {
save_chat_channel_token_core(channel_id, token)?; save_chat_channel_token_core(channel_id, token)?;
eprintln!("[Weixin] Token saved for channel {channel_id}"); eprintln!("[Weixin] Token saved for channel {channel_id}");
} else { } else {
eprintln!("[Weixin] WARNING: No bot_token in confirmed response for channel {channel_id}"); eprintln!(
"[Weixin] WARNING: No bot_token in confirmed response for channel {channel_id}"
);
} }
if let Some(ref base_url) = result.base_url { if let Some(ref base_url) = result.base_url {
let config_json = serde_json::json!({ "base_url": base_url }).to_string(); let config_json = serde_json::json!({ "base_url": base_url }).to_string();
@@ -415,7 +413,16 @@ pub async fn create_chat_channel(
daily_report_enabled: bool, daily_report_enabled: bool,
daily_report_time: Option<String>, daily_report_time: Option<String>,
) -> Result<ChatChannelInfo, AppCommandError> { ) -> Result<ChatChannelInfo, AppCommandError> {
create_chat_channel_core(&db, name, channel_type, config_json, enabled, daily_report_enabled, daily_report_time).await create_chat_channel_core(
&db,
name,
channel_type,
config_json,
enabled,
daily_report_enabled,
daily_report_time,
)
.await
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -431,7 +438,17 @@ pub async fn update_chat_channel(
daily_report_enabled: Option<bool>, daily_report_enabled: Option<bool>,
daily_report_time: Option<Option<String>>, daily_report_time: Option<Option<String>>,
) -> Result<ChatChannelInfo, AppCommandError> { ) -> Result<ChatChannelInfo, AppCommandError> {
update_chat_channel_core(&db, id, name, enabled, config_json, event_filter_json, daily_report_enabled, daily_report_time).await update_chat_channel_core(
&db,
id,
name,
enabled,
config_json,
event_filter_json,
daily_report_enabled,
daily_report_time,
)
.await
} }
#[cfg(feature = "tauri-runtime")] #[cfg(feature = "tauri-runtime")]
@@ -446,7 +463,10 @@ pub async fn delete_chat_channel(
#[cfg(feature = "tauri-runtime")] #[cfg(feature = "tauri-runtime")]
#[tauri::command] #[tauri::command]
pub async fn save_chat_channel_token(channel_id: i32, token: String) -> Result<(), AppCommandError> { pub async fn save_chat_channel_token(
channel_id: i32,
token: String,
) -> Result<(), AppCommandError> {
save_chat_channel_token_core(channel_id, &token) save_chat_channel_token_core(channel_id, &token)
} }
+10 -12
View File
@@ -3,9 +3,9 @@ use std::collections::{HashMap, HashSet};
use crate::app_error::AppCommandError; use crate::app_error::AppCommandError;
#[cfg(feature = "tauri-runtime")] #[cfg(feature = "tauri-runtime")]
use crate::db::entities::conversation; use crate::db::entities::conversation;
use crate::db::service::{conversation_service, folder_service};
#[cfg(feature = "tauri-runtime")] #[cfg(feature = "tauri-runtime")]
use crate::db::service::import_service; use crate::db::service::import_service;
use crate::db::service::{conversation_service, folder_service};
#[cfg(feature = "tauri-runtime")] #[cfg(feature = "tauri-runtime")]
use crate::db::AppDatabase; use crate::db::AppDatabase;
use crate::models::*; use crate::models::*;
@@ -288,8 +288,7 @@ pub async fn get_folder_conversation_core(
.await .await
.map_err(AppCommandError::from)?; .map_err(AppCommandError::from)?;
let (turns, session_stats, resolved_ext_id) = if let Some(ref ext_id) = summary.external_id let (turns, session_stats, resolved_ext_id) = if let Some(ref ext_id) = summary.external_id {
{
let at = summary.agent_type; let at = summary.agent_type;
let eid = ext_id.clone(); let eid = ext_id.clone();
let db_created_at = summary.created_at; let db_created_at = summary.created_at;
@@ -317,7 +316,10 @@ pub async fn get_folder_conversation_core(
// ID after session/new fallback overwrote the original // ID after session/new fallback overwrote the original
// (Gemini CLI). Fall back to matching by folder_path // (Gemini CLI). Fall back to matching by folder_path
// and started_at from the parsed conversation list. // and started_at from the parsed conversation list.
if matches!(at, AgentType::OpenClaw | AgentType::Cline | AgentType::Gemini) { if matches!(
at,
AgentType::OpenClaw | AgentType::Cline | AgentType::Gemini
) {
if let Ok(all) = parser.list_conversations() { if let Ok(all) = parser.list_conversations() {
// Filter by folder_path first, then find the closest // Filter by folder_path first, then find the closest
// started_at match within 300 seconds of db_created_at. // started_at match within 300 seconds of db_created_at.
@@ -333,17 +335,14 @@ pub async fn get_folder_conversation_core(
(c.started_at - db_created_at).num_seconds().unsigned_abs() (c.started_at - db_created_at).num_seconds().unsigned_abs()
}) })
.filter(|c| { .filter(|c| {
let diff = (c.started_at - db_created_at).num_seconds().unsigned_abs(); let diff =
(c.started_at - db_created_at).num_seconds().unsigned_abs();
diff < 300 diff < 300
}); });
if let Some(conv) = matched { if let Some(conv) = matched {
let new_ext_id = conv.id.clone(); let new_ext_id = conv.id.clone();
if let Ok(d) = parser.get_conversation(&new_ext_id) { if let Ok(d) = parser.get_conversation(&new_ext_id) {
return Ok(( return Ok((d.turns, d.session_stats, Some(new_ext_id)));
d.turns,
d.session_stats,
Some(new_ext_id),
));
} }
} }
} }
@@ -367,8 +366,7 @@ pub async fn get_folder_conversation_core(
// If we resolved a different external_id (e.g. ACP UUID → parser branch ID), // If we resolved a different external_id (e.g. ACP UUID → parser branch ID),
// update the database so future lookups are direct. // update the database so future lookups are direct.
if let Some(new_ext_id) = resolved_ext_id { if let Some(new_ext_id) = resolved_ext_id {
let _ = let _ = conversation_service::update_external_id(conn, conversation_id, new_ext_id).await;
conversation_service::update_external_id(conn, conversation_id, new_ext_id).await;
} }
let mut summary = summary; let mut summary = summary;
+25 -27
View File
@@ -23,11 +23,11 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::acp::types::AgentSkillScope;
use crate::commands::acp::{ use crate::commands::acp::{
preferred_scope_skill_dir, remove_skill_entry, scoped_skill_dirs, skill_storage_spec, preferred_scope_skill_dir, remove_skill_entry, scoped_skill_dirs, skill_storage_spec,
validate_skill_id, validate_skill_id,
}; };
use crate::acp::types::AgentSkillScope;
use crate::models::agent::AgentType; use crate::models::agent::AgentType;
// ─── Embedded bundle ──────────────────────────────────────────────────── // ─── Embedded bundle ────────────────────────────────────────────────────
@@ -373,9 +373,7 @@ fn create_link_raw(src: &Path, dst: &Path) -> io::Result<bool> {
copy_dir_recursive(src, dst).map_err(|copy_err| { copy_dir_recursive(src, dst).map_err(|copy_err| {
io::Error::new( io::Error::new(
io::ErrorKind::Other, io::ErrorKind::Other,
format!( format!("junction failed ({junction_err}); copy fallback failed ({copy_err})"),
"junction failed ({junction_err}); copy fallback failed ({copy_err})"
),
) )
})?; })?;
Ok(true) Ok(true)
@@ -518,9 +516,7 @@ fn ensure_central_experts_installed_blocking() -> InstallReport {
report.pending_user_review.push(meta.id.clone()); report.pending_user_review.push(meta.id.clone());
} }
Err(e) => { Err(e) => {
report report.errors.push(format!("{}: {}", meta.id, e));
.errors
.push(format!("{}: {}", meta.id, e));
} }
} }
} }
@@ -639,10 +635,10 @@ fn extract_bundle_dir(
.path() .path()
.to_str() .to_str()
.ok_or_else(|| ExpertsError::Io("non-utf8 path in bundle".into()))?; .ok_or_else(|| ExpertsError::Io("non-utf8 path in bundle".into()))?;
let rel_within = let rel_within = rel
rel.strip_prefix(bundle_prefix) .strip_prefix(bundle_prefix)
.and_then(|s| s.strip_prefix('/')) .and_then(|s| s.strip_prefix('/'))
.unwrap_or(rel); .unwrap_or(rel);
let out_path = target.join(rel_within); let out_path = target.join(rel_within);
if let Some(parent) = out_path.parent() { if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
@@ -686,8 +682,7 @@ pub async fn experts_list() -> Result<Vec<ExpertListItem>, ExpertsError> {
pub async fn experts_list_for_agent( pub async fn experts_list_for_agent(
agent_type: AgentType, agent_type: AgentType,
) -> Result<Vec<ExpertListItem>, ExpertsError> { ) -> Result<Vec<ExpertListItem>, ExpertsError> {
let _ = skill_storage_spec(agent_type) let _ = skill_storage_spec(agent_type).ok_or(ExpertsError::UnsupportedAgent(agent_type))?;
.ok_or(ExpertsError::UnsupportedAgent(agent_type))?;
let dirs = scoped_skill_dirs(agent_type, AgentSkillScope::Global, None) let dirs = scoped_skill_dirs(agent_type, AgentSkillScope::Global, None)
.map_err(|_| ExpertsError::UnsupportedAgent(agent_type))?; .map_err(|_| ExpertsError::UnsupportedAgent(agent_type))?;
@@ -727,8 +722,8 @@ pub async fn experts_list_for_agent(
pub async fn experts_get_install_status( pub async fn experts_get_install_status(
expert_id: String, expert_id: String,
) -> Result<Vec<ExpertInstallStatus>, ExpertsError> { ) -> Result<Vec<ExpertInstallStatus>, ExpertsError> {
let expert_id = validate_skill_id(&expert_id) let expert_id =
.map_err(|e| ExpertsError::Metadata(e.to_string()))?; validate_skill_id(&expert_id).map_err(|e| ExpertsError::Metadata(e.to_string()))?;
let _ = find_metadata(&expert_id)?; // ensure it exists in the bundle let _ = find_metadata(&expert_id)?; // ensure it exists in the bundle
let expected = expert_central_path(&expert_id); let expected = expert_central_path(&expert_id);
let agents = supported_agents(); let agents = supported_agents();
@@ -776,8 +771,8 @@ pub async fn experts_link_to_agent(
expert_id: String, expert_id: String,
agent_type: AgentType, agent_type: AgentType,
) -> Result<ExpertInstallStatus, ExpertsError> { ) -> Result<ExpertInstallStatus, ExpertsError> {
let expert_id = validate_skill_id(&expert_id) let expert_id =
.map_err(|e| ExpertsError::Metadata(e.to_string()))?; validate_skill_id(&expert_id).map_err(|e| ExpertsError::Metadata(e.to_string()))?;
let _ = find_metadata(&expert_id)?; let _ = find_metadata(&expert_id)?;
let central = expert_central_path(&expert_id); let central = expert_central_path(&expert_id);
if !central.exists() { if !central.exists() {
@@ -819,9 +814,8 @@ pub async fn experts_link_to_agent(
} }
ExpertLinkState::NotLinked => { ExpertLinkState::NotLinked => {
// Shouldn't happen after AlreadyExists, but retry once. // Shouldn't happen after AlreadyExists, but retry once.
create_link_raw(&central, &link_path).map_err(|e| ExpertsError::Io(format!( create_link_raw(&central, &link_path)
"retry link failed: {e}" .map_err(|e| ExpertsError::Io(format!("retry link failed: {e}")))?;
)))?;
} }
} }
} }
@@ -846,8 +840,8 @@ pub async fn experts_unlink_from_agent(
expert_id: String, expert_id: String,
agent_type: AgentType, agent_type: AgentType,
) -> Result<(), ExpertsError> { ) -> Result<(), ExpertsError> {
let expert_id = validate_skill_id(&expert_id) let expert_id =
.map_err(|e| ExpertsError::Metadata(e.to_string()))?; validate_skill_id(&expert_id).map_err(|e| ExpertsError::Metadata(e.to_string()))?;
let _guard = mutation_lock().lock().await; let _guard = mutation_lock().lock().await;
@@ -865,10 +859,14 @@ pub async fn experts_unlink_from_agent(
continue; continue;
} }
let state = classify_link(&candidate, &central); let state = classify_link(&candidate, &central);
if matches!(state, ExpertLinkState::LinkedToCodeg | ExpertLinkState::Broken) { if matches!(
state,
ExpertLinkState::LinkedToCodeg | ExpertLinkState::Broken
) {
// Safe to remove a link to our central store or a broken link. // Safe to remove a link to our central store or a broken link.
remove_skill_entry(&candidate) remove_skill_entry(&candidate).map_err(|e| {
.map_err(|e| ExpertsError::Io(format!("remove link {}: {e}", candidate.display())))?; ExpertsError::Io(format!("remove link {}: {e}", candidate.display()))
})?;
removed = true; removed = true;
} else if state == ExpertLinkState::LinkedElsewhere { } else if state == ExpertLinkState::LinkedElsewhere {
return Err(ExpertsError::ForeignLink { return Err(ExpertsError::ForeignLink {
@@ -893,8 +891,8 @@ pub async fn experts_unlink_from_agent(
#[cfg_attr(feature = "tauri-runtime", tauri::command)] #[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn experts_read_content(expert_id: String) -> Result<String, ExpertsError> { pub async fn experts_read_content(expert_id: String) -> Result<String, ExpertsError> {
let expert_id = validate_skill_id(&expert_id) let expert_id =
.map_err(|e| ExpertsError::Metadata(e.to_string()))?; validate_skill_id(&expert_id).map_err(|e| ExpertsError::Metadata(e.to_string()))?;
let _ = find_metadata(&expert_id)?; let _ = find_metadata(&expert_id)?;
let path = expert_central_path(&expert_id).join("SKILL.md"); let path = expert_central_path(&expert_id).join("SKILL.md");
if !path.exists() { if !path.exists() {
+2 -9
View File
@@ -1192,11 +1192,7 @@ pub async fn git_checkout(path: String, branch_name: String) -> Result<(), AppCo
} }
#[cfg_attr(feature = "tauri-runtime", tauri::command)] #[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn git_reset( pub async fn git_reset(path: String, commit: String, mode: String) -> Result<(), AppCommandError> {
path: String,
commit: String,
mode: String,
) -> Result<(), AppCommandError> {
let mode = mode.trim().to_lowercase(); let mode = mode.trim().to_lowercase();
let mode_flag = match mode.as_str() { let mode_flag = match mode.as_str() {
"soft" | "mixed" | "hard" | "keep" => format!("--{mode}"), "soft" | "mixed" | "hard" | "keep" => format!("--{mode}"),
@@ -2128,10 +2124,7 @@ pub async fn git_delete_branch(
.map_err(AppCommandError::io)?; .map_err(AppCommandError::io)?;
if !output.status.success() { if !output.status.success() {
return Err(git_command_error( return Err(git_command_error(&format!("branch {flag}"), &output.stderr));
&format!("branch {flag}"),
&output.stderr,
));
} }
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} }
+1 -3
View File
@@ -1735,9 +1735,7 @@ fn upsert_openclaw_server(id: &str, spec: &Value) -> Result<(), AppCommandError>
let mcp = obj let mcp = obj
.get_mut("mcp") .get_mut("mcp")
.and_then(Value::as_object_mut) .and_then(Value::as_object_mut)
.ok_or_else(|| { .ok_or_else(|| mcp_configuration_invalid(format!("invalid mcp in {}", path.display())))?;
mcp_configuration_invalid(format!("invalid mcp in {}", path.display()))
})?;
if !mcp.get("servers").map(Value::is_object).unwrap_or(false) { if !mcp.get("servers").map(Value::is_object).unwrap_or(false) {
mcp.insert("servers".to_string(), Value::Object(Map::new())); mcp.insert("servers".to_string(), Value::Object(Map::new()));
+23 -22
View File
@@ -18,9 +18,7 @@ fn validate_agent_types(agent_types: &[String]) -> Result<(), AppCommandError> {
} }
for at in agent_types { for at in agent_types {
let _: AgentType = serde_json::from_value(serde_json::Value::String(at.clone())) let _: AgentType = serde_json::from_value(serde_json::Value::String(at.clone()))
.map_err(|_| { .map_err(|_| AppCommandError::invalid_input(format!("Invalid agent type: {at}")))?;
AppCommandError::invalid_input(format!("Invalid agent type: {at}"))
})?;
} }
Ok(()) Ok(())
} }
@@ -32,20 +30,28 @@ fn validate_fields(
) -> Result<(), AppCommandError> { ) -> Result<(), AppCommandError> {
if let Some(n) = name { if let Some(n) = name {
if n.len() > 256 { if n.len() > 256 {
return Err(AppCommandError::invalid_input("Name must be 256 characters or less")); return Err(AppCommandError::invalid_input(
"Name must be 256 characters or less",
));
} }
} }
if let Some(u) = api_url { if let Some(u) = api_url {
if u.len() > 2048 { if u.len() > 2048 {
return Err(AppCommandError::invalid_input("API URL must be 2048 characters or less")); return Err(AppCommandError::invalid_input(
"API URL must be 2048 characters or less",
));
} }
if !u.starts_with("http://") && !u.starts_with("https://") { if !u.starts_with("http://") && !u.starts_with("https://") {
return Err(AppCommandError::invalid_input("API URL must start with http:// or https://")); return Err(AppCommandError::invalid_input(
"API URL must start with http:// or https://",
));
} }
} }
if let Some(k) = api_key { if let Some(k) = api_key {
if k.len() > 4096 { if k.len() > 4096 {
return Err(AppCommandError::invalid_input("API Key must be 4096 characters or less")); return Err(AppCommandError::invalid_input(
"API Key must be 4096 characters or less",
));
} }
} }
Ok(()) Ok(())
@@ -72,15 +78,9 @@ pub async fn create_model_provider_core(
let agent_types_json = serde_json::to_string(&agent_types) let agent_types_json = serde_json::to_string(&agent_types)
.map_err(|e| AppCommandError::invalid_input(e.to_string()))?; .map_err(|e| AppCommandError::invalid_input(e.to_string()))?;
let model = model_provider_service::create( let model = model_provider_service::create(&db.conn, name, api_url, api_key, agent_types_json)
&db.conn, .await
name, .map_err(AppCommandError::from)?;
api_url,
api_key,
agent_types_json,
)
.await
.map_err(AppCommandError::from)?;
Ok(ModelProviderInfo::from(model)) Ok(ModelProviderInfo::from(model))
} }
@@ -122,8 +122,12 @@ pub async fn update_model_provider_core(
.map_err(AppCommandError::from)?; .map_err(AppCommandError::from)?;
// Cascade credential changes to all dependent agent settings and config files. // Cascade credential changes to all dependent agent settings and config files.
let url_changed = api_url.as_deref().is_some_and(|u| u != old_provider.api_url); let url_changed = api_url
let key_changed = api_key.as_deref().is_some_and(|k| k != old_provider.api_key); .as_deref()
.is_some_and(|u| u != old_provider.api_url);
let key_changed = api_key
.as_deref()
.is_some_and(|k| k != old_provider.api_key);
if url_changed || key_changed { if url_changed || key_changed {
let final_url = api_url.as_deref().unwrap_or(&old_provider.api_url); let final_url = api_url.as_deref().unwrap_or(&old_provider.api_url);
let final_key = api_key.as_deref().unwrap_or(&old_provider.api_key); let final_key = api_key.as_deref().unwrap_or(&old_provider.api_key);
@@ -135,10 +139,7 @@ pub async fn update_model_provider_core(
Ok(ModelProviderInfo::from(model)) Ok(ModelProviderInfo::from(model))
} }
pub async fn delete_model_provider_core( pub async fn delete_model_provider_core(db: &AppDatabase, id: i32) -> Result<(), AppCommandError> {
db: &AppDatabase,
id: i32,
) -> Result<(), AppCommandError> {
// Check if any agent settings reference this provider. // Check if any agent settings reference this provider.
let dependents = agent_setting_service::find_by_model_provider_id(&db.conn, id) let dependents = agent_setting_service::find_by_model_provider_id(&db.conn, id)
.await .await
+1 -6
View File
@@ -28,12 +28,7 @@ pub async fn send_notification(
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
{ {
use tauri_plugin_notification::NotificationExt; use tauri_plugin_notification::NotificationExt;
let _ = app let _ = app.notification().builder().title(title).body(body).show();
.notification()
.builder()
.title(title)
.body(body)
.show();
} }
Ok(()) Ok(())
+4 -4
View File
@@ -29,9 +29,7 @@ async fn detect_one(name: &str) -> PackageManagerInfo {
match result { match result {
Ok(output) if output.status.success() => { Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout) let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
.trim()
.to_string();
PackageManagerInfo { PackageManagerInfo {
name: name.to_string(), name: name.to_string(),
installed: true, installed: true,
@@ -76,7 +74,9 @@ pub async fn create_shadcn_project(
return Err(AppCommandError::invalid_input("Template is required")); return Err(AppCommandError::invalid_input("Template is required"));
} }
if target_dir.is_empty() { if target_dir.is_empty() {
return Err(AppCommandError::invalid_input("Target directory is required")); return Err(AppCommandError::invalid_input(
"Target directory is required",
));
} }
let full_path = PathBuf::from(&target_dir).join(&project_name); let full_path = PathBuf::from(&target_dir).join(&project_name);
+5 -1
View File
@@ -139,7 +139,11 @@ pub async fn update_system_language_settings(
.map_err(AppCommandError::from)?; .map_err(AppCommandError::from)?;
let emitter = crate::web::event_bridge::EventEmitter::Tauri(app); let emitter = crate::web::event_bridge::EventEmitter::Tauri(app);
crate::web::event_bridge::emit_event(&emitter, LANGUAGE_SETTINGS_UPDATED_EVENT, settings.clone()); crate::web::event_bridge::emit_event(
&emitter,
LANGUAGE_SETTINGS_UPDATED_EVENT,
settings.clone(),
);
Ok(settings) Ok(settings)
} }
+8 -10
View File
@@ -32,16 +32,14 @@ pub(crate) fn prepare_credential_env(
} }
}; };
let helper_script = match git_credential::create_credential_helper_script( let helper_script =
app_data_dir, match git_credential::create_credential_helper_script(app_data_dir, &app_binary) {
&app_binary, Ok(p) => p,
) { Err(e) => {
Ok(p) => p, eprintln!("[TERM] failed to create credential helper script: {}", e);
Err(e) => { return None;
eprintln!("[TERM] failed to create credential helper script: {}", e); }
return None; };
}
};
let helper_path_str = helper_script.to_string_lossy().to_string(); let helper_path_str = helper_script.to_string_lossy().to_string();
+15 -29
View File
@@ -24,9 +24,7 @@ async fn run_git_version(git_path: &str) -> Result<GitDetectResult, AppCommandEr
.arg("--version") .arg("--version")
.output() .output()
.await .await
.map_err(|_| { .map_err(|_| AppCommandError::not_found(format!("Cannot execute git at: {git_path}")))?;
AppCommandError::not_found(format!("Cannot execute git at: {git_path}"))
})?;
if !output.status.success() { if !output.status.success() {
return Ok(GitDetectResult { return Ok(GitDetectResult {
@@ -108,9 +106,7 @@ pub(crate) async fn detect_git_core(
#[cfg(feature = "tauri-runtime")] #[cfg(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)] #[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn detect_git( pub async fn detect_git(db: State<'_, AppDatabase>) -> Result<GitDetectResult, AppCommandError> {
db: State<'_, AppDatabase>,
) -> Result<GitDetectResult, AppCommandError> {
detect_git_core(&db.conn).await detect_git_core(&db.conn).await
} }
@@ -145,9 +141,7 @@ async fn load_git_settings(
#[cfg(feature = "tauri-runtime")] #[cfg(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)] #[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn get_git_settings( pub async fn get_git_settings(db: State<'_, AppDatabase>) -> Result<GitSettings, AppCommandError> {
db: State<'_, AppDatabase>,
) -> Result<GitSettings, AppCommandError> {
load_git_settings(&db.conn).await load_git_settings(&db.conn).await
} }
@@ -221,27 +215,21 @@ pub async fn update_github_accounts(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[cfg_attr(feature = "tauri-runtime", tauri::command)] #[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn save_account_token( pub async fn save_account_token(account_id: String, token: String) -> Result<(), AppCommandError> {
account_id: String,
token: String,
) -> Result<(), AppCommandError> {
crate::keyring_store::set_token(&account_id, &token) crate::keyring_store::set_token(&account_id, &token)
.map_err(|e| AppCommandError::io_error("Failed to save token to keyring").with_detail(e)) .map_err(|e| AppCommandError::io_error("Failed to save token to keyring").with_detail(e))
} }
#[cfg_attr(feature = "tauri-runtime", tauri::command)] #[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn get_account_token( pub async fn get_account_token(account_id: String) -> Result<Option<String>, AppCommandError> {
account_id: String,
) -> Result<Option<String>, AppCommandError> {
Ok(crate::keyring_store::get_token(&account_id)) Ok(crate::keyring_store::get_token(&account_id))
} }
#[cfg_attr(feature = "tauri-runtime", tauri::command)] #[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn delete_account_token( pub async fn delete_account_token(account_id: String) -> Result<(), AppCommandError> {
account_id: String, crate::keyring_store::delete_token(&account_id).map_err(|e| {
) -> Result<(), AppCommandError> { AppCommandError::io_error("Failed to delete token from keyring").with_detail(e)
crate::keyring_store::delete_token(&account_id) })
.map_err(|e| AppCommandError::io_error("Failed to delete token from keyring").with_detail(e))
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -283,7 +271,9 @@ pub async fn validate_github_token(
.header("Accept", "application/vnd.github+json") .header("Accept", "application/vnd.github+json")
.send() .send()
.await .await
.map_err(|e| AppCommandError::network("Failed to connect to GitHub API").with_detail(e.to_string()))?; .map_err(|e| {
AppCommandError::network("Failed to connect to GitHub API").with_detail(e.to_string())
})?;
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status().as_u16(); let status = response.status().as_u16();
@@ -315,13 +305,9 @@ pub async fn validate_github_token(
}) })
.unwrap_or_default(); .unwrap_or_default();
let user = response let user = response.json::<GitHubUserResponse>().await.map_err(|e| {
.json::<GitHubUserResponse>() AppCommandError::network("Failed to parse GitHub API response").with_detail(e.to_string())
.await })?;
.map_err(|e| {
AppCommandError::network("Failed to parse GitHub API response")
.with_detail(e.to_string())
})?;
Ok(GitHubTokenValidation { Ok(GitHubTokenValidation {
success: true, success: true,
+36 -27
View File
@@ -1,15 +1,15 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU8, Ordering as AtomicOrdering};
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use std::sync::atomic::AtomicU32; use std::sync::atomic::AtomicU32;
use std::sync::atomic::{AtomicU8, Ordering as AtomicOrdering};
use std::sync::Mutex;
use sea_orm::DatabaseConnection; use sea_orm::DatabaseConnection;
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
use crate::app_error::AppCommandError; use crate::app_error::AppCommandError;
use crate::db::AppDatabase;
use crate::db::service::app_metadata_service; use crate::db::service::app_metadata_service;
use crate::db::AppDatabase;
use crate::models::FolderDetail; use crate::models::FolderDetail;
/// Base traffic-light position (logical px) at 100 % zoom. /// Base traffic-light position (logical px) at 100 % zoom.
@@ -123,12 +123,15 @@ fn is_system_dark_mode() -> bool {
// Output: " AppsUseLightTheme REG_DWORD 0x0" // Output: " AppsUseLightTheme REG_DWORD 0x0"
// Extract the last token on the matching line to avoid // Extract the last token on the matching line to avoid
// substring false-positives (e.g. "0x00000001" contains "0x0"). // substring false-positives (e.g. "0x00000001" contains "0x0").
stdout.lines().find(|l| l.contains("AppsUseLightTheme")).map(|line| { stdout
line.split_whitespace() .lines()
.last() .find(|l| l.contains("AppsUseLightTheme"))
.map(|val| val == "0x0" || val == "0x00000000") .map(|line| {
.unwrap_or(false) line.split_whitespace()
}) .last()
.map(|val| val == "0x0" || val == "0x00000000")
.unwrap_or(false)
})
}) })
.unwrap_or(false) .unwrap_or(false)
}) })
@@ -263,6 +266,12 @@ impl SettingsWindowState {
} }
} }
impl Default for SettingsWindowState {
fn default() -> Self {
Self::new()
}
}
impl CommitWindowState { impl CommitWindowState {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@@ -284,6 +293,12 @@ impl CommitWindowState {
} }
} }
impl Default for CommitWindowState {
fn default() -> Self {
Self::new()
}
}
fn resolve_settings_route(section: Option<&str>) -> &'static str { fn resolve_settings_route(section: Option<&str>) -> &'static str {
match section { match section {
Some("appearance") => "settings/appearance", Some("appearance") => "settings/appearance",
@@ -508,6 +523,12 @@ impl MergeWindowState {
} }
} }
impl Default for MergeWindowState {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "tauri-runtime")] #[cfg(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)] #[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn open_merge_window( pub async fn open_merge_window(
@@ -738,11 +759,9 @@ pub async fn open_project_boot_window(
.inner_size(1400.0, 900.0) .inner_size(1400.0, 900.0)
.min_inner_size(1100.0, 700.0) .min_inner_size(1100.0, 700.0)
.center(); .center();
let window = apply_platform_window_style(builder) let window = apply_platform_window_style(builder).build().map_err(|e| {
.build() AppCommandError::window("Failed to open project boot window", e.to_string())
.map_err(|e| { })?;
AppCommandError::window("Failed to open project boot window", e.to_string())
})?;
post_window_setup(&window); post_window_setup(&window);
Ok(()) Ok(())
@@ -764,12 +783,8 @@ pub async fn update_traffic_light_position(
CURRENT_ZOOM.store(clamped, AtomicOrdering::Relaxed); CURRENT_ZOOM.store(clamped, AtomicOrdering::Relaxed);
// Persist to DB so the next launch reads the correct value. // Persist to DB so the next launch reads the correct value.
let _ = app_metadata_service::upsert_value( let _ =
&db.conn, app_metadata_service::upsert_value(&db.conn, ZOOM_LEVEL_DB_KEY, &clamped.to_string()).await;
ZOOM_LEVEL_DB_KEY,
&clamped.to_string(),
)
.await;
let _ = app; let _ = app;
Ok(()) Ok(())
@@ -786,13 +801,7 @@ pub async fn update_appearance_mode(
) -> Result<(), AppCommandError> { ) -> Result<(), AppCommandError> {
CACHED_APPEARANCE_MODE.store(mode_from_str(&mode), AtomicOrdering::Relaxed); CACHED_APPEARANCE_MODE.store(mode_from_str(&mode), AtomicOrdering::Relaxed);
let _ = app_metadata_service::upsert_value( let _ = app_metadata_service::upsert_value(&db.conn, APPEARANCE_MODE_DB_KEY, &mode).await;
&db.conn,
APPEARANCE_MODE_DB_KEY,
&mode,
)
.await;
Ok(()) Ok(())
} }
@@ -20,22 +20,14 @@ impl MigrationTrait for Migration {
.primary_key(), .primary_key(),
) )
.col(ColumnDef::new(ChatChannel::Name).string().not_null()) .col(ColumnDef::new(ChatChannel::Name).string().not_null())
.col( .col(ColumnDef::new(ChatChannel::ChannelType).string().not_null())
ColumnDef::new(ChatChannel::ChannelType)
.string()
.not_null(),
)
.col( .col(
ColumnDef::new(ChatChannel::Enabled) ColumnDef::new(ChatChannel::Enabled)
.boolean() .boolean()
.not_null() .not_null()
.default(true), .default(true),
) )
.col( .col(ColumnDef::new(ChatChannel::ConfigJson).text().not_null())
ColumnDef::new(ChatChannel::ConfigJson)
.text()
.not_null(),
)
.col(ColumnDef::new(ChatChannel::EventFilterJson).text().null()) .col(ColumnDef::new(ChatChannel::EventFilterJson).text().null())
.col( .col(
ColumnDef::new(ChatChannel::DailyReportEnabled) ColumnDef::new(ChatChannel::DailyReportEnabled)
@@ -43,11 +35,7 @@ impl MigrationTrait for Migration {
.not_null() .not_null()
.default(false), .default(false),
) )
.col( .col(ColumnDef::new(ChatChannel::DailyReportTime).string().null())
ColumnDef::new(ChatChannel::DailyReportTime)
.string()
.null(),
)
.col( .col(
ColumnDef::new(ChatChannel::CreatedAt) ColumnDef::new(ChatChannel::CreatedAt)
.timestamp_with_time_zone() .timestamp_with_time_zone()
@@ -138,11 +126,7 @@ impl MigrationTrait for Migration {
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager manager
.drop_table( .drop_table(Table::drop().table(ChatChannelMessageLog::Table).to_owned())
Table::drop()
.table(ChatChannelMessageLog::Table)
.to_owned(),
)
.await?; .await?;
manager manager
.drop_table(Table::drop().table(ChatChannel::Table).to_owned()) .drop_table(Table::drop().table(ChatChannel::Table).to_owned())
@@ -10,7 +10,11 @@ impl MigrationTrait for Migration {
.alter_table( .alter_table(
Table::alter() Table::alter()
.table(AgentSetting::Table) .table(AgentSetting::Table)
.add_column(ColumnDef::new(AgentSetting::ModelProviderId).integer().null()) .add_column(
ColumnDef::new(AgentSetting::ModelProviderId)
.integer()
.null(),
)
.to_owned(), .to_owned(),
) )
.await .await
@@ -1,7 +1,7 @@
use chrono::Utc; use chrono::Utc;
use sea_orm::sea_query::OnConflict; use sea_orm::sea_query::OnConflict;
use sea_orm::{ConnectionTrait, DatabaseConnection};
use sea_orm::{ActiveValue::NotSet, ColumnTrait, EntityTrait, QueryFilter, Set}; use sea_orm::{ActiveValue::NotSet, ColumnTrait, EntityTrait, QueryFilter, Set};
use sea_orm::{ConnectionTrait, DatabaseConnection};
use crate::db::entities::app_metadata; use crate::db::entities::app_metadata;
use crate::db::error::DbError; use crate::db::error::DbError;
@@ -207,8 +207,7 @@ pub async fn list_all(
sort_by: Option<String>, sort_by: Option<String>,
status: Option<String>, status: Option<String>,
) -> Result<Vec<DbConversationSummary>, DbError> { ) -> Result<Vec<DbConversationSummary>, DbError> {
let mut query = conversation::Entity::find() let mut query = conversation::Entity::find().filter(conversation::Column::DeletedAt.is_null());
.filter(conversation::Column::DeletedAt.is_null());
match folder_ids { match folder_ids {
Some(ids) if !ids.is_empty() => { Some(ids) if !ids.is_empty() => {
+1 -4
View File
@@ -181,10 +181,7 @@ pub async fn list_all_folder_details(
Ok(rows.into_iter().map(to_detail).collect()) Ok(rows.into_iter().map(to_detail).collect())
} }
pub async fn reorder_folders( pub async fn reorder_folders(conn: &DatabaseConnection, ids: Vec<i32>) -> Result<(), DbError> {
conn: &DatabaseConnection,
ids: Vec<i32>,
) -> Result<(), DbError> {
if ids.is_empty() { if ids.is_empty() {
return Ok(()); return Ok(());
} }
+1 -4
View File
@@ -87,10 +87,7 @@ pub async fn delete_tabs_for_folder(
conn: &DatabaseConnection, conn: &DatabaseConnection,
folder_id: i32, folder_id: i32,
) -> Result<(), DbError> { ) -> Result<(), DbError> {
let sql = format!( let sql = format!("DELETE FROM opened_tab WHERE folder_id = {}", folder_id);
"DELETE FROM opened_tab WHERE folder_id = {}",
folder_id
);
conn.execute(Statement::from_string(DbBackend::Sqlite, sql)) conn.execute(Statement::from_string(DbBackend::Sqlite, sql))
.await?; .await?;
Ok(()) Ok(())
+11 -9
View File
@@ -219,10 +219,7 @@ pub async fn get_remote_url(repo_path: &str) -> Option<String> {
} }
/// Get the remote URL for a specific named remote. /// Get the remote URL for a specific named remote.
pub async fn get_remote_url_by_name( pub async fn get_remote_url_by_name(repo_path: &str, remote_name: &str) -> Option<String> {
repo_path: &str,
remote_name: &str,
) -> Option<String> {
let output = crate::process::tokio_command("git") let output = crate::process::tokio_command("git")
.args(["remote", "get-url", remote_name]) .args(["remote", "get-url", remote_name])
.current_dir(repo_path) .current_dir(repo_path)
@@ -235,7 +232,11 @@ pub async fn get_remote_url_by_name(
} }
let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
if url.is_empty() { None } else { Some(url) } if url.is_empty() {
None
} else {
Some(url)
}
} }
/// Extract the hostname from a git remote URL. /// Extract the hostname from a git remote URL.
@@ -302,9 +303,7 @@ pub fn find_matching_account<'a>(
} }
/// Load GitHub accounts from the database. /// Load GitHub accounts from the database.
pub async fn load_github_accounts( pub async fn load_github_accounts(conn: &DatabaseConnection) -> Option<GitHubAccountsSettings> {
conn: &DatabaseConnection,
) -> Option<GitHubAccountsSettings> {
let raw = app_metadata_service::get_value(conn, GITHUB_ACCOUNTS_KEY) let raw = app_metadata_service::get_value(conn, GITHUB_ACCOUNTS_KEY)
.await .await
.ok()??; .ok()??;
@@ -375,7 +374,10 @@ pub async fn try_inject_for_repo_remote(
let remote_url = match get_remote_url_by_name(repo_path, target_remote).await { let remote_url = match get_remote_url_by_name(repo_path, target_remote).await {
Some(url) => url, Some(url) => url,
None => { None => {
eprintln!("[GIT_CRED] no remote URL found for {} (remote: {})", repo_path, target_remote); eprintln!(
"[GIT_CRED] no remote URL found for {} (remote: {})",
repo_path, target_remote
);
return false; return false;
} }
}; };
+4 -2
View File
@@ -62,8 +62,10 @@ pub fn ensure_git_repo(path: &str) -> Result<(), AppCommandError> {
git_path.display() git_path.display()
)) ))
.with_detail(err.to_string())), .with_detail(err.to_string())),
_ => Err(AppCommandError::io(err) _ => Err(AppCommandError::io(err).with_detail(format!(
.with_detail(format!("Failed to inspect Git metadata: {}", git_path.display()))), "Failed to inspect Git metadata: {}",
git_path.display()
))),
}, },
} }
} }
+1 -2
View File
@@ -69,8 +69,7 @@ fn write_tokens(tokens: &std::collections::HashMap<String, String>) -> Result<()
} }
let json = serde_json::to_string_pretty(tokens) let json = serde_json::to_string_pretty(tokens)
.map_err(|e| format!("failed to serialize tokens: {e}"))?; .map_err(|e| format!("failed to serialize tokens: {e}"))?;
std::fs::write(&path, json) std::fs::write(&path, json).map_err(|e| format!("failed to write token store: {e}"))
.map_err(|e| format!("failed to write token store: {e}"))
} }
#[cfg(not(feature = "tauri-runtime"))] #[cfg(not(feature = "tauri-runtime"))]
+3 -6
View File
@@ -181,14 +181,11 @@ mod tauri_app {
}); });
} }
if label == "main" if label == "main" && matches!(event, tauri::WindowEvent::CloseRequested { .. }) {
&& matches!(event, tauri::WindowEvent::CloseRequested { .. })
{
let app = window.app_handle(); let app = window.app_handle();
if let Some(cm) = app.try_state::<ConnectionManager>() { if let Some(cm) = app.try_state::<ConnectionManager>() {
let disconnected = tauri::async_runtime::block_on( let disconnected =
cm.disconnect_by_owner_window(&label), tauri::async_runtime::block_on(cm.disconnect_by_owner_window(&label));
);
eprintln!( eprintln!(
"[ACP] main window closing disconnected_connections={}", "[ACP] main window closing disconnected_connections={}",
disconnected disconnected
+1 -1
View File
@@ -2,8 +2,8 @@ pub mod agent;
pub mod chat_channel; pub mod chat_channel;
pub mod conversation; pub mod conversation;
pub mod folder; pub mod folder;
pub mod model_provider;
pub mod message; pub mod message;
pub mod model_provider;
pub mod system; pub mod system;
pub use agent::AgentType; pub use agent::AgentType;
+13 -19
View File
@@ -586,19 +586,16 @@ impl ClaudeParser {
if tur.get("agentType").is_some() { if tur.get("agentType").is_some() {
let mut stats = extract_agent_execution_stats(tur); let mut stats = extract_agent_execution_stats(tur);
// Load tool calls from subagent's own JSONL transcript // Load tool calls from subagent's own JSONL transcript
if let Some(agent_id) = if let Some(agent_id) = tur.get("agentId").and_then(|v| v.as_str()) {
tur.get("agentId").and_then(|v| v.as_str())
{
// Reject path traversal: agentId must be alphanumeric // Reject path traversal: agentId must be alphanumeric
if !agent_id.is_empty() if !agent_id.is_empty()
&& !agent_id.contains('/') && !agent_id.contains('/')
&& !agent_id.contains('\\') && !agent_id.contains('\\')
&& !agent_id.contains("..") && !agent_id.contains("..")
{ {
let subagent_dir = let subagent_dir = path.with_extension("").join("subagents");
path.with_extension("").join("subagents"); let subagent_path =
let subagent_path = subagent_dir subagent_dir.join(format!("agent-{}.jsonl", agent_id));
.join(format!("agent-{}.jsonl", agent_id));
if subagent_path.exists() { if subagent_path.exists() {
stats.tool_calls = stats.tool_calls =
parse_subagent_tool_calls(&subagent_path); parse_subagent_tool_calls(&subagent_path);
@@ -775,9 +772,7 @@ impl ClaudeParser {
.. ..
} = b } = b
{ {
if tn == tool_name if tn == tool_name && !existing_result_ids.contains(id) {
&& !existing_result_ids.contains(id)
{
return Some(id.clone()); return Some(id.clone());
} }
} }
@@ -980,7 +975,11 @@ fn extract_claude_user_image(item: &serde_json::Value) -> Option<ContentBlock> {
let mime_type = source let mime_type = source
.and_then(|s| s.get("media_type")) .and_then(|s| s.get("media_type"))
.and_then(|m| m.as_str()) .and_then(|m| m.as_str())
.or_else(|| source.and_then(|s| s.get("mime_type")).and_then(|m| m.as_str())) .or_else(|| {
source
.and_then(|s| s.get("mime_type"))
.and_then(|m| m.as_str())
})
.or_else(|| item.get("media_type").and_then(|m| m.as_str())) .or_else(|| item.get("media_type").and_then(|m| m.as_str()))
.or_else(|| item.get("mime_type").and_then(|m| m.as_str())) .or_else(|| item.get("mime_type").and_then(|m| m.as_str()))
.map(str::trim) .map(str::trim)
@@ -1192,9 +1191,7 @@ fn parse_subagent_tool_calls(path: &PathBuf) -> Vec<AgentToolCall> {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("unknown") .unwrap_or("unknown")
.to_string(); .to_string();
let input = item.get("input").map(|v| { let input = item.get("input").map(|v| truncate_str(&v.to_string(), 500));
truncate_str(&v.to_string(), 500)
});
if !id.is_empty() { if !id.is_empty() {
calls.push((id, name, input)); calls.push((id, name, input));
} }
@@ -1219,8 +1216,7 @@ fn parse_subagent_tool_calls(path: &PathBuf) -> Vec<AgentToolCall> {
.get("is_error") .get("is_error")
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())
.unwrap_or(false); .unwrap_or(false);
let output = extract_tool_result_text(item) let output = extract_tool_result_text(item).map(|s| truncate_str(&s, 500));
.map(|s| truncate_str(&s, 500));
if !id.is_empty() { if !id.is_empty() {
results.insert(id, (output, is_error)); results.insert(id, (output, is_error));
} }
@@ -1233,9 +1229,7 @@ fn parse_subagent_tool_calls(path: &PathBuf) -> Vec<AgentToolCall> {
calls calls
.into_iter() .into_iter()
.map(|(id, name, input)| { .map(|(id, name, input)| {
let (output, is_error) = results let (output, is_error) = results.remove(&id).unwrap_or((None, false));
.remove(&id)
.unwrap_or((None, false));
AgentToolCall { AgentToolCall {
tool_name: name, tool_name: name,
input_preview: input, input_preview: input,
+6 -21
View File
@@ -135,20 +135,13 @@ impl AgentParser for ClineParser {
fs::read_to_string(meta_path) fs::read_to_string(meta_path)
.ok() .ok()
.and_then(|raw| serde_json::from_str::<TaskMetadata>(&raw).ok()) .and_then(|raw| serde_json::from_str::<TaskMetadata>(&raw).ok())
.and_then(|meta| { .and_then(|meta| meta.model_usage.first().and_then(|u| u.model_id.clone()))
meta.model_usage
.first()
.and_then(|u| u.model_id.clone())
})
}); });
let folder_path = entry.cwd_on_task_initialization.clone(); let folder_path = entry.cwd_on_task_initialization.clone();
let folder_name = folder_path.as_deref().map(folder_name_from_path); let folder_name = folder_path.as_deref().map(folder_name_from_path);
let title = entry let title = entry.task.as_deref().map(|t| truncate_str(t.trim(), 100));
.task
.as_deref()
.map(|t| truncate_str(t.trim(), 100));
// Count messages from api_conversation_history.json // Count messages from api_conversation_history.json
let api_path = tasks_dir.join("api_conversation_history.json"); let api_path = tasks_dir.join("api_conversation_history.json");
@@ -309,10 +302,7 @@ impl AgentParser for ClineParser {
} }
} }
let started_at = turns let started_at = turns.first().map(|t| t.timestamp).unwrap_or_else(Utc::now);
.first()
.map(|t| t.timestamp)
.unwrap_or_else(Utc::now);
let ended_at = turns.last().map(|t| t.timestamp); let ended_at = turns.last().map(|t| t.timestamp);
let session_stats = compute_session_stats(&turns); let session_stats = compute_session_stats(&turns);
@@ -441,8 +431,7 @@ fn collect_text_parts(content: &serde_json::Value) -> Vec<String> {
/// Check if text looks like a Cline tool result: `[tool_name ...] Result:` /// Check if text looks like a Cline tool result: `[tool_name ...] Result:`
fn is_tool_result_text(text: &str) -> bool { fn is_tool_result_text(text: &str) -> bool {
let trimmed = text.trim_start(); let trimmed = text.trim_start();
trimmed.starts_with('[') trimmed.starts_with('[') && trimmed.contains("] Result:")
&& trimmed.contains("] Result:")
} }
/// Parse `[tool_name for 'arg'] Result:\ncontent` into (tool_name, output, is_error). /// Parse `[tool_name for 'arg'] Result:\ncontent` into (tool_name, output, is_error).
@@ -451,10 +440,7 @@ fn parse_tool_result_text(text: &str) -> (String, String, bool) {
// Extract tool name from [tool_name ...] or [tool_name] prefix // Extract tool name from [tool_name ...] or [tool_name] prefix
let tool_name = trimmed let tool_name = trimmed
.strip_prefix('[') .strip_prefix('[')
.and_then(|s| { .and_then(|s| s.find([']', ' ']).map(|i| s[..i].to_string()))
s.find([']', ' '])
.map(|i| s[..i].to_string())
})
.unwrap_or_default(); .unwrap_or_default();
let is_error = trimmed.contains("[ERROR]") || trimmed.contains("Error:"); let is_error = trimmed.contains("[ERROR]") || trimmed.contains("Error:");
@@ -537,8 +523,7 @@ fn parse_content_blocks(content: &serde_json::Value) -> Vec<ContentBlock> {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("unknown") .unwrap_or("unknown")
.to_string(); .to_string();
let tool_use_id = let tool_use_id = item.get("id").and_then(|v| v.as_str()).map(String::from);
item.get("id").and_then(|v| v.as_str()).map(String::from);
let input_preview = item.get("input").map(|v| { let input_preview = item.get("input").map(|v| {
let s = v.to_string(); let s = v.to_string();
truncate_str(&s, 2000) truncate_str(&s, 2000)
+49 -70
View File
@@ -564,24 +564,14 @@ fn parse_codex_subagent_stats(
let input_preview = if tool_name == "exec_command" { let input_preview = if tool_name == "exec_command" {
parse_codex_json_arg(payload) parse_codex_json_arg(payload)
.and_then(|a| { .and_then(|a| a.get("cmd").and_then(|v| v.as_str()).map(|s| s.to_string()))
a.get("cmd")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.or_else(|| { .or_else(|| {
value_to_preview( value_to_preview(
payload payload.get("arguments").or_else(|| payload.get("input")),
.get("arguments")
.or_else(|| payload.get("input")),
) )
}) })
} else { } else {
value_to_preview( value_to_preview(payload.get("arguments").or_else(|| payload.get("input")))
payload
.get("arguments")
.or_else(|| payload.get("input")),
)
}; };
let tc = AgentToolCall { let tc = AgentToolCall {
@@ -631,7 +621,11 @@ fn parse_codex_subagent_stats(
let total_duration_ms = match (first_ts, last_ts) { let total_duration_ms = match (first_ts, last_ts) {
(Some(f), Some(l)) => { (Some(f), Some(l)) => {
let dur = (l - f).num_milliseconds(); let dur = (l - f).num_milliseconds();
if dur > 0 { Some(dur as u64) } else { None } if dur > 0 {
Some(dur as u64)
} else {
None
}
} }
_ => None, _ => None,
}; };
@@ -955,10 +949,8 @@ impl CodexParser {
.and_then(|a| a.get("message")) .and_then(|a| a.get("message"))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(""); .unwrap_or("");
let description = truncate_str( let description =
message.lines().next().unwrap_or(""), truncate_str(message.lines().next().unwrap_or(""), 60);
60,
);
if let Some(ref id) = tool_use_id { if let Some(ref id) = tool_use_id {
spawn_agent_call_ids.insert(id.clone()); spawn_agent_call_ids.insert(id.clone());
@@ -993,47 +985,43 @@ impl CodexParser {
"close_agent" => { "close_agent" => {
if let Some(ref id) = tool_use_id { if let Some(ref id) = tool_use_id {
close_agent_call_ids.insert(id.clone()); close_agent_call_ids.insert(id.clone());
let target = parse_codex_json_arg(payload) let target =
.and_then(|a| { parse_codex_json_arg(payload).and_then(|a| {
a.get("target") a.get("target")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.to_string()) .map(|s| s.to_string())
}); });
if let Some(target) = target { if let Some(target) = target {
close_agent_targets close_agent_targets.insert(id.clone(), target);
.insert(id.clone(), target);
} }
} }
} }
_ => { _ => {
if let Some(ref id) = tool_use_id { if let Some(ref id) = tool_use_id {
call_id_tool_names.insert( call_id_tool_names
id.clone(), .insert(id.clone(), raw_tool_name.to_string());
raw_tool_name.to_string(),
);
} }
let input_preview = let input_preview = if raw_tool_name == "exec_command" {
if raw_tool_name == "exec_command" { parse_codex_json_arg(payload)
parse_codex_json_arg(payload) .and_then(|a| {
.and_then(|a| { a.get("cmd")
a.get("cmd") .and_then(|v| v.as_str())
.and_then(|v| v.as_str()) .map(|s| s.to_string())
.map(|s| s.to_string()) })
}) .or_else(|| {
.or_else(|| { value_to_preview(
value_to_preview( payload
payload.get("arguments").or_else( .get("arguments")
|| payload.get("input"), .or_else(|| payload.get("input")),
), )
) })
}) } else {
} else { value_to_preview(
value_to_preview( payload
payload .get("arguments")
.get("arguments") .or_else(|| payload.get("input")),
.or_else(|| payload.get("input")), )
) };
};
messages.push(UnifiedMessage { messages.push(UnifiedMessage {
id: format!("tool-{}", messages.len()), id: format!("tool-{}", messages.len()),
role: MessageRole::Assistant, role: MessageRole::Assistant,
@@ -1071,15 +1059,11 @@ impl CodexParser {
if is_spawn { if is_spawn {
if let Some(output_obj) = parse_codex_json_output(payload) { if let Some(output_obj) = parse_codex_json_output(payload) {
if let (Some(agent_id), Some(call_id)) = ( if let (Some(agent_id), Some(call_id)) = (
output_obj output_obj.get("agent_id").and_then(|v| v.as_str()),
.get("agent_id")
.and_then(|v| v.as_str()),
tool_use_id.as_ref(), tool_use_id.as_ref(),
) { ) {
agent_id_to_spawn_call_id.insert( agent_id_to_spawn_call_id
agent_id.to_string(), .insert(agent_id.to_string(), call_id.clone());
call_id.clone(),
);
} }
} }
messages.push(UnifiedMessage { messages.push(UnifiedMessage {
@@ -1098,14 +1082,12 @@ impl CodexParser {
}); });
} else if is_wait { } else if is_wait {
if let Some(output_obj) = parse_codex_json_output(payload) { if let Some(output_obj) = parse_codex_json_output(payload) {
if let Some(status) = output_obj if let Some(status) =
.get("status") output_obj.get("status").and_then(|s| s.as_object())
.and_then(|s| s.as_object())
{ {
for (agent_id, result) in status { for (agent_id, result) in status {
if let Some(text) = result if let Some(text) =
.get("completed") result.get("completed").and_then(|v| v.as_str())
.and_then(|v| v.as_str())
{ {
agent_final_results agent_final_results
.entry(agent_id.clone()) .entry(agent_id.clone())
@@ -1115,8 +1097,7 @@ impl CodexParser {
} }
} }
} else if is_close { } else if is_close {
active_agent_count = active_agent_count = active_agent_count.saturating_sub(1);
active_agent_count.saturating_sub(1);
if let Some(output_obj) = parse_codex_json_output(payload) { if let Some(output_obj) = parse_codex_json_output(payload) {
if let Some(agent_id) = tool_use_id if let Some(agent_id) = tool_use_id
.as_ref() .as_ref()
@@ -1135,7 +1116,9 @@ impl CodexParser {
} }
} else { } else {
let is_exec = tool_use_id.as_ref().is_some_and(|id| { let is_exec = tool_use_id.as_ref().is_some_and(|id| {
call_id_tool_names.get(id).is_some_and(|n| n == "exec_command") call_id_tool_names
.get(id)
.is_some_and(|n| n == "exec_command")
}); });
let output_value = payload.get("output"); let output_value = payload.get("output");
let raw_output = value_to_preview(output_value); let raw_output = value_to_preview(output_value);
@@ -1235,9 +1218,7 @@ impl CodexParser {
if let Some(dir) = session_dir { if let Some(dir) = session_dir {
let stats = agent_stats_cache let stats = agent_stats_cache
.entry(agent_id.to_string()) .entry(agent_id.to_string())
.or_insert_with(|| { .or_insert_with(|| parse_codex_subagent_stats(dir, agent_id));
parse_codex_subagent_stats(dir, agent_id)
});
if stats.is_some() { if stats.is_some() {
*agent_stats = stats.clone(); *agent_stats = stats.clone();
} }
@@ -1677,9 +1658,7 @@ fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
// Only absorb immediately following Tool messages // Only absorb immediately following Tool messages
// (stop at the next assistant message to keep turns small for virtualization) // (stop at the next assistant message to keep turns small for virtualization)
while i < messages.len() while i < messages.len() && matches!(messages[i].role, MessageRole::Tool) {
&& matches!(messages[i].role, MessageRole::Tool)
{
blocks.extend(messages[i].content.clone()); blocks.extend(messages[i].content.clone());
if usage.is_none() { if usage.is_none() {
usage = messages[i].usage.clone(); usage = messages[i].usage.clone();
+1 -3
View File
@@ -687,9 +687,7 @@ fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
// Only absorb immediately following Tool messages // Only absorb immediately following Tool messages
// (stop at the next assistant message to keep turns small for virtualization) // (stop at the next assistant message to keep turns small for virtualization)
while i < messages.len() while i < messages.len() && matches!(messages[i].role, MessageRole::Tool) {
&& matches!(messages[i].role, MessageRole::Tool)
{
blocks.extend(messages[i].content.clone()); blocks.extend(messages[i].content.clone());
if usage.is_none() { if usage.is_none() {
usage = messages[i].usage.clone(); usage = messages[i].usage.clone();
+13 -11
View File
@@ -471,8 +471,7 @@ pub fn resolve_patch_text(patch: &str, cwd: Option<&str>) -> Option<String> {
if let (Some(ref fl), true) = (&file_lines, current_file_path.is_some()) { if let (Some(ref fl), true) = (&file_lines, current_file_path.is_some()) {
// Collect context lines from this hunk to find match position // Collect context lines from this hunk to find match position
let hunk_lines = collect_hunk_lines(&lines, i + 1); let hunk_lines = collect_hunk_lines(&lines, i + 1);
if let Some((old_start, old_count, new_count)) = if let Some((old_start, old_count, new_count)) = find_hunk_position(fl, &hunk_lines)
find_hunk_position(fl, &hunk_lines)
{ {
let new_start = old_start; // same start for context-based patches let new_start = old_start; // same start for context-based patches
output.push_str(&format!( output.push_str(&format!(
@@ -496,7 +495,11 @@ pub fn resolve_patch_text(patch: &str, cwd: Option<&str>) -> Option<String> {
i += 1; i += 1;
} }
if any_resolved { Some(output) } else { None } if any_resolved {
Some(output)
} else {
None
}
} }
/// Load file lines from disk, trying both absolute path and cwd-relative. /// Load file lines from disk, trying both absolute path and cwd-relative.
@@ -523,9 +526,7 @@ pub fn load_file_lines(path: &str, cwd: Option<&str>) -> Option<Vec<String>> {
fn collect_hunk_lines<'a>(lines: &'a [&'a str], start: usize) -> Vec<&'a str> { fn collect_hunk_lines<'a>(lines: &'a [&'a str], start: usize) -> Vec<&'a str> {
let mut result = Vec::new(); let mut result = Vec::new();
for &line in &lines[start..] { for &line in &lines[start..] {
if line == "@@" if line == "@@" || line.starts_with("*** ") {
|| line.starts_with("*** ")
{
break; break;
} }
result.push(line); result.push(line);
@@ -541,10 +542,7 @@ fn collect_hunk_lines<'a>(lines: &'a [&'a str], start: usize) -> Vec<&'a str> {
/// 1. Contiguous match of context+added lines (post-patch file, no further edits) /// 1. Contiguous match of context+added lines (post-patch file, no further edits)
/// 2. Contiguous match of context+deleted lines (pre-patch file) /// 2. Contiguous match of context+deleted lines (pre-patch file)
/// 3. Subsequence match of context-only lines (file has been further modified) /// 3. Subsequence match of context-only lines (file has been further modified)
fn find_hunk_position( fn find_hunk_position(file_lines: &[String], hunk_lines: &[&str]) -> Option<(usize, usize, usize)> {
file_lines: &[String],
hunk_lines: &[&str],
) -> Option<(usize, usize, usize)> {
let mut old_count = 0usize; let mut old_count = 0usize;
let mut new_count = 0usize; let mut new_count = 0usize;
for hl in hunk_lines { for hl in hunk_lines {
@@ -601,7 +599,11 @@ fn find_contiguous(file_lines: &[String], view: &[&str]) -> Option<usize> {
if file_lines[i].as_str() != first { if file_lines[i].as_str() != first {
continue; continue;
} }
if view.iter().enumerate().all(|(j, v)| file_lines[i + j].as_str() == *v) { if view
.iter()
.enumerate()
.all(|(j, v)| file_lines[i + j].as_str() == *v)
{
return Some(i); return Some(i);
} }
} }
+31 -35
View File
@@ -19,9 +19,7 @@ use crate::parsers::{
/// timestamp prefix from OpenClaw user messages. /// timestamp prefix from OpenClaw user messages.
fn sender_block_regex() -> &'static Regex { fn sender_block_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new(); static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| { RE.get_or_init(|| Regex::new(r"(?s)^Sender \(untrusted metadata\):\s*```[^`]*```\s*").unwrap())
Regex::new(r"(?s)^Sender \(untrusted metadata\):\s*```[^`]*```\s*").unwrap()
})
} }
fn timestamp_prefix_regex() -> &'static Regex { fn timestamp_prefix_regex() -> &'static Regex {
@@ -354,9 +352,7 @@ impl OpenClawParser {
} }
/// Read sessions.json for a given agent directory. /// Read sessions.json for a given agent directory.
fn read_session_index( fn read_session_index(agent_dir: &Path) -> Result<HashMap<String, SessionMeta>, ParseError> {
agent_dir: &Path,
) -> Result<HashMap<String, SessionMeta>, ParseError> {
let index_path = agent_dir.join("sessions").join("sessions.json"); let index_path = agent_dir.join("sessions").join("sessions.json");
if !index_path.exists() { if !index_path.exists() {
return Ok(HashMap::new()); return Ok(HashMap::new());
@@ -710,18 +706,12 @@ impl OpenClawParser {
if jsonl_path.exists() { if jsonl_path.exists() {
let meta = Self::read_session_index(&agent_dir) let meta = Self::read_session_index(&agent_dir)
.ok() .ok()
.and_then(|index| { .and_then(|index| index.into_values().find(|m| m.session_id == session_id));
index
.into_values()
.find(|m| m.session_id == session_id)
});
return Ok((jsonl_path, leaf_id, meta)); return Ok((jsonl_path, leaf_id, meta));
} }
// Try reset files // Try reset files
if let Some((path, meta)) = if let Some((path, meta)) = Self::find_reset_file(&agent_dir, session_id) {
Self::find_reset_file(&agent_dir, session_id)
{
return Ok((path, leaf_id, meta)); return Ok((path, leaf_id, meta));
} }
} }
@@ -747,9 +737,7 @@ impl OpenClawParser {
if jsonl_path.exists() { if jsonl_path.exists() {
let meta = Self::read_session_index(&agent_dir) let meta = Self::read_session_index(&agent_dir)
.ok() .ok()
.and_then(|index| { .and_then(|index| index.into_values().find(|m| m.session_id == bare_id));
index.into_values().find(|m| m.session_id == bare_id)
});
return Ok((jsonl_path, None, meta)); return Ok((jsonl_path, None, meta));
} }
@@ -768,9 +756,8 @@ impl OpenClawParser {
// Check if any leaf id matches the bare_id // Check if any leaf id matches the bare_id
let leaves = tree.leaf_ids(); let leaves = tree.leaf_ids();
if leaves.iter().any(|l| l == bare_id) { if leaves.iter().any(|l| l == bare_id) {
let meta = Self::read_session_index(&agent_dir) let meta =
.ok() Self::read_session_index(&agent_dir).ok().and_then(|index| {
.and_then(|index| {
index.into_values().find(|m| m.session_id == *sid) index.into_values().find(|m| m.session_id == *sid)
}); });
return Ok((path.clone(), Some(bare_id.to_string()), meta)); return Ok((path.clone(), Some(bare_id.to_string()), meta));
@@ -905,7 +892,10 @@ fn extract_first_text_content(value: &serde_json::Value) -> Option<String> {
let content = value.get("message")?.get("content")?.as_array()?; let content = value.get("message")?.get("content")?.as_array()?;
for item in content { for item in content {
if item.get("type").and_then(|t| t.as_str()) == Some("text") { if item.get("type").and_then(|t| t.as_str()) == Some("text") {
return item.get("text").and_then(|t| t.as_str()).map(|s| s.to_string()); return item
.get("text")
.and_then(|t| t.as_str())
.map(|s| s.to_string());
} }
} }
None None
@@ -987,8 +977,13 @@ fn extract_assistant_content(value: &serde_json::Value) -> Vec<ContentBlock> {
.to_string(); .to_string();
let is_edit_tool = matches!( let is_edit_tool = matches!(
tool_name.to_lowercase().as_str(), tool_name.to_lowercase().as_str(),
"edit" | "write" | "apply_patch" | "patch" | "applypatch" "edit"
| "edit_file" | "editfile" | "write"
| "apply_patch"
| "patch"
| "applypatch"
| "edit_file"
| "editfile"
); );
let max_len = if is_edit_tool { 50000 } else { 500 }; let max_len = if is_edit_tool { 50000 } else { 500 };
let input_preview = item.get("arguments").map(|a| { let input_preview = item.get("arguments").map(|a| {
@@ -1068,10 +1063,7 @@ fn extract_usage(value: &serde_json::Value) -> Option<TurnUsage> {
.get("cacheWrite") .get("cacheWrite")
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.unwrap_or(0), .unwrap_or(0),
cache_read_input_tokens: usage cache_read_input_tokens: usage.get("cacheRead").and_then(|v| v.as_u64()).unwrap_or(0),
.get("cacheRead")
.and_then(|v| v.as_u64())
.unwrap_or(0),
}) })
} }
@@ -1117,9 +1109,7 @@ fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
// Only absorb immediately following Tool messages // Only absorb immediately following Tool messages
// (stop at the next assistant message to keep turns small for virtualization) // (stop at the next assistant message to keep turns small for virtualization)
while i < messages.len() while i < messages.len() && matches!(messages[i].role, MessageRole::Tool) {
&& matches!(messages[i].role, MessageRole::Tool)
{
blocks.extend(messages[i].content.clone()); blocks.extend(messages[i].content.clone());
if usage.is_none() { if usage.is_none() {
usage = messages[i].usage.clone(); usage = messages[i].usage.clone();
@@ -1162,7 +1152,8 @@ mod tests {
#[test] #[test]
fn extracts_working_directory() { fn extracts_working_directory() {
let text = "[Tue 2026-03-17 12:58 GMT+8] [Working directory: ~/forway/agent-workspace]\n\nHello"; let text =
"[Tue 2026-03-17 12:58 GMT+8] [Working directory: ~/forway/agent-workspace]\n\nHello";
let wd = extract_working_dir(text).unwrap(); let wd = extract_working_dir(text).unwrap();
let home = dirs::home_dir().unwrap().to_string_lossy().to_string(); let home = dirs::home_dir().unwrap().to_string_lossy().to_string();
assert_eq!(wd, format!("{}/forway/agent-workspace", home)); assert_eq!(wd, format!("{}/forway/agent-workspace", home));
@@ -1219,9 +1210,13 @@ mod tests {
}); });
let blocks = extract_assistant_content(&value); let blocks = extract_assistant_content(&value);
assert_eq!(blocks.len(), 3); assert_eq!(blocks.len(), 3);
assert!(matches!(&blocks[0], ContentBlock::Thinking { text } if text == "I should read the file")); assert!(
matches!(&blocks[0], ContentBlock::Thinking { text } if text == "I should read the file")
);
assert!(matches!(&blocks[1], ContentBlock::Text { text } if text == "Let me check.")); assert!(matches!(&blocks[1], ContentBlock::Text { text } if text == "Let me check."));
assert!(matches!(&blocks[2], ContentBlock::ToolUse { tool_name, .. } if tool_name == "read")); assert!(
matches!(&blocks[2], ContentBlock::ToolUse { tool_name, .. } if tool_name == "read")
);
} }
#[test] #[test]
@@ -1272,8 +1267,9 @@ mod tests {
json!({"type":"message","id":"a1","parentId":"u1","timestamp":"2026-03-17T04:56:30.466Z","message":{"role":"assistant","content":[{"type":"text","text":"[[reply_to_current]] Hi there!"}],"model":"gpt-5.4","usage":{"input":100,"output":50,"cacheRead":200,"cacheWrite":0,"totalTokens":350},"stopReason":"stop","timestamp":1773723390466_i64}}) json!({"type":"message","id":"a1","parentId":"u1","timestamp":"2026-03-17T04:56:30.466Z","message":{"role":"assistant","content":[{"type":"text","text":"[[reply_to_current]] Hi there!"}],"model":"gpt-5.4","usage":{"input":100,"output":50,"cacheRead":200,"cacheWrite":0,"totalTokens":350},"stopReason":"stop","timestamp":1773723390466_i64}})
).unwrap(); ).unwrap();
let detail = OpenClawParser::parse_conversation_detail(&path, "test/test-session", None, None) let detail =
.expect("parse detail"); OpenClawParser::parse_conversation_detail(&path, "test/test-session", None, None)
.expect("parse detail");
fs::remove_file(&path).unwrap(); fs::remove_file(&path).unwrap();
assert_eq!(detail.turns.len(), 2); assert_eq!(detail.turns.len(), 2);
+11 -11
View File
@@ -228,9 +228,7 @@ impl OpenCodeParser {
// Pre-scan: collect all subagent session IDs from task tool parts so we // Pre-scan: collect all subagent session IDs from task tool parts so we
// can batch-load their tool calls in a single query instead of N queries. // can batch-load their tool calls in a single query instead of N queries.
let subagent_session_ids = self let subagent_session_ids = self.scan_subagent_session_ids(conn, conversation_id).await;
.scan_subagent_session_ids(conn, conversation_id)
.await;
let subagent_tools = batch_load_subagent_tool_calls(conn, &subagent_session_ids).await; let subagent_tools = batch_load_subagent_tool_calls(conn, &subagent_session_ids).await;
let mut messages = Vec::with_capacity(rows.len()); let mut messages = Vec::with_capacity(rows.len());
@@ -270,8 +268,9 @@ impl OpenCodeParser {
None None
}; };
let (content_blocks, usage_from_step_finish) = let (content_blocks, usage_from_step_finish) = self
self.load_sqlite_parts(conn, &msg_id, &subagent_tools).await?; .load_sqlite_parts(conn, &msg_id, &subagent_tools)
.await?;
let usage = if is_assistant { let usage = if is_assistant {
extract_opencode_usage(&value).or(usage_from_step_finish) extract_opencode_usage(&value).or(usage_from_step_finish)
@@ -511,8 +510,7 @@ impl OpenCodeParser {
agent_stats, agent_stats,
}); });
} else { } else {
let input_preview = state_input let input_preview = state_input.and_then(|v| value_to_preview(Some(v)));
.and_then(|v| value_to_preview(Some(v)));
blocks.push(ContentBlock::ToolUse { blocks.push(ContentBlock::ToolUse {
tool_use_id: call_id.clone(), tool_use_id: call_id.clone(),
@@ -811,9 +809,7 @@ fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
// Only absorb immediately following Tool messages // Only absorb immediately following Tool messages
// (stop at the next assistant message to keep turns small for virtualization) // (stop at the next assistant message to keep turns small for virtualization)
while i < messages.len() while i < messages.len() && matches!(messages[i].role, MessageRole::Tool) {
&& matches!(messages[i].role, MessageRole::Tool)
{
blocks.extend(messages[i].content.clone()); blocks.extend(messages[i].content.clone());
if usage.is_none() { if usage.is_none() {
usage = messages[i].usage.clone(); usage = messages[i].usage.clone();
@@ -888,7 +884,11 @@ async fn batch_load_subagent_tool_calls(
let values: Vec<sea_orm::Value> = session_ids.iter().map(|s| s.as_str().into()).collect(); let values: Vec<sea_orm::Value> = session_ids.iter().map(|s| s.as_str().into()).collect();
let rows = match conn let rows = match conn
.query_all(Statement::from_sql_and_values(DbBackend::Sqlite, &sql, values)) .query_all(Statement::from_sql_and_values(
DbBackend::Sqlite,
&sql,
values,
))
.await .await
{ {
Ok(r) => r, Ok(r) => r,
+4 -3
View File
@@ -280,8 +280,7 @@ fn find_node_bin_dir(home: Option<&std::path::Path>) -> Option<PathBuf> {
} }
// All installed versions, newest first. // All installed versions, newest first.
if let Some(nvm_home) = if let Some(nvm_home) = resolve_dir(&[("NVM_HOME", &[]), ("APPDATA", &["nvm"])], None, &[])
resolve_dir(&[("NVM_HOME", &[]), ("APPDATA", &["nvm"])], None, &[])
{ {
if nvm_home.is_dir() { if nvm_home.is_dir() {
if let Ok(mut entries) = std::fs::read_dir(&nvm_home).map(|rd| { if let Ok(mut entries) = std::fs::read_dir(&nvm_home).map(|rd| {
@@ -460,7 +459,9 @@ fn find_node_bin_dir(home: Option<&std::path::Path>) -> Option<PathBuf> {
} }
// Return the first candidate that actually contains a `node` binary. // Return the first candidate that actually contains a `node` binary.
candidates.into_iter().find(|dir| dir.join(node_bin).is_file()) candidates
.into_iter()
.find(|dir| dir.join(node_bin).is_file())
} }
/// Prepend a directory to the process `PATH` environment variable. /// Prepend a directory to the process `PATH` environment variable.
+1 -5
View File
@@ -5,11 +5,7 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
pub async fn require_token( pub async fn require_token(request: Request, next: Next, token: String) -> Response {
request: Request,
next: Next,
token: String,
) -> Response {
// Allow WebSocket upgrade requests to authenticate via query param. // Allow WebSocket upgrade requests to authenticate via query param.
// The token value is URL-encoded by the client, so decode before comparing. // The token value is URL-encoded by the client, so decode before comparing.
if let Some(query) = request.uri().query() { if let Some(query) = request.uri().query() {
+1 -5
View File
@@ -56,11 +56,7 @@ pub enum EventEmitter {
} }
/// Unified event emission: sends to both Tauri webview and Web clients (if applicable). /// Unified event emission: sends to both Tauri webview and Web clients (if applicable).
pub fn emit_event( pub fn emit_event(emitter: &EventEmitter, event: &str, payload: impl Serialize + Clone) {
emitter: &EventEmitter,
event: &str,
payload: impl Serialize + Clone,
) {
match emitter { match emitter {
#[cfg(feature = "tauri-runtime")] #[cfg(feature = "tauri-runtime")]
EventEmitter::Tauri(app) => { EventEmitter::Tauri(app) => {
+9 -14
View File
@@ -188,10 +188,9 @@ pub struct AcpListAgentSkillsParams {
pub async fn acp_list_agent_skills( pub async fn acp_list_agent_skills(
Json(params): Json<AcpListAgentSkillsParams>, Json(params): Json<AcpListAgentSkillsParams>,
) -> Result<Json<AgentSkillsListResult>, AppCommandError> { ) -> Result<Json<AgentSkillsListResult>, AppCommandError> {
let result = let result = acp_commands::acp_list_agent_skills(params.agent_type, params.workspace_path)
acp_commands::acp_list_agent_skills(params.agent_type, params.workspace_path) .await
.await .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?;
.map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?;
Ok(Json(result)) Ok(Json(result))
} }
@@ -482,10 +481,9 @@ pub async fn acp_detect_agent_local_version(
Json(params): Json<AgentTypeParams>, Json(params): Json<AgentTypeParams>,
) -> Result<Json<Option<String>>, AppCommandError> { ) -> Result<Json<Option<String>>, AppCommandError> {
let db = &state.db; let db = &state.db;
let result = let result = acp_commands::acp_detect_agent_local_version_core(params.agent_type, &db.conn)
acp_commands::acp_detect_agent_local_version_core(params.agent_type, &db.conn) .await
.await .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?;
.map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?;
Ok(Json(result)) Ok(Json(result))
} }
@@ -610,11 +608,8 @@ pub struct CodexPollDeviceCodeParams {
pub async fn codex_poll_device_code( pub async fn codex_poll_device_code(
Json(params): Json<CodexPollDeviceCodeParams>, Json(params): Json<CodexPollDeviceCodeParams>,
) -> Result<Json<acp_commands::CodexDeviceCodePollResult>, AppCommandError> { ) -> Result<Json<acp_commands::CodexDeviceCodePollResult>, AppCommandError> {
let result = acp_commands::codex_poll_device_code_core( let result = acp_commands::codex_poll_device_code_core(params.device_auth_id, params.user_code)
params.device_auth_id, .await
params.user_code, .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?;
)
.await
.map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?;
Ok(Json(result)) Ok(Json(result))
} }
+3 -5
View File
@@ -5,8 +5,8 @@ use serde::Deserialize;
use crate::app_error::AppCommandError; use crate::app_error::AppCommandError;
use crate::app_state::AppState; use crate::app_state::AppState;
use crate::commands::chat_channel as cc_commands;
use crate::chat_channel::backends::weixin::{WeixinQrcodeInfo, WeixinQrcodeStatusPublic}; use crate::chat_channel::backends::weixin::{WeixinQrcodeInfo, WeixinQrcodeStatusPublic};
use crate::commands::chat_channel as cc_commands;
use crate::models::chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo}; use crate::models::chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -167,8 +167,7 @@ pub async fn test_chat_channel(
pub async fn get_chat_channel_status( pub async fn get_chat_channel_status(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
) -> Result<Json<Vec<ChannelStatusInfo>>, AppCommandError> { ) -> Result<Json<Vec<ChannelStatusInfo>>, AppCommandError> {
let result = let result = cc_commands::get_chat_channel_status_core(&state.chat_channel_manager).await?;
cc_commands::get_chat_channel_status_core(&state.chat_channel_manager).await?;
Ok(Json(result)) Ok(Json(result))
} }
@@ -270,7 +269,6 @@ pub async fn weixin_check_qrcode(
Json(params): Json<WeixinCheckQrcodeParams>, Json(params): Json<WeixinCheckQrcodeParams>,
) -> Result<Json<WeixinQrcodeStatusPublic>, AppCommandError> { ) -> Result<Json<WeixinQrcodeStatusPublic>, AppCommandError> {
let result = let result =
cc_commands::weixin_check_qrcode_core(&state.db, params.channel_id, &params.qrcode) cc_commands::weixin_check_qrcode_core(&state.db, params.channel_id, &params.qrcode).await?;
.await?;
Ok(Json(result)) Ok(Json(result))
} }
+5 -5
View File
@@ -98,8 +98,7 @@ pub struct GetConversationParams {
pub async fn get_conversation( pub async fn get_conversation(
Json(params): Json<GetConversationParams>, Json(params): Json<GetConversationParams>,
) -> Result<Json<ConversationDetail>, AppCommandError> { ) -> Result<Json<ConversationDetail>, AppCommandError> {
let result = let result = conv_commands::get_conversation(params.agent_type, params.conversation_id).await?;
conv_commands::get_conversation(params.agent_type, params.conversation_id).await?;
Ok(Json(result)) Ok(Json(result))
} }
@@ -149,9 +148,10 @@ pub async fn import_local_conversations(
.await .await
.map_err(AppCommandError::from)? .map_err(AppCommandError::from)?
.ok_or_else(|| AppCommandError::not_found("Folder not found"))?; .ok_or_else(|| AppCommandError::not_found("Folder not found"))?;
let result = import_service::import_local_conversations(&db.conn, params.folder_id, &folder.path) let result =
.await import_service::import_local_conversations(&db.conn, params.folder_id, &folder.path)
.map_err(AppCommandError::from)?; .await
.map_err(AppCommandError::from)?;
Ok(Json(result)) Ok(Json(result))
} }
+8 -18
View File
@@ -77,24 +77,21 @@ pub struct CreateFileTreeEntryParams {
pub async fn read_file_preview( pub async fn read_file_preview(
Json(params): Json<ReadFilePreviewParams>, Json(params): Json<ReadFilePreviewParams>,
) -> Result<Json<folder_commands::FilePreviewContent>, AppCommandError> { ) -> Result<Json<folder_commands::FilePreviewContent>, AppCommandError> {
let result = let result = folder_commands::read_file_preview(params.root_path, params.path).await?;
folder_commands::read_file_preview(params.root_path, params.path).await?;
Ok(Json(result)) Ok(Json(result))
} }
pub async fn read_file_base64( pub async fn read_file_base64(
Json(params): Json<ReadFileBase64Params>, Json(params): Json<ReadFileBase64Params>,
) -> Result<Json<String>, AppCommandError> { ) -> Result<Json<String>, AppCommandError> {
let result = let result = folder_commands::read_file_base64(params.path, params.max_bytes).await?;
folder_commands::read_file_base64(params.path, params.max_bytes).await?;
Ok(Json(result)) Ok(Json(result))
} }
pub async fn read_file_for_edit( pub async fn read_file_for_edit(
Json(params): Json<ReadFileForEditParams>, Json(params): Json<ReadFileForEditParams>,
) -> Result<Json<folder_commands::FileEditContent>, AppCommandError> { ) -> Result<Json<folder_commands::FileEditContent>, AppCommandError> {
let result = let result = folder_commands::read_file_for_edit(params.root_path, params.path).await?;
folder_commands::read_file_for_edit(params.root_path, params.path).await?;
Ok(Json(result)) Ok(Json(result))
} }
@@ -114,24 +111,17 @@ pub async fn save_file_content(
pub async fn save_file_copy( pub async fn save_file_copy(
Json(params): Json<SaveFileCopyParams>, Json(params): Json<SaveFileCopyParams>,
) -> Result<Json<folder_commands::FileSaveResult>, AppCommandError> { ) -> Result<Json<folder_commands::FileSaveResult>, AppCommandError> {
let result = folder_commands::save_file_copy( let result =
params.root_path, folder_commands::save_file_copy(params.root_path, params.path, params.content).await?;
params.path,
params.content,
)
.await?;
Ok(Json(result)) Ok(Json(result))
} }
pub async fn rename_file_tree_entry( pub async fn rename_file_tree_entry(
Json(params): Json<RenameFileTreeEntryParams>, Json(params): Json<RenameFileTreeEntryParams>,
) -> Result<Json<String>, AppCommandError> { ) -> Result<Json<String>, AppCommandError> {
let result = folder_commands::rename_file_tree_entry( let result =
params.root_path, folder_commands::rename_file_tree_entry(params.root_path, params.path, params.new_name)
params.path, .await?;
params.new_name,
)
.await?;
Ok(Json(result)) Ok(Json(result))
} }
+10 -12
View File
@@ -68,14 +68,10 @@ pub async fn create_folder_command(
Json(params): Json<CreateFolderCommandParams>, Json(params): Json<CreateFolderCommandParams>,
) -> Result<Json<FolderCommandInfo>, AppCommandError> { ) -> Result<Json<FolderCommandInfo>, AppCommandError> {
let db = &state.db; let db = &state.db;
let result = folder_command_service::create( let result =
&db.conn, folder_command_service::create(&db.conn, params.folder_id, &params.name, &params.command)
params.folder_id, .await
&params.name, .map_err(AppCommandError::from)?;
&params.command,
)
.await
.map_err(AppCommandError::from)?;
Ok(Json(result)) Ok(Json(result))
} }
@@ -142,10 +138,12 @@ pub async fn bootstrap_folder_commands_from_package_json(
crate::commands::folder_commands::load_package_scripts_as_commands(&params.folder_path) crate::commands::folder_commands::load_package_scripts_as_commands(&params.folder_path)
}) })
.await .await
.map_err(|e| AppCommandError::new( .map_err(|e| {
crate::app_error::AppErrorCode::TaskExecutionFailed, AppCommandError::new(
format!("bootstrap task failed: {e}"), crate::app_error::AppErrorCode::TaskExecutionFailed,
))?; format!("bootstrap task failed: {e}"),
)
})?;
if commands_to_create.is_empty() { if commands_to_create.is_empty() {
return Ok(Json(existing)); return Ok(Json(existing));
+26 -66
View File
@@ -79,8 +79,7 @@ pub struct GitStatusParams {
pub async fn git_status( pub async fn git_status(
Json(params): Json<GitStatusParams>, Json(params): Json<GitStatusParams>,
) -> Result<Json<Vec<folder_commands::GitStatusEntry>>, AppCommandError> { ) -> Result<Json<Vec<folder_commands::GitStatusEntry>>, AppCommandError> {
let result = let result = folder_commands::git_status(params.path, params.show_all_untracked).await?;
folder_commands::git_status(params.path, params.show_all_untracked).await?;
Ok(Json(result)) Ok(Json(result))
} }
@@ -101,8 +100,7 @@ pub struct GitCommitBranchesParams {
pub async fn git_commit_branches( pub async fn git_commit_branches(
Json(params): Json<GitCommitBranchesParams>, Json(params): Json<GitCommitBranchesParams>,
) -> Result<Json<Vec<String>>, AppCommandError> { ) -> Result<Json<Vec<String>>, AppCommandError> {
let result = let result = folder_commands::git_commit_branches(params.path, params.commit).await?;
folder_commands::git_commit_branches(params.path, params.commit).await?;
Ok(Json(result)) Ok(Json(result))
} }
@@ -117,9 +115,7 @@ pub struct GitShowFileParams {
pub async fn git_show_file( pub async fn git_show_file(
Json(params): Json<GitShowFileParams>, Json(params): Json<GitShowFileParams>,
) -> Result<Json<String>, AppCommandError> { ) -> Result<Json<String>, AppCommandError> {
let result = let result = folder_commands::git_show_file(params.path, params.file, params.ref_name).await?;
folder_commands::git_show_file(params.path, params.file, params.ref_name)
.await?;
Ok(Json(result)) Ok(Json(result))
} }
@@ -130,9 +126,7 @@ pub struct GitDiffParams {
pub file: Option<String>, pub file: Option<String>,
} }
pub async fn git_diff( pub async fn git_diff(Json(params): Json<GitDiffParams>) -> Result<Json<String>, AppCommandError> {
Json(params): Json<GitDiffParams>,
) -> Result<Json<String>, AppCommandError> {
let result = folder_commands::git_diff(params.path, params.file).await?; let result = folder_commands::git_diff(params.path, params.file).await?;
Ok(Json(result)) Ok(Json(result))
} }
@@ -160,13 +154,8 @@ pub struct GitLogParams {
pub async fn git_log( pub async fn git_log(
Json(params): Json<GitLogParams>, Json(params): Json<GitLogParams>,
) -> Result<Json<folder_commands::GitLogResult>, AppCommandError> { ) -> Result<Json<folder_commands::GitLogResult>, AppCommandError> {
let result = folder_commands::git_log( let result =
params.path, folder_commands::git_log(params.path, params.limit, params.branch, params.remote).await?;
params.limit,
params.branch,
params.remote,
)
.await?;
Ok(Json(result)) Ok(Json(result))
} }
@@ -174,9 +163,7 @@ pub async fn git_log(
// New pure git handlers (Pattern A – direct function calls) // New pure git handlers (Pattern A – direct function calls)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
pub async fn git_init( pub async fn git_init(Json(params): Json<PathParams>) -> Result<Json<()>, AppCommandError> {
Json(params): Json<PathParams>,
) -> Result<Json<()>, AppCommandError> {
folder_commands::git_init(params.path).await?; folder_commands::git_init(params.path).await?;
Ok(Json(())) Ok(Json(()))
} }
@@ -220,8 +207,7 @@ pub struct GitNewBranchParams {
pub async fn git_new_branch( pub async fn git_new_branch(
Json(params): Json<GitNewBranchParams>, Json(params): Json<GitNewBranchParams>,
) -> Result<Json<()>, AppCommandError> { ) -> Result<Json<()>, AppCommandError> {
folder_commands::git_new_branch(params.path, params.branch_name, params.start_point) folder_commands::git_new_branch(params.path, params.branch_name, params.start_point).await?;
.await?;
Ok(Json(())) Ok(Json(()))
} }
@@ -236,12 +222,8 @@ pub struct GitWorktreeAddParams {
pub async fn git_worktree_add( pub async fn git_worktree_add(
Json(params): Json<GitWorktreeAddParams>, Json(params): Json<GitWorktreeAddParams>,
) -> Result<Json<()>, AppCommandError> { ) -> Result<Json<()>, AppCommandError> {
folder_commands::git_worktree_add( folder_commands::git_worktree_add(params.path, params.branch_name, params.worktree_path)
params.path, .await?;
params.branch_name,
params.worktree_path,
)
.await?;
Ok(Json(())) Ok(Json(()))
} }
@@ -260,9 +242,7 @@ pub struct GitResetParams {
pub mode: String, pub mode: String,
} }
pub async fn git_reset( pub async fn git_reset(Json(params): Json<GitResetParams>) -> Result<Json<()>, AppCommandError> {
Json(params): Json<GitResetParams>,
) -> Result<Json<()>, AppCommandError> {
folder_commands::git_reset(params.path, params.commit, params.mode).await?; folder_commands::git_reset(params.path, params.commit, params.mode).await?;
Ok(Json(())) Ok(Json(()))
} }
@@ -285,12 +265,8 @@ pub struct GitStashPushParams {
pub async fn git_stash_push( pub async fn git_stash_push(
Json(params): Json<GitStashPushParams>, Json(params): Json<GitStashPushParams>,
) -> Result<Json<String>, AppCommandError> { ) -> Result<Json<String>, AppCommandError> {
let result = folder_commands::git_stash_push( let result =
params.path, folder_commands::git_stash_push(params.path, params.message, params.keep_index).await?;
params.message,
params.keep_index,
)
.await?;
Ok(Json(result)) Ok(Json(result))
} }
@@ -304,8 +280,7 @@ pub struct GitStashPopParams {
pub async fn git_stash_pop( pub async fn git_stash_pop(
Json(params): Json<GitStashPopParams>, Json(params): Json<GitStashPopParams>,
) -> Result<Json<String>, AppCommandError> { ) -> Result<Json<String>, AppCommandError> {
let result = let result = folder_commands::git_stash_pop(params.path, params.stash_ref).await?;
folder_commands::git_stash_pop(params.path, params.stash_ref).await?;
Ok(Json(result)) Ok(Json(result))
} }
@@ -319,16 +294,14 @@ pub async fn git_stash_list(
pub async fn git_stash_apply( pub async fn git_stash_apply(
Json(params): Json<PathStashRefParams>, Json(params): Json<PathStashRefParams>,
) -> Result<Json<String>, AppCommandError> { ) -> Result<Json<String>, AppCommandError> {
let result = let result = folder_commands::git_stash_apply(params.path, params.stash_ref).await?;
folder_commands::git_stash_apply(params.path, params.stash_ref).await?;
Ok(Json(result)) Ok(Json(result))
} }
pub async fn git_stash_drop( pub async fn git_stash_drop(
Json(params): Json<PathStashRefParams>, Json(params): Json<PathStashRefParams>,
) -> Result<Json<String>, AppCommandError> { ) -> Result<Json<String>, AppCommandError> {
let result = let result = folder_commands::git_stash_drop(params.path, params.stash_ref).await?;
folder_commands::git_stash_drop(params.path, params.stash_ref).await?;
Ok(Json(result)) Ok(Json(result))
} }
@@ -342,16 +315,14 @@ pub async fn git_stash_clear(
pub async fn git_stash_show( pub async fn git_stash_show(
Json(params): Json<PathStashRefParams>, Json(params): Json<PathStashRefParams>,
) -> Result<Json<Vec<folder_commands::GitStatusEntry>>, AppCommandError> { ) -> Result<Json<Vec<folder_commands::GitStatusEntry>>, AppCommandError> {
let result = let result = folder_commands::git_stash_show(params.path, params.stash_ref).await?;
folder_commands::git_stash_show(params.path, params.stash_ref).await?;
Ok(Json(result)) Ok(Json(result))
} }
pub async fn git_is_tracked( pub async fn git_is_tracked(
Json(params): Json<PathFileParams>, Json(params): Json<PathFileParams>,
) -> Result<Json<bool>, AppCommandError> { ) -> Result<Json<bool>, AppCommandError> {
let result = let result = folder_commands::git_is_tracked(params.path, params.file).await?;
folder_commands::git_is_tracked(params.path, params.file).await?;
Ok(Json(result)) Ok(Json(result))
} }
@@ -366,12 +337,8 @@ pub struct GitDiffWithBranchParams {
pub async fn git_diff_with_branch( pub async fn git_diff_with_branch(
Json(params): Json<GitDiffWithBranchParams>, Json(params): Json<GitDiffWithBranchParams>,
) -> Result<Json<String>, AppCommandError> { ) -> Result<Json<String>, AppCommandError> {
let result = folder_commands::git_diff_with_branch( let result =
params.path, folder_commands::git_diff_with_branch(params.path, params.branch, params.file).await?;
params.branch,
params.file,
)
.await?;
Ok(Json(result)) Ok(Json(result))
} }
@@ -386,9 +353,7 @@ pub struct GitShowDiffParams {
pub async fn git_show_diff( pub async fn git_show_diff(
Json(params): Json<GitShowDiffParams>, Json(params): Json<GitShowDiffParams>,
) -> Result<Json<String>, AppCommandError> { ) -> Result<Json<String>, AppCommandError> {
let result = let result = folder_commands::git_show_diff(params.path, params.commit, params.file).await?;
folder_commands::git_show_diff(params.path, params.commit, params.file)
.await?;
Ok(Json(result)) Ok(Json(result))
} }
@@ -437,16 +402,14 @@ pub async fn git_set_remote_url(
pub async fn git_merge( pub async fn git_merge(
Json(params): Json<PathBranchParams>, Json(params): Json<PathBranchParams>,
) -> Result<Json<folder_commands::GitMergeResult>, AppCommandError> { ) -> Result<Json<folder_commands::GitMergeResult>, AppCommandError> {
let result = let result = folder_commands::git_merge(params.path, params.branch_name).await?;
folder_commands::git_merge(params.path, params.branch_name).await?;
Ok(Json(result)) Ok(Json(result))
} }
pub async fn git_rebase( pub async fn git_rebase(
Json(params): Json<PathBranchParams>, Json(params): Json<PathBranchParams>,
) -> Result<Json<folder_commands::GitRebaseResult>, AppCommandError> { ) -> Result<Json<folder_commands::GitRebaseResult>, AppCommandError> {
let result = let result = folder_commands::git_rebase(params.path, params.branch_name).await?;
folder_commands::git_rebase(params.path, params.branch_name).await?;
Ok(Json(result)) Ok(Json(result))
} }
@@ -462,8 +425,7 @@ pub async fn git_delete_branch(
Json(params): Json<GitDeleteBranchParams>, Json(params): Json<GitDeleteBranchParams>,
) -> Result<Json<String>, AppCommandError> { ) -> Result<Json<String>, AppCommandError> {
let result = let result =
folder_commands::git_delete_branch(params.path, params.branch_name, params.force) folder_commands::git_delete_branch(params.path, params.branch_name, params.force).await?;
.await?;
Ok(Json(result)) Ok(Json(result))
} }
@@ -503,16 +465,14 @@ pub async fn git_list_conflicts(
pub async fn git_conflict_file_versions( pub async fn git_conflict_file_versions(
Json(params): Json<PathFileParams>, Json(params): Json<PathFileParams>,
) -> Result<Json<folder_commands::GitConflictFileVersions>, AppCommandError> { ) -> Result<Json<folder_commands::GitConflictFileVersions>, AppCommandError> {
let result = let result = folder_commands::git_conflict_file_versions(params.path, params.file).await?;
folder_commands::git_conflict_file_versions(params.path, params.file).await?;
Ok(Json(result)) Ok(Json(result))
} }
pub async fn git_resolve_conflict( pub async fn git_resolve_conflict(
Json(params): Json<PathFileContentParams>, Json(params): Json<PathFileContentParams>,
) -> Result<Json<()>, AppCommandError> { ) -> Result<Json<()>, AppCommandError> {
folder_commands::git_resolve_conflict(params.path, params.file, params.content) folder_commands::git_resolve_conflict(params.path, params.file, params.content).await?;
.await?;
Ok(Json(())) Ok(Json(()))
} }
+11 -23
View File
@@ -71,8 +71,7 @@ pub async fn mcp_scan_local() -> Result<Json<Vec<LocalMcpServer>>, AppCommandErr
Ok(Json(result)) Ok(Json(result))
} }
pub async fn mcp_list_marketplaces( pub async fn mcp_list_marketplaces() -> Result<Json<Vec<McpMarketplaceProvider>>, AppCommandError> {
) -> Result<Json<Vec<McpMarketplaceProvider>>, AppCommandError> {
let result = mcp_commands::mcp_list_marketplaces().await?; let result = mcp_commands::mcp_list_marketplaces().await?;
Ok(Json(result)) Ok(Json(result))
} }
@@ -80,23 +79,18 @@ pub async fn mcp_list_marketplaces(
pub async fn mcp_search_marketplace( pub async fn mcp_search_marketplace(
Json(params): Json<SearchMarketplaceParams>, Json(params): Json<SearchMarketplaceParams>,
) -> Result<Json<Vec<McpMarketplaceItem>>, AppCommandError> { ) -> Result<Json<Vec<McpMarketplaceItem>>, AppCommandError> {
let result = mcp_commands::mcp_search_marketplace( let result =
params.provider_id, mcp_commands::mcp_search_marketplace(params.provider_id, params.query, params.limit)
params.query, .await?;
params.limit,
)
.await?;
Ok(Json(result)) Ok(Json(result))
} }
pub async fn mcp_get_marketplace_server_detail( pub async fn mcp_get_marketplace_server_detail(
Json(params): Json<GetMarketplaceServerDetailParams>, Json(params): Json<GetMarketplaceServerDetailParams>,
) -> Result<Json<McpMarketplaceServerDetail>, AppCommandError> { ) -> Result<Json<McpMarketplaceServerDetail>, AppCommandError> {
let result = mcp_commands::mcp_get_marketplace_server_detail( let result =
params.provider_id, mcp_commands::mcp_get_marketplace_server_detail(params.provider_id, params.server_id)
params.server_id, .await?;
)
.await?;
Ok(Json(result)) Ok(Json(result))
} }
@@ -119,27 +113,21 @@ pub async fn mcp_install_from_marketplace(
pub async fn mcp_upsert_local_server( pub async fn mcp_upsert_local_server(
Json(params): Json<UpsertLocalServerParams>, Json(params): Json<UpsertLocalServerParams>,
) -> Result<Json<LocalMcpServer>, AppCommandError> { ) -> Result<Json<LocalMcpServer>, AppCommandError> {
let result = mcp_commands::mcp_upsert_local_server( let result =
params.server_id, mcp_commands::mcp_upsert_local_server(params.server_id, params.spec, params.apps).await?;
params.spec,
params.apps,
)
.await?;
Ok(Json(result)) Ok(Json(result))
} }
pub async fn mcp_set_server_apps( pub async fn mcp_set_server_apps(
Json(params): Json<SetServerAppsParams>, Json(params): Json<SetServerAppsParams>,
) -> Result<Json<Option<LocalMcpServer>>, AppCommandError> { ) -> Result<Json<Option<LocalMcpServer>>, AppCommandError> {
let result = let result = mcp_commands::mcp_set_server_apps(params.server_id, params.apps).await?;
mcp_commands::mcp_set_server_apps(params.server_id, params.apps).await?;
Ok(Json(result)) Ok(Json(result))
} }
pub async fn mcp_remove_server( pub async fn mcp_remove_server(
Json(params): Json<RemoveServerParams>, Json(params): Json<RemoveServerParams>,
) -> Result<Json<bool>, AppCommandError> { ) -> Result<Json<bool>, AppCommandError> {
let result = let result = mcp_commands::mcp_remove_server(params.server_id, params.apps).await?;
mcp_commands::mcp_remove_server(params.server_id, params.apps).await?;
Ok(Json(result)) Ok(Json(result))
} }
+7 -16
View File
@@ -44,8 +44,7 @@ pub async fn get_system_language_settings(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
) -> Result<Json<SystemLanguageSettings>, AppCommandError> { ) -> Result<Json<SystemLanguageSettings>, AppCommandError> {
let db = &state.db; let db = &state.db;
let settings = let settings = settings_commands::load_system_language_settings(&db.conn).await?;
settings_commands::load_system_language_settings(&db.conn).await?;
Ok(Json(settings)) Ok(Json(settings))
} }
@@ -67,13 +66,9 @@ pub async fn update_system_proxy_settings(
.with_detail(e.to_string()) .with_detail(e.to_string())
})?; })?;
app_metadata_service::upsert_value( app_metadata_service::upsert_value(&db.conn, SYSTEM_PROXY_SETTINGS_KEY, &serialized)
&db.conn, .await
SYSTEM_PROXY_SETTINGS_KEY, .map_err(AppCommandError::from)?;
&serialized,
)
.await
.map_err(AppCommandError::from)?;
proxy::apply_system_proxy_settings(&settings)?; proxy::apply_system_proxy_settings(&settings)?;
Ok(Json(settings)) Ok(Json(settings))
@@ -91,13 +86,9 @@ pub async fn update_system_language_settings(
.with_detail(e.to_string()) .with_detail(e.to_string())
})?; })?;
app_metadata_service::upsert_value( app_metadata_service::upsert_value(&db.conn, SYSTEM_LANGUAGE_SETTINGS_KEY, &serialized)
&db.conn, .await
SYSTEM_LANGUAGE_SETTINGS_KEY, .map_err(AppCommandError::from)?;
&serialized,
)
.await
.map_err(AppCommandError::from)?;
crate::web::event_bridge::emit_event( crate::web::event_bridge::emit_event(
&state.emitter, &state.emitter,
@@ -125,10 +125,8 @@ pub async fn get_github_accounts(
let settings = match raw { let settings = match raw {
Some(raw) => serde_json::from_str::<GitHubAccountsSettings>(&raw).map_err(|e| { Some(raw) => serde_json::from_str::<GitHubAccountsSettings>(&raw).map_err(|e| {
AppCommandError::configuration_invalid( AppCommandError::configuration_invalid("Failed to parse stored GitHub accounts")
"Failed to parse stored GitHub accounts", .with_detail(e.to_string())
)
.with_detail(e.to_string())
})?, })?,
None => GitHubAccountsSettings::default(), None => GitHubAccountsSettings::default(),
}; };
@@ -160,8 +158,7 @@ pub async fn update_github_accounts(
pub async fn validate_github_token( pub async fn validate_github_token(
Json(params): Json<ValidateGitHubTokenParams>, Json(params): Json<ValidateGitHubTokenParams>,
) -> Result<Json<GitHubTokenValidation>, AppCommandError> { ) -> Result<Json<GitHubTokenValidation>, AppCommandError> {
let result = let result = vc_commands::validate_github_token(params.server_url, params.token).await?;
vc_commands::validate_github_token(params.server_url, params.token).await?;
Ok(Json(result)) Ok(Json(result))
} }
+38 -22
View File
@@ -121,11 +121,10 @@ async fn persist_web_service_config(
}) })
.await .await
.map_err(|e: TransactionError<AppCommandError>| match e { .map_err(|e: TransactionError<AppCommandError>| match e {
TransactionError::Connection(db) => AppCommandError::new( TransactionError::Connection(db) => {
AppErrorCode::DatabaseError, AppCommandError::new(AppErrorCode::DatabaseError, "Database transaction failed")
"Database transaction failed", .with_detail(db.to_string())
) }
.with_detail(db.to_string()),
TransactionError::Transaction(inner) => inner, TransactionError::Transaction(inner) => inner,
}) })
} }
@@ -178,12 +177,18 @@ pub(crate) fn find_static_dir_tauri(app: &tauri::AppHandle) -> PathBuf {
if let Some(ref dir) = resource { if let Some(ref dir) = resource {
let web = dir.join("web"); let web = dir.join("web");
if web.join("index.html").exists() { if web.join("index.html").exists() {
eprintln!("[WEB] Serving static files from resource/web: {}", web.display()); eprintln!(
"[WEB] Serving static files from resource/web: {}",
web.display()
);
return web; return web;
} }
// Fallback: files at resource root. // Fallback: files at resource root.
if dir.join("index.html").exists() { if dir.join("index.html").exists() {
eprintln!("[WEB] Serving static files from resource dir: {}", dir.display()); eprintln!(
"[WEB] Serving static files from resource dir: {}",
dir.display()
);
return dir.clone(); return dir.clone();
} }
} }
@@ -197,7 +202,10 @@ pub(crate) fn find_static_dir_fallback() -> PathBuf {
let project_out = manifest_dir.parent().map(|p| p.join("out")); let project_out = manifest_dir.parent().map(|p| p.join("out"));
if let Some(ref out) = project_out { if let Some(ref out) = project_out {
if out.join("index.html").exists() { if out.join("index.html").exists() {
eprintln!("[WEB] Serving static files from project out/: {}", out.display()); eprintln!(
"[WEB] Serving static files from project out/: {}",
out.display()
);
return out.clone(); return out.clone();
} }
} }
@@ -217,7 +225,10 @@ pub fn find_static_dir_standalone(explicit: Option<&str>) -> PathBuf {
if let Some(dir) = explicit { if let Some(dir) = explicit {
let p = PathBuf::from(dir); let p = PathBuf::from(dir);
if p.join("index.html").exists() { if p.join("index.html").exists() {
eprintln!("[WEB] Serving static files from CODEG_STATIC_DIR: {}", p.display()); eprintln!(
"[WEB] Serving static files from CODEG_STATIC_DIR: {}",
p.display()
);
return p; return p;
} }
} }
@@ -292,12 +303,13 @@ pub(crate) async fn do_start_web_server_with_state(
let host = host.unwrap_or_else(|| "0.0.0.0".to_string()); let host = host.unwrap_or_else(|| "0.0.0.0".to_string());
let token = resolve_web_service_token(&app_state.db.conn, token).await?; let token = resolve_web_service_token(&app_state.db.conn, token).await?;
let addr: SocketAddr = format!("{}:{}", host, port) let addr: SocketAddr =
.parse() format!("{}:{}", host, port)
.map_err(|e: std::net::AddrParseError| { .parse()
AppCommandError::new(AppErrorCode::InvalidInput, ERR_INVALID_ADDRESS) .map_err(|e: std::net::AddrParseError| {
.with_detail(e.to_string()) AppCommandError::new(AppErrorCode::InvalidInput, ERR_INVALID_ADDRESS)
})?; .with_detail(e.to_string())
})?;
let listener = tokio::net::TcpListener::bind(addr) let listener = tokio::net::TcpListener::bind(addr)
.await .await
@@ -387,12 +399,13 @@ pub async fn start_web_server(
let host_val = host.unwrap_or_else(|| "0.0.0.0".to_string()); let host_val = host.unwrap_or_else(|| "0.0.0.0".to_string());
let token = resolve_web_service_token(&db.conn, token).await?; let token = resolve_web_service_token(&db.conn, token).await?;
let addr: SocketAddr = format!("{}:{}", host_val, port_val) let addr: SocketAddr =
.parse() format!("{}:{}", host_val, port_val)
.map_err(|e: std::net::AddrParseError| { .parse()
AppCommandError::new(AppErrorCode::InvalidInput, ERR_INVALID_ADDRESS) .map_err(|e: std::net::AddrParseError| {
.with_detail(e.to_string()) AppCommandError::new(AppErrorCode::InvalidInput, ERR_INVALID_ADDRESS)
})?; .with_detail(e.to_string())
})?;
let listener = tokio::net::TcpListener::bind(addr) let listener = tokio::net::TcpListener::bind(addr)
.await .await
@@ -410,7 +423,10 @@ pub async fn start_web_server(
}, },
connection_manager: (*app.state::<crate::acp::manager::ConnectionManager>()).clone_ref(), connection_manager: (*app.state::<crate::acp::manager::ConnectionManager>()).clone_ref(),
terminal_manager: (*app.state::<crate::terminal::manager::TerminalManager>()).clone_ref(), terminal_manager: (*app.state::<crate::terminal::manager::TerminalManager>()).clone_ref(),
event_broadcaster: app.state::<Arc<crate::web::event_bridge::WebEventBroadcaster>>().inner().clone(), event_broadcaster: app
.state::<Arc<crate::web::event_bridge::WebEventBroadcaster>>()
.inner()
.clone(),
emitter: crate::web::event_bridge::EventEmitter::Tauri(app.clone()), emitter: crate::web::event_bridge::EventEmitter::Tauri(app.clone()),
data_dir: app.path().app_data_dir().unwrap_or_default(), data_dir: app.path().app_data_dir().unwrap_or_default(),
web_server_state: WebServerState::new(), // placeholder; not used by handlers web_server_state: WebServerState::new(), // placeholder; not used by handlers
+1 -4
View File
@@ -106,10 +106,7 @@ pub fn build_router(state: Arc<AppState>, token: String, static_dir: std::path::
"/remove_folder_from_workspace", "/remove_folder_from_workspace",
post(handlers::folders::remove_folder_from_workspace), post(handlers::folders::remove_folder_from_workspace),
) )
.route( .route("/reorder_folders", post(handlers::folders::reorder_folders))
"/reorder_folders",
post(handlers::folders::reorder_folders),
)
.route( .route(
"/add_folder_to_history", "/add_folder_to_history",
post(handlers::folders::add_folder_to_history), post(handlers::folders::add_folder_to_history),
+1 -1
View File
@@ -1,10 +1,10 @@
use std::sync::Arc; use std::sync::Arc;
use axum::extract::ws::{Message, WebSocket};
use axum::{ use axum::{
extract::{Extension, WebSocketUpgrade}, extract::{Extension, WebSocketUpgrade},
response::IntoResponse, response::IntoResponse,
}; };
use axum::extract::ws::{Message, WebSocket};
use crate::app_state::AppState; use crate::app_state::AppState;
+15 -13
View File
@@ -321,7 +321,6 @@ impl WatchEventBatch {
"modify".to_string() "modify".to_string()
} }
} }
} }
fn normalize_slash_path(path: &Path) -> String { fn normalize_slash_path(path: &Path) -> String {
@@ -375,7 +374,9 @@ fn git_check_ignored_paths(
if let Some(mut stdin) = child.stdin.take() { if let Some(mut stdin) = child.stdin.take() {
for path in paths { for path in paths {
stdin.write_all(path.as_bytes()).map_err(AppCommandError::io)?; stdin
.write_all(path.as_bytes())
.map_err(AppCommandError::io)?;
stdin.write_all(&[0]).map_err(AppCommandError::io)?; stdin.write_all(&[0]).map_err(AppCommandError::io)?;
} }
} }
@@ -899,15 +900,13 @@ pub async fn start_workspace_state_stream_core(
let mut watcher = Some( let mut watcher = Some(
notify::recommended_watcher( notify::recommended_watcher(
move |result: Result<notify::Event, notify::Error>| match result { move |result: Result<notify::Event, notify::Error>| match result {
Ok(event) => { Ok(event) => match event_tx.try_send(event) {
match event_tx.try_send(event) { Ok(()) => {}
Ok(()) => {} Err(TrySendError::Full(_)) => {
Err(TrySendError::Full(_)) => { dropped_events_for_callback.store(true, Ordering::Release);
dropped_events_for_callback.store(true, Ordering::Release);
}
Err(TrySendError::Closed(_)) => {}
} }
} Err(TrySendError::Closed(_)) => {}
},
Err(err) => { Err(err) => {
eprintln!( eprintln!(
"[workspace-state-watch] failed event for {}: {}", "[workspace-state-watch] failed event for {}: {}",
@@ -1082,7 +1081,8 @@ mod tests {
#[test] #[test]
fn workspace_state_core_seq_is_monotonic() { fn workspace_state_core_seq_is_monotonic() {
let mut core = WorkspaceStateCore::new("/tmp/repo".to_string(), Vec::new(), Vec::new(), false); let mut core =
WorkspaceStateCore::new("/tmp/repo".to_string(), Vec::new(), Vec::new(), false);
let e1 = core.append_event( let e1 = core.append_event(
"meta".to_string(), "meta".to_string(),
@@ -1107,7 +1107,8 @@ mod tests {
#[test] #[test]
fn workspace_state_core_snapshot_incremental_when_since_available() { fn workspace_state_core_snapshot_incremental_when_since_available() {
let mut core = WorkspaceStateCore::new("/tmp/repo".to_string(), Vec::new(), Vec::new(), false); let mut core =
WorkspaceStateCore::new("/tmp/repo".to_string(), Vec::new(), Vec::new(), false);
let e1 = core.append_event( let e1 = core.append_event(
"meta".to_string(), "meta".to_string(),
@@ -1136,7 +1137,8 @@ mod tests {
#[test] #[test]
fn workspace_state_core_snapshot_full_when_since_too_old() { fn workspace_state_core_snapshot_full_when_since_too_old() {
let mut core = WorkspaceStateCore::new("/tmp/repo".to_string(), Vec::new(), Vec::new(), false); let mut core =
WorkspaceStateCore::new("/tmp/repo".to_string(), Vec::new(), Vec::new(), false);
core.recent_capacity = 1; core.recent_capacity = 1;
core.append_event( core.append_event(
@@ -73,10 +73,7 @@ export const ConversationContextBar = memo(function ConversationContextBar({
setTabFolder(ownTab.id, target.id, target.path) setTabFolder(ownTab.id, target.id, target.path)
toast.success(t("toasts.folderChanged", { name: target.name })) toast.success(t("toasts.folderChanged", { name: target.name }))
} catch (err) { } catch (err) {
console.error( console.error("[ConversationContextBar] switch folder failed:", err)
"[ConversationContextBar] switch folder failed:",
err
)
toast.error(t("toasts.openFolderFailed")) toast.error(t("toasts.openFolderFailed"))
} }
}} }}
+1 -3
View File
@@ -96,9 +96,7 @@ export function Sidebar() {
size="icon" size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground" className="h-6 w-6 shrink-0 text-muted-foreground"
onClick={handleToggleExpandAll} onClick={handleToggleExpandAll}
title={ title={allExpanded ? t("collapseAllGroups") : t("expandAllGroups")}
allExpanded ? t("collapseAllGroups") : t("expandAllGroups")
}
> >
{allExpanded ? ( {allExpanded ? (
<ChevronsDownUp className="h-3.5 w-3.5" /> <ChevronsDownUp className="h-3.5 w-3.5" />