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);
match meta.distribution {
AgentDistribution::Npx {
cmd, args, env, ..
} => {
AgentDistribution::Npx { cmd, args, env, .. } => {
let merged_env = merge_agent_env(env, runtime_env);
let mut parts: Vec<String> = Vec::new();
for (k, v) in &merged_env {
@@ -251,10 +249,7 @@ async fn build_agent(
))
})?;
if cached_version == registry_version {
eprintln!(
"[ACP][{}] Using cached binary {cached_version}",
meta.name
);
eprintln!("[ACP][{}] Using cached binary {cached_version}", meta.name);
} else {
eprintln!(
"[ACP][{}] Using cached binary {cached_version} (registry recommends {registry_version})",
@@ -273,8 +268,7 @@ async fn build_agent(
server = server.args(cmd_args);
}
let merged_env = merge_agent_env(env, runtime_env);
let env_key_list: Vec<&str> =
merged_env.iter().map(|(k, _)| k.as_str()).collect();
let env_key_list: Vec<&str> = merged_env.iter().map(|(k, _)| k.as_str()).collect();
if !merged_env.is_empty() {
let env_vars: Vec<sacp::schema::EnvVariable> = merged_env
.iter()
@@ -314,34 +308,36 @@ async fn build_agent(
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let agent_name = meta.name.to_string();
Ok(AcpAgent::new(sacp::schema::McpServer::Stdio(server)).with_debug(
move |line, dir| {
let (tag, enabled) = match dir {
sacp_tokio::LineDirection::Stderr => ("stderr", true),
sacp_tokio::LineDirection::Stdout => ("stdout", stdio_debug_enabled),
sacp_tokio::LineDirection::Stdin => ("stdin", stdio_debug_enabled),
};
if !enabled {
return;
}
const MAX: usize = 256;
if line.len() > MAX {
let head = line
.char_indices()
.take_while(|(i, _)| *i < MAX)
.last()
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(MAX);
eprintln!(
"[ACP][{agent_name}][{tag}] {}... <truncated {} bytes>",
&line[..head],
line.len() - head
);
} else {
eprintln!("[ACP][{agent_name}][{tag}] {line}");
}
},
))
Ok(
AcpAgent::new(sacp::schema::McpServer::Stdio(server)).with_debug(
move |line, dir| {
let (tag, enabled) = match dir {
sacp_tokio::LineDirection::Stderr => ("stderr", true),
sacp_tokio::LineDirection::Stdout => ("stdout", stdio_debug_enabled),
sacp_tokio::LineDirection::Stdin => ("stdin", stdio_debug_enabled),
};
if !enabled {
return;
}
const MAX: usize = 256;
if line.len() > MAX {
let head = line
.char_indices()
.take_while(|(i, _)| *i < MAX)
.last()
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(MAX);
eprintln!(
"[ACP][{agent_name}][{tag}] {}... <truncated {} bytes>",
&line[..head],
line.len() - head
);
} 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") {
return;
}
options.insert(0, SessionConfigOptionInfo {
id: "mode".to_string(),
name: "Approval Preset".to_string(),
description: Some(
"Choose an approval and sandboxing preset for your session".to_string(),
),
category: Some("mode".to_string()),
kind: SessionConfigKindInfo::Select(SessionConfigSelectInfo {
current_value: "auto".to_string(),
options: vec![
SessionConfigSelectOptionInfo {
value: "read-only".to_string(),
name: "Read Only".to_string(),
description: Some("Codex can only read files".to_string()),
},
SessionConfigSelectOptionInfo {
value: "auto".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(),
description: Some("Codex runs without asking for approval".to_string()),
},
],
groups: vec![],
}),
});
options.insert(
0,
SessionConfigOptionInfo {
id: "mode".to_string(),
name: "Approval Preset".to_string(),
description: Some(
"Choose an approval and sandboxing preset for your session".to_string(),
),
category: Some("mode".to_string()),
kind: SessionConfigKindInfo::Select(SessionConfigSelectInfo {
current_value: "auto".to_string(),
options: vec![
SessionConfigSelectOptionInfo {
value: "read-only".to_string(),
name: "Read Only".to_string(),
description: Some("Codex can only read files".to_string()),
},
SessionConfigSelectOptionInfo {
value: "auto".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(),
description: Some("Codex runs without asking for approval".to_string()),
},
],
groups: vec![],
}),
},
);
}
fn emit_session_config_options_values(
@@ -758,7 +757,15 @@ async fn run_connection(
async move |req: RequestPermissionRequest,
responder: Responder<RequestPermissionResponse>,
_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(())
}
},
@@ -979,7 +986,13 @@ async fn run_connection(
notif.update,
SessionUpdate::AvailableCommandsUpdate(_)
) {
emit_conversation_update(&cid, &h, agent_type, notif.update, None);
emit_conversation_update(
&cid,
&h,
agent_type,
notif.update,
None,
);
}
Ok(())
})
@@ -1004,7 +1017,12 @@ async fn run_connection(
},
);
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);
let loop_result = run_conversation_loop(
@@ -1058,9 +1076,7 @@ async fn run_connection(
"acp://event",
AcpEvent::Error {
connection_id: conn_id.clone(),
message: format!(
"Failed to load session, starting new: {e}"
),
message: format!("Failed to load session, starting new: {e}"),
agent_type: agent_type.to_string(),
code: None,
},
@@ -1072,8 +1088,7 @@ async fn run_connection(
.await?;
let fallback_sid = new_resp.session_id.0.to_string();
let initial_config_options = new_resp.config_options.clone();
let mut session =
cx.attach_session(new_resp, Default::default())?;
let mut session = cx.attach_session(new_resp, Default::default())?;
crate::web::event_bridge::emit_event(
&emitter_clone,
"acp://event",
@@ -1139,7 +1154,12 @@ async fn run_connection(
},
);
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);
let loop_result = run_conversation_loop(
@@ -1229,8 +1249,7 @@ async fn handle_permission_request(
obj.insert(key.to_string(), parsed);
}
} else if text.contains("@@\n") || text.contains("@@\r\n") {
if let Some(resolved) =
crate::parsers::resolve_patch_text(&text, Some(cwd))
if let Some(resolved) = crate::parsers::resolve_patch_text(&text, Some(cwd))
{
obj.insert(key.to_string(), serde_json::Value::String(resolved));
}
@@ -1759,7 +1778,14 @@ async fn handle_fork_or_exit(
// Recursively handle nested forks
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,
))
.await
@@ -2169,8 +2195,10 @@ async fn run_conversation_loop<'a>(
}) => {
let cx = session.connection();
let sid = session.session_id().clone();
if let Err(e) =
set_session_config_option(&cx, &sid, conn_id, emitter, agent_type, config_id, value_id).await
if let Err(e) = set_session_config_option(
&cx, &sid, conn_id, emitter, agent_type, config_id, value_id,
)
.await
{
crate::web::event_bridge::emit_event(
emitter,
@@ -2499,10 +2527,10 @@ fn emit_conversation_update(
}
SessionUpdate::ToolCall(tc) => {
let content = serialize_tool_call_content(&tc.content);
let raw_input = 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)
.map(|text| structurize_live_output(&text));
let raw_input =
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).map(|text| structurize_live_output(&text));
let locations = if tc.locations.is_empty() {
None
} else {
@@ -2581,7 +2609,12 @@ fn emit_conversation_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) => {
let commands: Vec<AvailableCommandInfo> = update
@@ -2661,8 +2694,8 @@ mod tests {
)
.unwrap();
let event = map_claude_sdk_ext_notification("conn-1", &raw)
.expect("valid sdk payload should map");
let event =
map_claude_sdk_ext_notification("conn-1", &raw).expect("valid sdk payload should map");
match event {
AcpEvent::ClaudeSdkMessage {
@@ -2700,11 +2733,8 @@ mod tests {
.unwrap();
assert!(map_claude_sdk_ext_notification("conn-1", &wrong_method).is_none());
let missing_fields = UntypedMessage::new(
"_claude/sdkMessage",
serde_json::json!({"sessionId": 1}),
)
.unwrap();
let missing_fields =
UntypedMessage::new("_claude/sdkMessage", serde_json::json!({"sessionId": 1})).unwrap();
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 {
let cmd_txs: Vec<_> = {
let mut connections = self.connections.lock().await;
connections
.drain()
.map(|(_, conn)| conn.cmd_tx)
.collect()
connections.drain().map(|(_, conn)| conn.cmd_tx).collect()
};
let disconnected = cmd_txs.len();
for cmd_tx in cmd_txs {
+2 -2
View File
@@ -1,11 +1,11 @@
pub mod binary_cache;
pub mod connection;
pub mod error;
pub mod fork;
pub mod file_system_runtime;
pub mod fork;
pub mod manager;
pub mod opencode_plugins;
pub mod preflight;
pub mod registry;
pub mod terminal_runtime;
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/`
/// to determine which declared plugins are installed and which are missing.
pub fn check_opencode_plugins(
project_root: Option<&Path>,
) -> Result<PluginCheckSummary, String> {
pub fn check_opencode_plugins(project_root: Option<&Path>) -> Result<PluginCheckSummary, String> {
let config_path = opencode_config_path()
.ok_or_else(|| "Cannot determine opencode config directory".to_string())?;
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)
.map_err(|e| e.to_string())?
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with(&prefix)
})
.filter(|entry| entry.file_name().to_string_lossy().starts_with(&prefix))
.collect();
// Sort by name descending (timestamp in name → newest first)
@@ -280,8 +273,8 @@ pub(crate) fn atomic_rewrite_opencode_json(
path: &Path,
mutator: impl FnOnce(&mut serde_json::Value) -> Result<(), String>,
) -> Result<(), String> {
let raw = fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
let raw =
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
// and any "//" or "/*" sequences live inside string values — not real
@@ -302,14 +295,12 @@ pub(crate) fn atomic_rewrite_opencode_json(
mutator(&mut doc)?;
let new_raw = serde_json::to_string_pretty(&doc)
.map_err(|e| format!("Failed to serialize JSON: {e}"))?;
let new_raw =
serde_json::to_string_pretty(&doc).map_err(|e| format!("Failed to serialize JSON: {e}"))?;
let tmp_path = path.with_extension("json.tmp");
fs::write(&tmp_path, &new_raw)
.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::write(&tmp_path, &new_raw).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}"))?;
Ok(())
}
@@ -366,12 +357,9 @@ fn pin_latest_specs(
for item in arr.iter_mut() {
if let Some(spec_str) = item.as_str() {
if let Some((parsed_name, _)) = parse_plugin_spec(spec_str) {
if let Some((_, version)) =
pin_map.iter().find(|(n, _)| *n == parsed_name)
if let Some((_, version)) = pin_map.iter().find(|(n, _)| *n == parsed_name)
{
*item = serde_json::Value::String(format!(
"{parsed_name}@{version}"
));
*item = serde_json::Value::String(format!("{parsed_name}@{version}"));
pinned += 1;
}
}
@@ -417,9 +405,9 @@ pub async fn install_missing_plugins(
task_id: String,
emitter: &EventEmitter,
) -> Result<(), String> {
let _guard = PLUGIN_OP_LOCK.try_lock().map_err(|_| {
"Another plugin operation is in progress".to_string()
})?;
let _guard = PLUGIN_OP_LOCK
.try_lock()
.map_err(|_| "Another plugin operation is in progress".to_string())?;
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.
pub async fn uninstall_plugin(name: String) -> Result<PluginCheckSummary, String> {
let _guard = PLUGIN_OP_LOCK.try_lock().map_err(|_| {
"Another plugin operation is in progress".to_string()
})?;
let _guard = PLUGIN_OP_LOCK
.try_lock()
.map_err(|_| "Another plugin operation is in progress".to_string())?;
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()
+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!(
async {
match &node_path {
Some(p) => crate::process::tokio_command(p).arg("--version").output().await,
None => Err(std::io::Error::new(std::io::ErrorKind::NotFound, "node not found in PATH")),
Some(p) => {
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 {
match &npm_path {
Some(p) => crate::process::tokio_command(p).arg("--version").output().await,
None => Err(std::io::Error::new(std::io::ErrorKind::NotFound, "npm not found in PATH")),
Some(p) => {
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
// canonical place to surface "upgrade available".
if platform_supported {
let cache_check =
match binary_cache::find_best_cached_binary_for_agent(agent_type, cmd) {
Ok(Some((_, cached_version))) => {
let message = if cached_version == version {
"Binary is cached locally".to_string()
} else {
format!(
"Binary {cached_version} is cached locally (recommended: {version})"
)
};
CheckItem {
check_id: "binary_cached".into(),
label: "Binary cache".into(),
status: CheckStatus::Pass,
message,
fixes: vec![],
}
let cache_check = match binary_cache::find_best_cached_binary_for_agent(agent_type, cmd) {
Ok(Some((_, cached_version))) => {
let message = if cached_version == version {
"Binary is cached locally".to_string()
} else {
format!("Binary {cached_version} is cached locally (recommended: {version})")
};
CheckItem {
check_id: "binary_cached".into(),
label: "Binary cache".into(),
status: CheckStatus::Pass,
message,
fixes: vec![],
}
Ok(None) => CheckItem {
check_id: "binary_cached".into(),
label: "Binary cache".into(),
status: CheckStatus::Warn,
message:
"Binary is not installed. Download it from Agent Settings before connecting."
.into(),
fixes: vec![],
},
Err(_) => CheckItem {
check_id: "binary_cached".into(),
label: "Binary cache".into(),
status: CheckStatus::Warn,
message: "Cannot determine binary cache path".into(),
fixes: vec![],
},
};
}
Ok(None) => CheckItem {
check_id: "binary_cached".into(),
label: "Binary cache".into(),
status: CheckStatus::Warn,
message:
"Binary is not installed. Download it from Agent Settings before connecting."
.into(),
fixes: vec![],
},
Err(_) => CheckItem {
check_id: "binary_cached".into(),
label: "Binary cache".into(),
status: CheckStatus::Warn,
message: "Cannot determine binary cache path".into(),
fixes: vec![],
},
};
checks.push(cache_check);
}
// OpenCode plugin checks
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) {
Ok(summary) => {
let missing: Vec<_> = summary
@@ -376,8 +389,7 @@ async fn check_binary_environment(
fixes: vec![],
});
} else {
let names: Vec<&str> =
missing.iter().map(|p| p.name.as_str()).collect();
let names: Vec<&str> = missing.iter().map(|p| p.name.as_str()).collect();
checks.push(CheckItem {
check_id: "opencode_plugins".into(),
label: "OpenCode plugins".into(),
+3 -2
View File
@@ -38,8 +38,9 @@ pub struct AcpAgentMeta {
impl AcpAgentMeta {
pub fn registry_version(&self) -> Option<&'static str> {
match &self.distribution {
AgentDistribution::Npx { version, .. }
| AgentDistribution::Binary { version, .. } => Some(*version),
AgentDistribution::Npx { version, .. } | AgentDistribution::Binary { version, .. } => {
Some(*version)
}
}
}
}
+3 -4
View File
@@ -3,10 +3,9 @@ use std::process::Stdio;
use std::sync::Arc;
use sacp::schema::{
CreateTerminalRequest, CreateTerminalResponse, KillTerminalRequest,
KillTerminalResponse, ReleaseTerminalRequest, ReleaseTerminalResponse,
TerminalExitStatus, TerminalOutputRequest, TerminalOutputResponse, WaitForTerminalExitRequest,
WaitForTerminalExitResponse,
CreateTerminalRequest, CreateTerminalResponse, KillTerminalRequest, KillTerminalResponse,
ReleaseTerminalRequest, ReleaseTerminalResponse, TerminalExitStatus, TerminalOutputRequest,
TerminalOutputResponse, WaitForTerminalExitRequest, WaitForTerminalExitResponse,
};
use tokio::io::{AsyncRead, AsyncReadExt};
use tokio::sync::Mutex;
+2 -7
View File
@@ -202,9 +202,7 @@ impl LarkBackend {
let token = result
.tenant_access_token
.ok_or_else(|| {
ChatChannelError::AuthenticationFailed("No token in response".into())
})?;
.ok_or_else(|| ChatChannelError::AuthenticationFailed("No token in response".into()))?;
let expire_secs = result.expire.unwrap_or(7200);
let expires_at = Instant::now()
@@ -252,10 +250,7 @@ impl LarkBackend {
)));
}
let message_id = result
.data
.and_then(|d| d.message_id)
.unwrap_or_default();
let message_id = result.data.and_then(|d| d.message_id).unwrap_or_default();
Ok(SentMessageId(message_id))
}
@@ -34,10 +34,7 @@ impl TelegramBackend {
}
fn api_url(&self, method: &str) -> String {
format!(
"https://api.telegram.org/bot{}/{}",
self.bot_token, method
)
format!("https://api.telegram.org/bot{}/{}", self.bot_token, method)
}
async fn send_text(
@@ -171,9 +168,8 @@ impl ChatChannelBackend for TelegramBackend {
{
offset = uid + 1;
}
if let Some(text) = update
.pointer("/message/text")
.and_then(|t| t.as_str())
if let Some(text) =
update.pointer("/message/text").and_then(|t| t.as_str())
{
// Group chat filtering: only process if @bot is mentioned
let chat_type = update
@@ -184,8 +180,7 @@ impl ChatChannelBackend for TelegramBackend {
if (chat_type == "group" || chat_type == "supergroup")
&& !bot_username.is_empty()
{
let at_bot =
format!("@{}", bot_username);
let at_bot = format!("@{}", bot_username);
if !text.to_lowercase().contains(&at_bot) {
eprintln!("[Telegram] skipped group msg without @bot: {text}");
continue;
+59 -76
View File
@@ -57,6 +57,18 @@ pub struct WeixinQrcodeStatusPublic {
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) ──
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}"))
}
pub async fn weixin_check_qrcode(
qrcode: &str,
) -> Result<WeixinQrcodeStatus, ChatChannelError> {
pub async fn weixin_check_qrcode(qrcode: &str) -> Result<WeixinQrcodeStatus, ChatChannelError> {
let client = qr_client();
let resp = client
.get(format!("{ILINK_BASE_URL}/ilink/bot/get_qrcode_status"))
.query(&[("qrcode", qrcode)])
.send()
.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
.json()
@@ -289,11 +301,7 @@ impl WeixinBackend {
}
/// Build the JSON body for the iLink sendmessage API.
fn build_send_body(
to_user_id: &str,
context_token: &str,
text: &str,
) -> serde_json::Value {
fn build_send_body(to_user_id: &str, context_token: &str, text: &str) -> serde_json::Value {
serde_json::json!({
"msg": {
"from_user_id": "",
@@ -313,23 +321,14 @@ impl WeixinBackend {
/// Send a message via the iLink API and handle the response.
/// Returns `Ok(true)` if sent, `Ok(false)` if buffered due to expired context.
async fn do_send(
client: &reqwest::Client,
base_url: &str,
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");
async fn do_send(req: SendRequest<'_>) -> Result<bool, ChatChannelError> {
let body = Self::build_send_body(req.to_user_id, req.context_token, req.text);
let url = format!("{}/ilink/bot/sendmessage", req.base_url);
let resp = client
let resp = req
.client
.post(&url)
.headers(Self::build_headers(bot_token, wechat_uin))
.headers(Self::build_headers(req.bot_token, req.wechat_uin))
.json(&body)
.send()
.await
@@ -356,20 +355,18 @@ impl WeixinBackend {
if ret == -2 {
// 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;
}
let mut buf = pending_messages.lock().await;
let mut buf = req.pending_messages.lock().await;
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");
return Ok(false);
}
return Err(ChatChannelError::SendFailed(format!(
"ret={ret}: {errmsg}"
)));
return Err(ChatChannelError::SendFailed(format!("ret={ret}: {errmsg}")));
}
}
}
@@ -377,10 +374,7 @@ impl WeixinBackend {
Ok(true)
}
async fn send_text(
&self,
text: &str,
) -> Result<SentMessageId, ChatChannelError> {
async fn send_text(&self, text: &str) -> Result<SentMessageId, ChatChannelError> {
// Extract context data under lock, then release
let (to_user_id, context_token, expired) = {
let guard = self.reply_context.lock().await;
@@ -418,17 +412,17 @@ impl WeixinBackend {
text.len()
);
Self::do_send(
&self.client,
&self.base_url,
&self.bot_token,
&self.wechat_uin,
&to_user_id,
&context_token,
Self::do_send(SendRequest {
client: &self.client,
base_url: &self.base_url,
bot_token: &self.bot_token,
wechat_uin: &self.wechat_uin,
to_user_id: &to_user_id,
context_token: &context_token,
text,
&self.reply_context,
&self.pending_messages,
)
reply_context: &self.reply_context,
pending_messages: &self.pending_messages,
})
.await?;
Ok(SentMessageId(String::new()))
@@ -478,10 +472,8 @@ impl ChatChannelBackend for WeixinBackend {
eprintln!("[Weixin] verify response status={status_code}, body={resp_text}");
let verify_result: serde_json::Value =
serde_json::from_str(&resp_text).map_err(|e| {
ChatChannelError::ConnectionFailed(format!("JSON parse failed: {e}"))
})?;
let verify_result: serde_json::Value = serde_json::from_str(&resp_text)
.map_err(|e| ChatChannelError::ConnectionFailed(format!("JSON parse failed: {e}")))?;
// iLink API auth failures come back as `{"errcode":-14,"errmsg":"session timeout"}`
// (no `ret` field). Treat any non-zero errcode as authentication failure.
@@ -606,8 +598,7 @@ impl ChatChannelBackend for WeixinBackend {
for msg in msgs {
// Only handle user messages (message_type=1),
// skip bot echo (message_type=2)
let msg_type =
msg.get("message_type").and_then(|v| v.as_i64());
let msg_type = msg.get("message_type").and_then(|v| v.as_i64());
if msg_type != Some(1) {
continue;
}
@@ -654,10 +645,8 @@ impl ChatChannelBackend for WeixinBackend {
if !from_user_id.is_empty() && !context_token.is_empty() {
let was_expired = {
let mut guard = reply_context.lock().await;
let was = guard
.as_ref()
.map(|c| c.expired)
.unwrap_or(false);
let was =
guard.as_ref().map(|c| c.expired).unwrap_or(false);
*guard = Some(WeixinReplyContext {
to_user_id: from_user_id.to_string(),
context_token: context_token.to_string(),
@@ -676,23 +665,22 @@ impl ChatChannelBackend for WeixinBackend {
buffered.len()
);
for pending_text in &buffered {
let ok = WeixinBackend::do_send(
&client,
&base_url,
&bot_token,
&wechat_uin,
from_user_id,
let ok = WeixinBackend::do_send(SendRequest {
client: &client,
base_url: &base_url,
bot_token: &bot_token,
wechat_uin: &wechat_uin,
to_user_id: from_user_id,
context_token,
pending_text,
&reply_context,
&pending_messages,
)
text: pending_text,
reply_context: &reply_context,
pending_messages: &pending_messages,
})
.await;
if let Err(e) = ok {
eprintln!("[Weixin] resend error: {e}");
// Re-buffer remaining on hard error
let mut buf =
pending_messages.lock().await;
let mut buf = pending_messages.lock().await;
if buf.len() < MAX_PENDING_MESSAGES {
buf.push(pending_text.clone());
}
@@ -724,15 +712,11 @@ impl ChatChannelBackend for WeixinBackend {
}
Err(e) => {
consecutive_errors += 1;
eprintln!(
"[Weixin] polling error ({consecutive_errors}): {e}"
);
eprintln!("[Weixin] polling error ({consecutive_errors}): {e}");
*status.lock().await = ChannelConnectionStatus::Error;
// Exponential backoff: 5s, 10s, 20s, capped at 30s
let delay = std::cmp::min(
5 * 2u64.saturating_pow(consecutive_errors - 1),
30,
);
let delay =
std::cmp::min(5 * 2u64.saturating_pow(consecutive_errors - 1), 30);
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}");
let resp_json: serde_json::Value = serde_json::from_str(&resp_text).map_err(|e| {
ChatChannelError::ConnectionFailed(format!("Not valid JSON: {e}"))
})?;
let resp_json: serde_json::Value = serde_json::from_str(&resp_text)
.map_err(|e| ChatChannelError::ConnectionFailed(format!("Not valid JSON: {e}")))?;
if !status_code.is_success() {
return Err(ChatChannelError::AuthenticationFailed(format!(
@@ -155,9 +155,16 @@ async fn dispatch_command(
guard.find_by_sender(channel_id, sender_id).is_some()
};
if has_session {
return session_commands::handle_followup(
db, text, channel_id, sender_id, conn_mgr, bridge, lang, prefix,
)
return session_commands::handle_followup(session_commands::FollowupRequest {
db,
text,
channel_id,
sender_id,
conn_mgr,
bridge,
lang,
prefix,
})
.await;
}
return command_handlers::handle_help(prefix, lang);
@@ -205,8 +212,7 @@ async fn dispatch_command(
.await
}
"cancel" => {
session_commands::handle_cancel(db, channel_id, sender_id, conn_mgr, bridge, lang)
.await
session_commands::handle_cancel(db, channel_id, sender_id, conn_mgr, bridge, lang).await
}
"approve" => {
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 crate::db::entities::conversation;
pub async fn handle_search(
db: &DatabaseConnection,
keyword: &str,
lang: Lang,
) -> RichMessage {
pub async fn handle_search(db: &DatabaseConnection, keyword: &str, lang: Lang) -> RichMessage {
let matched = match conversation::Entity::find()
.filter(conversation::Column::DeletedAt.is_null())
.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 agent = &conv.agent_type;
let time = conv.created_at.format("%m-%d %H:%M");
body.push_str(&format!(
"#{} [{}] {} ({})\n",
conv.id, agent, title, time,
));
body.push_str(&format!("#{} [{}] {} ({})\n", conv.id, agent, title, time,));
}
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 {
let now = Utc::now();
let today_start = now
.date_naive()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc();
let today_start = now.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc();
let rows = match conversation::Entity::find()
.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);
body.push_str(&format!("\n\n{}", i18n::by_agent_label(lang)));
for (agent, count) in &by_agent {
body.push_str(&format!(
"\n {}",
i18n::agent_count(lang, agent, *count)
));
body.push_str(&format!("\n {}", i18n::agent_count(lang, agent, *count)));
}
if !titles.is_empty() {
@@ -9,7 +9,9 @@ use super::i18n::Lang;
use super::manager::ChatChannelManager;
use super::message_formatter;
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;
/// 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 {
match lang {
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 {
match lang {
Lang::ZhCn => format!(
"未知命令: {prefix}{command}\n输入 {prefix}help 查看可用命令"
),
Lang::ZhTw => format!(
"未知命令: {prefix}{command}\n輸入 {prefix}help 查看可用命令"
),
Lang::Ja => format!(
"不明なコマンド: {prefix}{command}\n{prefix}help でヘルプを表示"
),
Lang::Ko => format!(
"알 수 없는 명령: {prefix}{command}\n{prefix}help 로 도움말 보기"
),
Lang::ZhCn => format!("未知命令: {prefix}{command}\n输入 {prefix}help 查看可用命令"),
Lang::ZhTw => format!("未知命令: {prefix}{command}\n輸入 {prefix}help 查看可用命令"),
Lang::Ja => format!("不明なコマンド: {prefix}{command}\n{prefix}help でヘルプを表示"),
Lang::Ko => format!("알 수 없는 명령: {prefix}{command}\n{prefix}help 로 도움말 보기"),
Lang::Es => format!(
"Comando desconocido: {prefix}{command}\nEscriba {prefix}help para ver los comandos"
),
Lang::De => format!(
"Unbekannter Befehl: {prefix}{command}\n{prefix}help für Hilfe eingeben"
),
Lang::Fr => format!(
"Commande inconnue : {prefix}{command}\nTapez {prefix}help pour l'aide"
),
Lang::Pt => format!(
"Comando desconhecido: {prefix}{command}\nDigite {prefix}help para ajuda"
),
Lang::Ar => format!(
"أمر غير معروف: {prefix}{command}\nاكتب {prefix}help لعرض المساعدة"
),
Lang::En => format!(
"Unknown command: {prefix}{command}\nType {prefix}help for available commands"
),
Lang::De => {
format!("Unbekannter Befehl: {prefix}{command}\n{prefix}help für Hilfe eingeben")
}
Lang::Fr => {
format!("Commande inconnue : {prefix}{command}\nTapez {prefix}help pour l'aide")
}
Lang::Pt => {
format!("Comando desconhecido: {prefix}{command}\nDigite {prefix}help para ajuda")
}
Lang::Ar => format!("أمر غير معروف: {prefix}{command}\nاكتب {prefix}help لعرض المساعدة"),
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 {
Lang::ZhCn => format!("序号超出范围,请使用 {prefix}folder 查看列表。"),
Lang::ZhTw => format!("序號超出範圍,請使用 {prefix}folder 查看列表。"),
Lang::Ja => format!("インデックスが範囲外です。{prefix}folder でリストを確認してください。"),
Lang::Ja => {
format!("インデックスが範囲外です。{prefix}folder でリストを確認してください。")
}
Lang::Ko => format!("인덱스가 범위를 벗어났습니다. {prefix}folder로 목록을 확인하세요."),
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::Pt => format!("Índice fora de intervalo. Use {prefix}folder para listar."),
Lang::Ar => format!("الفهرس خارج النطاق. استخدم {prefix}folder لعرض القائمة."),
@@ -842,7 +835,9 @@ pub fn no_folder_selected(lang: Lang, prefix: &str) -> String {
match lang {
Lang::ZhCn => format!("未选择工作目录,请先使用 {prefix}folder 选择。"),
Lang::ZhTw => format!("未選擇工作目錄,請先使用 {prefix}folder 選擇。"),
Lang::Ja => format!("フォルダが選択されていません。先に {prefix}folder を使用してください。"),
Lang::Ja => {
format!("フォルダが選択されていません。先に {prefix}folder を使用してください。")
}
Lang::Ko => format!("폴더가 선택되지 않았습니다. 먼저 {prefix}folder를 사용하세요."),
Lang::Es => format!("Ninguna carpeta seleccionada. Usa {prefix}folder primero."),
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 {
Lang::ZhCn => 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::Es => format!("Responde {prefix}agent <número> o {prefix}agent <nombre> para seleccionar."),
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::Es => {
format!("Responde {prefix}agent <número> o {prefix}agent <nombre> para seleccionar.")
}
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::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::Ko => format!("인덱스가 범위를 벗어났습니다. {prefix}agent로 목록을 확인하세요."),
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::Pt => format!("Índice fora de intervalo. Use {prefix}agent para listar."),
Lang::Ar => format!("الفهرس خارج النطاق. استخدم {prefix}agent لعرض القائمة."),
@@ -1299,7 +1306,9 @@ pub fn no_active_session_use_task(lang: Lang, prefix: &str) -> String {
match lang {
Lang::ZhCn => format!("没有活跃的会话,请使用 {prefix}task 开始新任务。"),
Lang::ZhTw => format!("沒有活躍的對話,請使用 {prefix}task 開始新任務。"),
Lang::Ja => format!("アクティブなセッションがありません。{prefix}task で開始してください。"),
Lang::Ja => {
format!("アクティブなセッションがありません。{prefix}task で開始してください。")
}
Lang::Ko => format!("활성 세션이 없습니다. {prefix}task로 시작하세요."),
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."),
@@ -1314,11 +1323,17 @@ pub fn session_connection_lost(lang: Lang, prefix: &str) -> String {
match lang {
Lang::ZhCn => format!("会话连接已断开,请使用 {prefix}task 开始新任务。"),
Lang::ZhTw => format!("對話連線已斷開,請使用 {prefix}task 開始新任務。"),
Lang::Ja => format!("セッション接続が切断されました。{prefix}task で新しく開始してください。"),
Lang::Ja => {
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::De => 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::De => {
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::Ar => format!("انقطع اتصال الجلسة. استخدم {prefix}task لبدء جلسة جديدة."),
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) {
let channels =
match crate::db::service::chat_channel_service::list_enabled(db_conn).await {
Ok(c) => c,
Err(e) => {
eprintln!("[ChatChannel] failed to load enabled channels: {e}");
return;
}
};
let channels = match crate::db::service::chat_channel_service::list_enabled(db_conn).await {
Ok(c) => c,
Err(e) => {
eprintln!("[ChatChannel] failed to load enabled channels: {e}");
return;
}
};
for ch in channels {
let channel_type: ChannelType = match serde_json::from_value(
serde_json::Value::String(ch.channel_type.clone()),
) {
Ok(t) => t,
Err(_) => {
eprintln!(
"[ChatChannel] unknown channel type '{}' for '{}' (id={}), skipping",
ch.channel_type, ch.name, ch.id
);
continue;
}
};
let channel_type: ChannelType =
match serde_json::from_value(serde_json::Value::String(ch.channel_type.clone())) {
Ok(t) => t,
Err(_) => {
eprintln!(
"[ChatChannel] unknown channel type '{}' for '{}' (id={}), skipping",
ch.channel_type, ch.name, ch.id
);
continue;
}
};
let config: serde_json::Value = match serde_json::from_str(&ch.config_json) {
Ok(v) => v,
@@ -313,17 +311,17 @@ impl ChatChannelManager {
}
};
let backend =
match super::backends::create_backend(ch.id, channel_type, &config, token) {
Ok(b) => b,
Err(e) => {
eprintln!(
"[ChatChannel] failed to create backend for '{}' (id={}): {e}",
ch.name, ch.id
);
continue;
}
};
let backend = match super::backends::create_backend(ch.id, channel_type, &config, token)
{
Ok(b) => b,
Err(e) => {
eprintln!(
"[ChatChannel] failed to create backend for '{}' (id={}): {e}",
ch.name, ch.id
);
continue;
}
};
if let Err(e) = self
.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::message_formatter::{self, DailyReportData};
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";
/// Days to retain message logs before cleanup.
@@ -60,10 +62,7 @@ pub fn spawn_daily_report_scheduler(
continue;
}
let report_time = ch
.daily_report_time
.as_deref()
.unwrap_or("18:00");
let report_time = ch.daily_report_time.as_deref().unwrap_or("18:00");
if current_time != report_time {
continue;
@@ -124,8 +123,7 @@ async fn generate_daily_report(db: &DatabaseConnection) -> DailyReportData {
.await
.unwrap_or_default();
let mut by_agent: std::collections::HashMap<String, u32> =
std::collections::HashMap::new();
let mut by_agent: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
let mut folder_ids: HashSet<i32> = HashSet::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> {
self.sessions.values().find(|s| {
s.channel_id == channel_id && s.sender_id == sender_id
})
self.sessions
.values()
.find(|s| s.channel_id == channel_id && s.sender_id == sender_id)
}
pub fn find_by_sender_mut(
@@ -64,9 +64,9 @@ impl SessionBridge {
channel_id: i32,
sender_id: &str,
) -> Option<&mut ActiveSession> {
self.sessions.values_mut().find(|s| {
s.channel_id == channel_id && s.sender_id == sender_id
})
self.sessions
.values_mut()
.find(|s| s.channel_id == channel_id && s.sender_id == sender_id)
}
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::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 ──
pub async fn handle_folder(
@@ -70,13 +81,7 @@ async fn list_folders(
.map(|id| id == f.id)
.unwrap_or(false);
let marker = if current { " [*]" } else { "" };
body.push_str(&format!(
"{}. {}{} ({})\n",
i + 1,
f.name,
marker,
f.path
));
body.push_str(&format!("{}. {}{} ({})\n", i + 1, f.name, marker, f.path));
}
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));
};
let _ = sender_context_service::update_folder(db, channel_id, sender_id, Some(folder.id))
.await;
let _ = sender_context_service::update_folder(db, channel_id, sender_id, Some(folder.id)).await;
RichMessage::info(format!("{} ({})", folder.name, folder.path))
.with_title(i18n::folder_selected_title(lang))
@@ -128,8 +132,7 @@ async fn select_folder_by_path(
}
};
let _ =
sender_context_service::update_folder(db, channel_id, sender_id, Some(entry.id)).await;
let _ = sender_context_service::update_folder(db, channel_id, sender_id, Some(entry.id)).await;
RichMessage::info(format!("{} ({})", entry.name, entry.path))
.with_title(i18n::folder_selected_title(lang))
@@ -317,10 +320,7 @@ pub async fn handle_task(
conversation::ConversationStatus::Cancelled,
)
.await;
return RichMessage::error(format!(
"{}{e}",
i18n::failed_to_start_agent_label(lang)
));
return RichMessage::error(format!("{}{e}", i18n::failed_to_start_agent_label(lang)));
}
};
@@ -352,11 +352,8 @@ pub async fn handle_task(
)
.await;
RichMessage::info(format!(
"[{}] #{} @ {}",
agent_type, conv.id, folder.name,
))
.with_title(i18n::task_started_title(lang))
RichMessage::info(format!("[{}] #{} @ {}", agent_type, conv.id, folder.name,))
.with_title(i18n::task_started_title(lang))
}
// ── /sessions ──
@@ -401,16 +398,16 @@ pub async fn handle_sessions(
{
Ok(c) => c,
Err(e) => {
return RichMessage::error(format!(
"{}{e}",
i18n::failed_to_list_sessions_label(lang)
));
return RichMessage::error(format!("{}{e}", i18n::failed_to_list_sessions_label(lang)));
}
};
if convs.is_empty() {
return RichMessage::info(i18n::no_active_sessions_in_folder(lang))
.with_title(format!("{} - {}", i18n::sessions_title(lang), folder.name));
return RichMessage::info(i18n::no_active_sessions_in_folder(lang)).with_title(format!(
"{} - {}",
i18n::sessions_title(lang),
folder.name
));
}
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)));
RichMessage::info(body.trim_end())
.with_title(format!("{} - {}", i18n::sessions_title(lang), folder.name))
RichMessage::info(body.trim_end()).with_title(format!(
"{} - {}",
i18n::sessions_title(lang),
folder.name
))
}
// ── /resume ──
@@ -491,10 +491,7 @@ pub async fn handle_resume(
{
Ok(id) => id,
Err(e) => {
return RichMessage::error(format!(
"{}{e}",
i18n::failed_to_start_agent_label(lang)
));
return RichMessage::error(format!("{}{e}", i18n::failed_to_start_agent_label(lang)));
}
};
@@ -579,8 +576,7 @@ pub async fn handle_cancel(
// Clear session from context
let _ = sender_context_service::clear_session(db, channel_id, sender_id).await;
RichMessage::info(i18n::task_cancelled_body(lang))
.with_title(i18n::task_cancelled_title(lang))
RichMessage::info(i18n::task_cancelled_body(lang)).with_title(i18n::task_cancelled_title(lang))
}
// ── /approve, /deny ──
@@ -661,8 +657,7 @@ pub async fn handle_permission_response(
// Update auto_approve if requested
if always && approve {
let _ =
sender_context_service::update_auto_approve(db, channel_id, sender_id, true).await;
let _ = sender_context_service::update_auto_approve(db, channel_id, sender_id, true).await;
}
let action = if approve {
@@ -680,66 +675,58 @@ pub async fn handle_permission_response(
// ── follow-up (non-command text) ──
pub async fn handle_followup(
db: &DatabaseConnection,
text: &str,
channel_id: i32,
sender_id: &str,
conn_mgr: &ConnectionManager,
bridge: &Arc<Mutex<SessionBridge>>,
lang: 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)));
}
};
pub async fn handle_followup(req: FollowupRequest<'_>) -> RichMessage {
let ctx =
match sender_context_service::get_or_create(req.db, req.channel_id, req.sender_id).await {
Ok(c) => c,
Err(e) => {
return RichMessage::error(format!(
"{}{e}",
i18n::failed_to_load_context_label(req.lang)
));
}
};
let connection_id = match &ctx.current_connection_id {
Some(id) => id.clone(),
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
{
let bridge_guard = bridge.lock().await;
let bridge_guard = req.bridge.lock().await;
if bridge_guard.get(&connection_id).is_none() {
// Connection lost, clear context
drop(bridge_guard);
let _ = sender_context_service::clear_session(db, channel_id, sender_id).await;
return RichMessage::info(i18n::session_connection_lost(lang, prefix));
let _ =
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
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
bridge.lock().await.remove(&connection_id);
let _ = sender_context_service::clear_session(db, channel_id, sender_id).await;
req.bridge.lock().await.remove(&connection_id);
let _ = sender_context_service::clear_session(req.db, req.channel_id, req.sender_id).await;
return RichMessage::error(format!(
"{}{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) ──
async fn list_recent_sessions(
db: &DatabaseConnection,
lang: Lang,
prefix: &str,
) -> RichMessage {
async fn list_recent_sessions(db: &DatabaseConnection, lang: Lang, prefix: &str) -> RichMessage {
let recent = match conversation::Entity::find()
.filter(conversation::Column::DeletedAt.is_null())
.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 agent = &conv.agent_type;
let time = conv.created_at.format("%m-%d %H:%M");
body.push_str(&format!(
"#{} [{}] {} ({})\n",
conv.id, agent, title, time,
));
body.push_str(&format!("#{} [{}] {} ({})\n", conv.id, agent, title, time,));
}
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 crate::acp::manager::ConnectionManager;
use crate::acp::types::PromptInputBlock;
use crate::db::service::{
app_metadata_service, conversation_service, sender_context_service,
};
use crate::db::service::{app_metadata_service, conversation_service, sender_context_service};
use crate::web::event_bridge::WebEventBroadcaster;
use super::manager::ChatChannelManager;
@@ -132,10 +130,7 @@ async fn handle_acp_event_payload(
}
"content_delta" => {
let text = payload
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("");
let text = payload.get("text").and_then(|v| v.as_str()).unwrap_or("");
// Collect flush info under the lock, then release before any IO.
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 sender_id = session.sender_id.clone();
let auto_approve = sender_context_service::get_or_create(
db,
channel_id,
&sender_id,
)
.await
.map(|ctx| ctx.auto_approve)
.unwrap_or(false);
let auto_approve =
sender_context_service::get_or_create(db, channel_id, &sender_id)
.await
.map(|ctx| ctx.auto_approve)
.unwrap_or(false);
if auto_approve {
let option_id = options
@@ -286,8 +278,7 @@ async fn handle_acp_event_payload(
serde_json::Value::Null => None,
other => Some(other.to_string()),
});
let tool_desc =
format_tool_call_detail(tool_title, raw_input_str.as_deref());
let tool_desc = format_tool_call_detail(tool_title, raw_input_str.as_deref());
session.permission_pending = Some(PendingPermission {
request_id: request_id.to_string(),
@@ -407,16 +398,12 @@ async fn handle_acp_event_payload(
crate::db::entities::conversation::ConversationStatus::Cancelled,
)
.await;
let _ =
sender_context_service::clear_session(db, channel_id, &sender_id).await;
let _ = sender_context_service::clear_session(db, channel_id, &sender_id).await;
}
}
"status_changed" => {
let status = payload
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("");
let status = payload.get("status").and_then(|v| v.as_str()).unwrap_or("");
if status == "disconnected" || status == "error" {
let mut guard = bridge.lock().await;
@@ -425,8 +412,7 @@ async fn handle_acp_event_payload(
let sender_id = session.sender_id.clone();
drop(guard);
let _ =
sender_context_service::clear_session(db, channel_id, &sender_id).await;
let _ = 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()) {
let path = obj.get("path").and_then(|v| v.as_str());
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 {
format!("Grep: \"{}\"", truncate_str(pattern, 60))
};
+2 -1
View File
@@ -147,7 +147,8 @@ impl InteractiveMessage {
.iter()
.map(|b| format!("[{}]", b.label))
.collect();
msg.body.push_str(&format!("\n\n{}", button_text.join(" ")));
msg.body
.push_str(&format!("\n\n{}", button_text.join(" ")));
}
msg
}
+179 -126
View File
@@ -8,9 +8,9 @@ use tauri::State;
use crate::acp::binary_cache;
use crate::acp::error::AcpError;
use crate::acp::opencode_plugins::{self, PluginCheckSummary};
#[cfg(feature = "tauri-runtime")]
use crate::acp::manager::ConnectionManager;
use crate::acp::opencode_plugins::{self, PluginCheckSummary};
use crate::acp::preflight::{self, PreflightResult};
use crate::acp::registry;
use crate::acp::types::{
@@ -154,9 +154,7 @@ pub(crate) fn verify_agent_installed(agent_type: AgentType) -> Result<(), AcpErr
}
Ok(())
}
registry::AgentDistribution::Binary {
cmd, platforms, ..
} => {
registry::AgentDistribution::Binary { cmd, platforms, .. } => {
let platform = registry::current_platform();
if !platforms.iter().any(|p| p.platform == platform) {
return Err(AcpError::PlatformNotSupported(format!(
@@ -212,7 +210,11 @@ async fn npm_list_version(
prefix: Option<&std::path::Path>,
) -> Option<String> {
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 {
cmd.arg(format!("--prefix={}", p.display()));
}
@@ -266,9 +268,9 @@ async fn run_npm_streaming(
cmd.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().map_err(|e| {
AcpError::protocol(format!("failed to spawn npm: {e}"))
})?;
let mut child = cmd
.spawn()
.map_err(|e| AcpError::protocol(format!("failed to spawn npm: {e}")))?;
let stdout = child.stdout.take();
let stderr = child.stderr.take();
@@ -283,9 +285,7 @@ async fn run_npm_streaming(
let reader = BufReader::new(out);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
emit_agent_install_event(
&emitter, &task_id, AgentInstallEventKind::Log, &line,
);
emit_agent_install_event(&emitter, &task_id, AgentInstallEventKind::Log, &line);
}
}
}
@@ -300,9 +300,7 @@ async fn run_npm_streaming(
let reader = BufReader::new(err);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
emit_agent_install_event(
&emitter, &task_id, AgentInstallEventKind::Log, &line,
);
emit_agent_install_event(&emitter, &task_id, AgentInstallEventKind::Log, &line);
if !collected.is_empty() {
collected.push('\n');
}
@@ -316,9 +314,10 @@ async fn run_npm_streaming(
let (_, stderr_result) = tokio::join!(stdout_handle, stderr_handle);
let collected_stderr = stderr_result.unwrap_or_default();
let status = child.wait().await.map_err(|e| {
AcpError::protocol(format!("failed to wait for npm process: {e}"))
})?;
let status = child
.wait()
.await
.map_err(|e| AcpError::protocol(format!("failed to wait for npm process: {e}")))?;
Ok((status.success(), collected_stderr))
}
@@ -331,49 +330,58 @@ async fn install_npm_global_package_streaming(
let registry_arg = format!("--registry={NPM_OFFICIAL_REGISTRY}");
emit_agent_install_event(
emitter, task_id, AgentInstallEventKind::Log,
emitter,
task_id,
AgentInstallEventKind::Log,
format!("$ npm install -g {package}"),
);
let (success, stderr) = run_npm_streaming(
&["install", "-g", &registry_arg, package],
task_id,
emitter,
).await?;
let (success, stderr) =
run_npm_streaming(&["install", "-g", &registry_arg, package], task_id, emitter).await?;
if !success {
// EACCES: permission denied — retry with a user-local --prefix so
// we don't require root/sudo on macOS / Linux.
if stderr.contains("EACCES") {
emit_agent_install_event(
emitter, task_id, AgentInstallEventKind::Log,
emitter,
task_id,
AgentInstallEventKind::Log,
"Permission denied, retrying with user prefix...",
);
return install_npm_to_user_prefix_streaming(
package, &registry_arg, task_id, emitter,
).await;
return install_npm_to_user_prefix_streaming(package, &registry_arg, task_id, emitter)
.await;
}
// EEXIST: file conflict — retry with --force to overwrite
if stderr.contains("EEXIST") {
emit_agent_install_event(
emitter, task_id, AgentInstallEventKind::Log,
emitter,
task_id,
AgentInstallEventKind::Log,
"File conflict, retrying with --force...",
);
let (retry_success, retry_stderr) = run_npm_streaming(
&["install", "-g", "--force", &registry_arg, package],
task_id,
emitter,
).await?;
)
.await?;
if !retry_success {
if retry_stderr.contains("EACCES") {
emit_agent_install_event(
emitter, task_id, AgentInstallEventKind::Log,
emitter,
task_id,
AgentInstallEventKind::Log,
"Permission denied on --force retry, falling back to user prefix...",
);
return install_npm_to_user_prefix_streaming(
package, &registry_arg, task_id, emitter,
).await;
package,
&registry_arg,
task_id,
emitter,
)
.await;
}
let err = retry_stderr.trim().to_string();
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());
emit_agent_install_event(
emitter, task_id, AgentInstallEventKind::Log,
emitter,
task_id,
AgentInstallEventKind::Log,
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],
task_id,
emitter,
).await?;
)
.await?;
if !success {
// EEXIST in the user prefix: retry with --force to overwrite stale files
// from a previous installation.
if stderr.contains("EEXIST") {
emit_agent_install_event(
emitter, task_id, AgentInstallEventKind::Log,
emitter,
task_id,
AgentInstallEventKind::Log,
"File conflict in user prefix, retrying with --force...",
);
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,
emitter,
).await?;
)
.await?;
if !force_success {
let err = force_stderr.trim().to_string();
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(),
serde_json::Value::String(model.clone()),
);
gs_obj.insert(
plan_model_key.to_string(),
serde_json::Value::String(model),
);
gs_obj.insert(plan_model_key.to_string(), serde_json::Value::String(model));
}
None => {
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() {
fs::create_dir_all(parent).map_err(|e| {
AcpError::protocol(format!("create cline data directory failed: {e}"))
})?;
fs::create_dir_all(parent)
.map_err(|e| AcpError::protocol(format!("create cline data directory failed: {e}")))?;
}
let serialized_gs = serde_json::to_string_pretty(&gs)
.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);
match trim_non_empty(runtime.api_key) {
Some(api_key) => {
secrets_obj.insert(
key_field.to_string(),
serde_json::Value::String(api_key),
);
secrets_obj.insert(key_field.to_string(), serde_json::Value::String(api_key));
}
None => {
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() {
fs::create_dir_all(parent).map_err(|e| {
AcpError::protocol(format!("create cline data directory failed: {e}"))
})?;
fs::create_dir_all(parent)
.map_err(|e| AcpError::protocol(format!("create cline data directory failed: {e}")))?;
}
let serialized_secrets = serde_json::to_string_pretty(&secrets)
.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.
fn agent_env_keys(agent_type: AgentType) -> (&'static str, &'static str, &'static str) {
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"),
_ => ("OPENAI_BASE_URL", "OPENAI_API_KEY", "OPENAI_MODEL"),
}
@@ -1814,11 +1831,17 @@ fn cascade_update_agent_config(
AgentType::ClaudeCode | AgentType::Gemini => {
// Write into config.env (not root-level)
let mut env = serde_json::Map::new();
env.insert(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()));
env.insert(
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_str = serde_json::to_string(&patch)
.map_err(|e| AcpError::protocol(e.to_string()))?;
let patch_str =
serde_json::to_string(&patch).map_err(|e| AcpError::protocol(e.to_string()))?;
persist_agent_local_config_json(agent_type, Some(&patch_str))?;
}
AgentType::OpenClaw => {
@@ -1855,7 +1878,10 @@ fn cascade_update_agent_config(
if api_url.trim().is_empty() {
table.remove("api_base_url");
} 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)
@@ -1882,8 +1908,8 @@ fn cascade_update_agent_config(
persist_opencode_auth_json(&auth_str)?;
let patch = serde_json::json!({ "apiBaseUrl": api_url });
let patch_str = serde_json::to_string(&patch)
.map_err(|e| AcpError::protocol(e.to_string()))?;
let patch_str =
serde_json::to_string(&patch).map_err(|e| AcpError::protocol(e.to_string()))?;
persist_agent_local_config_json(agent_type, Some(&patch_str))?;
}
AgentType::Cline => {}
@@ -2106,17 +2132,11 @@ pub(crate) async fn acp_get_agent_status_core(
true,
setting.as_ref().and_then(|m| m.installed_version.clone()),
),
registry::AgentDistribution::Binary {
platforms, cmd, ..
} => {
let detected =
binary_cache::detect_installed_version(agent_type, cmd)
.ok()
.flatten();
(
platforms.iter().any(|p| p.platform == platform),
detected,
)
registry::AgentDistribution::Binary { platforms, cmd, .. } => {
let detected = binary_cache::detect_installed_version(agent_type, cmd)
.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
}
pub(crate) async fn acp_list_agents_core(
db: &AppDatabase,
) -> Result<Vec<AcpAgentInfo>, AcpError> {
pub(crate) async fn acp_list_agents_core(db: &AppDatabase) -> Result<Vec<AcpAgentInfo>, AcpError> {
let platform = registry::current_platform();
let agent_types = registry::all_acp_agents();
@@ -2417,9 +2435,17 @@ pub async fn acp_update_agent_preferences(
) -> Result<(), AcpError> {
let emitter = EventEmitter::Tauri(app);
acp_update_agent_preferences_core(
agent_type, enabled, env, config_json, opencode_auth_json,
codex_auth_json, codex_config_toml, &db, &emitter,
).await
agent_type,
enabled,
env,
config_json,
opencode_auth_json,
codex_auth_json,
codex_config_toml,
&db,
&emitter,
)
.await
}
pub(crate) async fn acp_update_agent_env_core(
@@ -2557,7 +2583,12 @@ pub async fn acp_update_agent_config(
) -> Result<(), AcpError> {
let emitter = EventEmitter::Tauri(app);
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
}
@@ -2589,17 +2620,25 @@ pub(crate) async fn acp_download_agent_binary_core(
})?;
emit_agent_install_event(
emitter, &task_id, AgentInstallEventKind::Log,
emitter,
&task_id,
AgentInstallEventKind::Log,
format!("Downloading {} v{version} for {platform}", meta.name),
);
let emitter_clone = emitter.clone();
let task_id_clone = task_id.clone();
let _ = binary_cache::ensure_binary_for_agent_with_progress(
agent_type, version, fallback.url, cmd,
agent_type,
version,
fallback.url,
cmd,
move |msg| {
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));
Ok(())
}
registry::AgentDistribution::Npx { .. } => Err(
AcpError::protocol("download is only supported for binary agents"),
),
registry::AgentDistribution::Npx { .. } => Err(AcpError::protocol(
"download is only supported for binary agents",
)),
};
match &result {
Ok(()) => {
emit_agent_install_event(
emitter, &task_id, AgentInstallEventKind::Completed,
emitter,
&task_id,
AgentInstallEventKind::Completed,
format!("{} installed successfully", meta.name),
);
}
Err(e) => {
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> {
let detected = detect_local_version(agent_type).await;
if let Some(version) = detected.clone() {
let _ = agent_setting_service::set_installed_version(
conn,
agent_type,
Some(version.clone()),
)
.await;
let _ =
agent_setting_service::set_installed_version(conn, agent_type, Some(version.clone()))
.await;
return Ok(Some(version));
}
@@ -2699,13 +2740,17 @@ pub(crate) async fn acp_prepare_npx_agent_core(
.and_then(|m| m.installed_version);
emit_agent_install_event(
emitter, &task_id, AgentInstallEventKind::Log,
emitter,
&task_id,
AgentInstallEventKind::Log,
format!("Installing {} ({package})", meta.name),
);
install_npm_global_package_streaming(package, &task_id, emitter).await?;
emit_agent_install_event(
emitter, &task_id, AgentInstallEventKind::Log,
emitter,
&task_id,
AgentInstallEventKind::Log,
"Detecting installed version...",
);
let resolved = detect_local_version(agent_type)
@@ -2741,13 +2786,18 @@ pub(crate) async fn acp_prepare_npx_agent_core(
match &result {
Ok(version) => {
emit_agent_install_event(
emitter, &task_id, AgentInstallEventKind::Completed,
emitter,
&task_id,
AgentInstallEventKind::Completed,
format!("{} v{version} installed successfully", meta.name),
);
}
Err(e) => {
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);
emit_agent_install_event(
emitter, &task_id, AgentInstallEventKind::Log,
emitter,
&task_id,
AgentInstallEventKind::Log,
format!("Uninstalling {}...", meta.name),
);
@@ -2802,13 +2854,18 @@ pub(crate) async fn acp_uninstall_agent_core(
match &result {
Ok(()) => {
emit_agent_install_event(
emitter, &task_id, AgentInstallEventKind::Completed,
emitter,
&task_id,
AgentInstallEventKind::Completed,
format!("{} uninstalled successfully", meta.name),
);
}
Err(e) => {
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 {
return Ok(AgentSkillsListResult {
supported: false,
message: Some(format!(
"{agent_type} 暂不支持在设置页管理 Skills"
)),
message: Some(format!("{agent_type} 暂不支持在设置页管理 Skills")),
locations: 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> {
opencode_plugins::check_opencode_plugins(None)
.map_err(|e| AcpError::Protocol(e))
opencode_plugins::check_opencode_plugins(None).map_err(AcpError::Protocol)
}
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
@@ -3049,7 +3103,7 @@ pub(crate) async fn opencode_install_plugins_core(
) -> Result<(), AcpError> {
opencode_plugins::install_missing_plugins(names, task_id, emitter)
.await
.map_err(|e| AcpError::Protocol(e))
.map_err(AcpError::Protocol)
}
#[cfg(feature = "tauri-runtime")]
@@ -3068,13 +3122,11 @@ pub(crate) async fn opencode_uninstall_plugin_core(
) -> Result<PluginCheckSummary, AcpError> {
opencode_plugins::uninstall_plugin(name)
.await
.map_err(|e| AcpError::Protocol(e))
.map_err(AcpError::Protocol)
}
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn opencode_uninstall_plugin(
name: String,
) -> Result<PluginCheckSummary, AcpError> {
pub async fn opencode_uninstall_plugin(name: String) -> Result<PluginCheckSummary, AcpError> {
opencode_uninstall_plugin_core(name).await
}
@@ -3108,7 +3160,10 @@ struct DeviceCodeUserCodeResp {
device_auth_id: String,
#[serde(alias = "usercode")]
user_code: String,
#[serde(default = "default_interval", deserialize_with = "deserialize_interval")]
#[serde(
default = "default_interval",
deserialize_with = "deserialize_interval"
)]
interval: u64,
}
@@ -3118,11 +3173,8 @@ fn default_interval() -> u64 {
fn extract_jwt_account_id(jwt: &str) -> Option<String> {
let payload = jwt.split('.').nth(1)?;
let decoded = base64::Engine::decode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
payload,
)
.ok()?;
let decoded =
base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, payload).ok()?;
let value: serde_json::Value = serde_json::from_slice(&decoded).ok()?;
value
.get("https://api.openai.com/auth")
@@ -3138,11 +3190,13 @@ where
use serde::de;
let value = serde_json::Value::deserialize(deserializer)?;
match &value {
serde_json::Value::Number(n) => n.as_u64().ok_or_else(|| {
de::Error::custom(format!("invalid interval number: {n}"))
}),
serde_json::Value::Number(n) => 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),
_ => 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,
}
pub(crate) async fn codex_request_device_code_core()
-> Result<CodexDeviceCodeResponse, AcpError>
{
pub(crate) async fn codex_request_device_code_core() -> Result<CodexDeviceCodeResponse, AcpError> {
let client = reqwest::Client::new();
let url = format!("{CODEX_OAUTH_ISSUER}/api/accounts/deviceauth/usercode");
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()
.await
.map_err(|e| AcpError::protocol(format!("read device code response failed: {e}")))?;
let uc: DeviceCodeUserCodeResp = serde_json::from_str(&raw_body)
.map_err(|e| AcpError::protocol(format!("parse device code response failed: {e} | body: {raw_body}")))?;
let uc: DeviceCodeUserCodeResp = serde_json::from_str(&raw_body).map_err(|e| {
AcpError::protocol(format!(
"parse device code response failed: {e} | body: {raw_body}"
))
})?;
Ok(CodexDeviceCodeResponse {
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)]
pub async fn codex_request_device_code()
-> Result<CodexDeviceCodeResponse, AcpError>
{
pub async fn codex_request_device_code() -> Result<CodexDeviceCodeResponse, AcpError> {
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)?
.ok_or_else(|| AppCommandError::not_found(format!("Chat channel {id} not found")))?;
let channel_type: ChannelType =
serde_json::from_value(serde_json::Value::String(model.channel_type.clone()))
.map_err(|_| {
AppCommandError::configuration_invalid(format!(
"Invalid channel type: {}",
model.channel_type
))
})?;
let channel_type: ChannelType = serde_json::from_value(serde_json::Value::String(
model.channel_type.clone(),
))
.map_err(|_| {
AppCommandError::configuration_invalid(format!(
"Invalid channel type: {}",
model.channel_type
))
})?;
let config: serde_json::Value =
serde_json::from_str(&model.config_json).map_err(|e| {
AppCommandError::configuration_invalid("Invalid config JSON")
.with_detail(e.to_string())
})?;
let config: serde_json::Value = serde_json::from_str(&model.config_json).map_err(|e| {
AppCommandError::configuration_invalid("Invalid config JSON").with_detail(e.to_string())
})?;
let token = crate::keyring_store::get_channel_token(id).ok_or_else(|| {
eprintln!("[connect_chat_channel] channel {id}: Token not set in keyring");
@@ -138,29 +137,25 @@ pub async fn connect_chat_channel_core(
Ok(())
}
pub async fn test_chat_channel_core(
db: &AppDatabase,
id: i32,
) -> Result<(), AppCommandError> {
pub async fn test_chat_channel_core(db: &AppDatabase, id: i32) -> Result<(), AppCommandError> {
let model = chat_channel_service::get_by_id(&db.conn, id)
.await
.map_err(AppCommandError::from)?
.ok_or_else(|| AppCommandError::not_found(format!("Chat channel {id} not found")))?;
let channel_type: ChannelType =
serde_json::from_value(serde_json::Value::String(model.channel_type.clone()))
.map_err(|_| {
AppCommandError::configuration_invalid(format!(
"Invalid channel type: {}",
model.channel_type
))
})?;
let channel_type: ChannelType = serde_json::from_value(serde_json::Value::String(
model.channel_type.clone(),
))
.map_err(|_| {
AppCommandError::configuration_invalid(format!(
"Invalid channel type: {}",
model.channel_type
))
})?;
let config: serde_json::Value =
serde_json::from_str(&model.config_json).map_err(|e| {
AppCommandError::configuration_invalid("Invalid config JSON")
.with_detail(e.to_string())
})?;
let config: serde_json::Value = serde_json::from_str(&model.config_json).map_err(|e| {
AppCommandError::configuration_invalid("Invalid config JSON").with_detail(e.to_string())
})?;
let token = crate::keyring_store::get_channel_token(id)
.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> {
let limit = limit.unwrap_or(50);
let offset = offset.unwrap_or(0);
let rows = chat_channel_message_log_service::list_by_channel(&db.conn, channel_id, limit, offset)
.await
.map_err(AppCommandError::from)?;
Ok(rows.into_iter().map(ChatChannelMessageLogInfo::from).collect())
let rows =
chat_channel_message_log_service::list_by_channel(&db.conn, channel_id, limit, offset)
.await
.map_err(AppCommandError::from)?;
Ok(rows
.into_iter()
.map(ChatChannelMessageLogInfo::from)
.collect())
}
const COMMAND_PREFIX_KEY: &str = "chat_command_prefix";
const DEFAULT_COMMAND_PREFIX: &str = "/";
pub async fn get_chat_command_prefix_core(
db: &AppDatabase,
) -> Result<String, AppCommandError> {
pub async fn get_chat_command_prefix_core(db: &AppDatabase) -> Result<String, AppCommandError> {
let val = crate::db::service::app_metadata_service::get_value(&db.conn, COMMAND_PREFIX_KEY)
.await
.map_err(AppCommandError::from)?;
@@ -238,10 +235,7 @@ pub async fn set_chat_command_prefix_core(
prefix: String,
) -> Result<(), AppCommandError> {
let trimmed = prefix.trim();
if trimmed.is_empty()
|| trimmed.len() > 3
|| trimmed.chars().any(|c| c.is_alphanumeric())
{
if trimmed.is_empty() || trimmed.len() > 3 || trimmed.chars().any(|c| c.is_alphanumeric()) {
return Err(AppCommandError::invalid_input(
"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";
pub async fn get_chat_message_language_core(
db: &AppDatabase,
) -> Result<String, AppCommandError> {
pub async fn get_chat_message_language_core(db: &AppDatabase) -> Result<String, AppCommandError> {
let val = crate::db::service::app_metadata_service::get_value(&db.conn, MESSAGE_LANGUAGE_KEY)
.await
.map_err(AppCommandError::from)?;
@@ -360,14 +352,20 @@ pub async fn weixin_check_qrcode_core(
if result.status == "confirmed" {
eprintln!(
"[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"),
);
if let Some(ref token) = result.bot_token {
save_chat_channel_token_core(channel_id, token)?;
eprintln!("[Weixin] Token saved for channel {channel_id}");
} 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 {
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_time: Option<String>,
) -> 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)]
@@ -431,7 +438,17 @@ pub async fn update_chat_channel(
daily_report_enabled: Option<bool>,
daily_report_time: Option<Option<String>>,
) -> 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")]
@@ -446,7 +463,10 @@ pub async fn delete_chat_channel(
#[cfg(feature = "tauri-runtime")]
#[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)
}
+10 -12
View File
@@ -3,9 +3,9 @@ use std::collections::{HashMap, HashSet};
use crate::app_error::AppCommandError;
#[cfg(feature = "tauri-runtime")]
use crate::db::entities::conversation;
use crate::db::service::{conversation_service, folder_service};
#[cfg(feature = "tauri-runtime")]
use crate::db::service::import_service;
use crate::db::service::{conversation_service, folder_service};
#[cfg(feature = "tauri-runtime")]
use crate::db::AppDatabase;
use crate::models::*;
@@ -288,8 +288,7 @@ pub async fn get_folder_conversation_core(
.await
.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 eid = ext_id.clone();
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
// (Gemini CLI). Fall back to matching by folder_path
// 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() {
// Filter by folder_path first, then find the closest
// 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()
})
.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
});
if let Some(conv) = matched {
let new_ext_id = conv.id.clone();
if let Ok(d) = parser.get_conversation(&new_ext_id) {
return Ok((
d.turns,
d.session_stats,
Some(new_ext_id),
));
return Ok((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),
// update the database so future lookups are direct.
if let Some(new_ext_id) = resolved_ext_id {
let _ =
conversation_service::update_external_id(conn, conversation_id, new_ext_id).await;
let _ = conversation_service::update_external_id(conn, conversation_id, new_ext_id).await;
}
let mut summary = summary;
+25 -27
View File
@@ -23,11 +23,11 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use crate::acp::types::AgentSkillScope;
use crate::commands::acp::{
preferred_scope_skill_dir, remove_skill_entry, scoped_skill_dirs, skill_storage_spec,
validate_skill_id,
};
use crate::acp::types::AgentSkillScope;
use crate::models::agent::AgentType;
// ─── 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| {
io::Error::new(
io::ErrorKind::Other,
format!(
"junction failed ({junction_err}); copy fallback failed ({copy_err})"
),
format!("junction failed ({junction_err}); copy fallback failed ({copy_err})"),
)
})?;
Ok(true)
@@ -518,9 +516,7 @@ fn ensure_central_experts_installed_blocking() -> InstallReport {
report.pending_user_review.push(meta.id.clone());
}
Err(e) => {
report
.errors
.push(format!("{}: {}", meta.id, e));
report.errors.push(format!("{}: {}", meta.id, e));
}
}
}
@@ -639,10 +635,10 @@ fn extract_bundle_dir(
.path()
.to_str()
.ok_or_else(|| ExpertsError::Io("non-utf8 path in bundle".into()))?;
let rel_within =
rel.strip_prefix(bundle_prefix)
.and_then(|s| s.strip_prefix('/'))
.unwrap_or(rel);
let rel_within = rel
.strip_prefix(bundle_prefix)
.and_then(|s| s.strip_prefix('/'))
.unwrap_or(rel);
let out_path = target.join(rel_within);
if let Some(parent) = out_path.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(
agent_type: AgentType,
) -> Result<Vec<ExpertListItem>, ExpertsError> {
let _ = skill_storage_spec(agent_type)
.ok_or(ExpertsError::UnsupportedAgent(agent_type))?;
let _ = skill_storage_spec(agent_type).ok_or(ExpertsError::UnsupportedAgent(agent_type))?;
let dirs = scoped_skill_dirs(agent_type, AgentSkillScope::Global, None)
.map_err(|_| ExpertsError::UnsupportedAgent(agent_type))?;
@@ -727,8 +722,8 @@ pub async fn experts_list_for_agent(
pub async fn experts_get_install_status(
expert_id: String,
) -> Result<Vec<ExpertInstallStatus>, ExpertsError> {
let expert_id = validate_skill_id(&expert_id)
.map_err(|e| ExpertsError::Metadata(e.to_string()))?;
let expert_id =
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 expected = expert_central_path(&expert_id);
let agents = supported_agents();
@@ -776,8 +771,8 @@ pub async fn experts_link_to_agent(
expert_id: String,
agent_type: AgentType,
) -> Result<ExpertInstallStatus, ExpertsError> {
let expert_id = validate_skill_id(&expert_id)
.map_err(|e| ExpertsError::Metadata(e.to_string()))?;
let expert_id =
validate_skill_id(&expert_id).map_err(|e| ExpertsError::Metadata(e.to_string()))?;
let _ = find_metadata(&expert_id)?;
let central = expert_central_path(&expert_id);
if !central.exists() {
@@ -819,9 +814,8 @@ pub async fn experts_link_to_agent(
}
ExpertLinkState::NotLinked => {
// Shouldn't happen after AlreadyExists, but retry once.
create_link_raw(&central, &link_path).map_err(|e| ExpertsError::Io(format!(
"retry link failed: {e}"
)))?;
create_link_raw(&central, &link_path)
.map_err(|e| ExpertsError::Io(format!("retry link failed: {e}")))?;
}
}
}
@@ -846,8 +840,8 @@ pub async fn experts_unlink_from_agent(
expert_id: String,
agent_type: AgentType,
) -> Result<(), ExpertsError> {
let expert_id = validate_skill_id(&expert_id)
.map_err(|e| ExpertsError::Metadata(e.to_string()))?;
let expert_id =
validate_skill_id(&expert_id).map_err(|e| ExpertsError::Metadata(e.to_string()))?;
let _guard = mutation_lock().lock().await;
@@ -865,10 +859,14 @@ pub async fn experts_unlink_from_agent(
continue;
}
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.
remove_skill_entry(&candidate)
.map_err(|e| ExpertsError::Io(format!("remove link {}: {e}", candidate.display())))?;
remove_skill_entry(&candidate).map_err(|e| {
ExpertsError::Io(format!("remove link {}: {e}", candidate.display()))
})?;
removed = true;
} else if state == ExpertLinkState::LinkedElsewhere {
return Err(ExpertsError::ForeignLink {
@@ -893,8 +891,8 @@ pub async fn experts_unlink_from_agent(
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn experts_read_content(expert_id: String) -> Result<String, ExpertsError> {
let expert_id = validate_skill_id(&expert_id)
.map_err(|e| ExpertsError::Metadata(e.to_string()))?;
let expert_id =
validate_skill_id(&expert_id).map_err(|e| ExpertsError::Metadata(e.to_string()))?;
let _ = find_metadata(&expert_id)?;
let path = expert_central_path(&expert_id).join("SKILL.md");
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)]
pub async fn git_reset(
path: String,
commit: String,
mode: String,
) -> Result<(), AppCommandError> {
pub async fn git_reset(path: String, commit: String, mode: String) -> Result<(), AppCommandError> {
let mode = mode.trim().to_lowercase();
let mode_flag = match mode.as_str() {
"soft" | "mixed" | "hard" | "keep" => format!("--{mode}"),
@@ -2128,10 +2124,7 @@ pub async fn git_delete_branch(
.map_err(AppCommandError::io)?;
if !output.status.success() {
return Err(git_command_error(
&format!("branch {flag}"),
&output.stderr,
));
return Err(git_command_error(&format!("branch {flag}"), &output.stderr));
}
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
.get_mut("mcp")
.and_then(Value::as_object_mut)
.ok_or_else(|| {
mcp_configuration_invalid(format!("invalid mcp in {}", path.display()))
})?;
.ok_or_else(|| mcp_configuration_invalid(format!("invalid mcp in {}", path.display())))?;
if !mcp.get("servers").map(Value::is_object).unwrap_or(false) {
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 {
let _: AgentType = serde_json::from_value(serde_json::Value::String(at.clone()))
.map_err(|_| {
AppCommandError::invalid_input(format!("Invalid agent type: {at}"))
})?;
.map_err(|_| AppCommandError::invalid_input(format!("Invalid agent type: {at}")))?;
}
Ok(())
}
@@ -32,20 +30,28 @@ fn validate_fields(
) -> Result<(), AppCommandError> {
if let Some(n) = name {
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 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://") {
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 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(())
@@ -72,15 +78,9 @@ pub async fn create_model_provider_core(
let agent_types_json = serde_json::to_string(&agent_types)
.map_err(|e| AppCommandError::invalid_input(e.to_string()))?;
let model = model_provider_service::create(
&db.conn,
name,
api_url,
api_key,
agent_types_json,
)
.await
.map_err(AppCommandError::from)?;
let model = model_provider_service::create(&db.conn, name, api_url, api_key, agent_types_json)
.await
.map_err(AppCommandError::from)?;
Ok(ModelProviderInfo::from(model))
}
@@ -122,8 +122,12 @@ pub async fn update_model_provider_core(
.map_err(AppCommandError::from)?;
// 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 key_changed = api_key.as_deref().is_some_and(|k| k != old_provider.api_key);
let url_changed = api_url
.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 {
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);
@@ -135,10 +139,7 @@ pub async fn update_model_provider_core(
Ok(ModelProviderInfo::from(model))
}
pub async fn delete_model_provider_core(
db: &AppDatabase,
id: i32,
) -> Result<(), AppCommandError> {
pub async fn delete_model_provider_core(db: &AppDatabase, id: i32) -> Result<(), AppCommandError> {
// Check if any agent settings reference this provider.
let dependents = agent_setting_service::find_by_model_provider_id(&db.conn, id)
.await
+1 -6
View File
@@ -28,12 +28,7 @@ pub async fn send_notification(
#[cfg(not(target_os = "macos"))]
{
use tauri_plugin_notification::NotificationExt;
let _ = app
.notification()
.builder()
.title(title)
.body(body)
.show();
let _ = app.notification().builder().title(title).body(body).show();
}
Ok(())
+4 -4
View File
@@ -29,9 +29,7 @@ async fn detect_one(name: &str) -> PackageManagerInfo {
match result {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout)
.trim()
.to_string();
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
PackageManagerInfo {
name: name.to_string(),
installed: true,
@@ -76,7 +74,9 @@ pub async fn create_shadcn_project(
return Err(AppCommandError::invalid_input("Template is required"));
}
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);
+5 -1
View File
@@ -139,7 +139,11 @@ pub async fn update_system_language_settings(
.map_err(AppCommandError::from)?;
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)
}
+8 -10
View File
@@ -32,16 +32,14 @@ pub(crate) fn prepare_credential_env(
}
};
let helper_script = match git_credential::create_credential_helper_script(
app_data_dir,
&app_binary,
) {
Ok(p) => p,
Err(e) => {
eprintln!("[TERM] failed to create credential helper script: {}", e);
return None;
}
};
let helper_script =
match git_credential::create_credential_helper_script(app_data_dir, &app_binary) {
Ok(p) => p,
Err(e) => {
eprintln!("[TERM] failed to create credential helper script: {}", e);
return None;
}
};
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")
.output()
.await
.map_err(|_| {
AppCommandError::not_found(format!("Cannot execute git at: {git_path}"))
})?;
.map_err(|_| AppCommandError::not_found(format!("Cannot execute git at: {git_path}")))?;
if !output.status.success() {
return Ok(GitDetectResult {
@@ -108,9 +106,7 @@ pub(crate) async fn detect_git_core(
#[cfg(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn detect_git(
db: State<'_, AppDatabase>,
) -> Result<GitDetectResult, AppCommandError> {
pub async fn detect_git(db: State<'_, AppDatabase>) -> Result<GitDetectResult, AppCommandError> {
detect_git_core(&db.conn).await
}
@@ -145,9 +141,7 @@ async fn load_git_settings(
#[cfg(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn get_git_settings(
db: State<'_, AppDatabase>,
) -> Result<GitSettings, AppCommandError> {
pub async fn get_git_settings(db: State<'_, AppDatabase>) -> Result<GitSettings, AppCommandError> {
load_git_settings(&db.conn).await
}
@@ -221,27 +215,21 @@ pub async fn update_github_accounts(
// ---------------------------------------------------------------------------
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn save_account_token(
account_id: String,
token: String,
) -> Result<(), AppCommandError> {
pub async fn save_account_token(account_id: String, token: String) -> Result<(), AppCommandError> {
crate::keyring_store::set_token(&account_id, &token)
.map_err(|e| AppCommandError::io_error("Failed to save token to keyring").with_detail(e))
}
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn get_account_token(
account_id: String,
) -> Result<Option<String>, AppCommandError> {
pub async fn get_account_token(account_id: String) -> Result<Option<String>, AppCommandError> {
Ok(crate::keyring_store::get_token(&account_id))
}
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn delete_account_token(
account_id: String,
) -> Result<(), AppCommandError> {
crate::keyring_store::delete_token(&account_id)
.map_err(|e| AppCommandError::io_error("Failed to delete token from keyring").with_detail(e))
pub async fn delete_account_token(account_id: String) -> Result<(), AppCommandError> {
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")
.send()
.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() {
let status = response.status().as_u16();
@@ -315,13 +305,9 @@ pub async fn validate_github_token(
})
.unwrap_or_default();
let user = response
.json::<GitHubUserResponse>()
.await
.map_err(|e| {
AppCommandError::network("Failed to parse GitHub API response")
.with_detail(e.to_string())
})?;
let user = response.json::<GitHubUserResponse>().await.map_err(|e| {
AppCommandError::network("Failed to parse GitHub API response").with_detail(e.to_string())
})?;
Ok(GitHubTokenValidation {
success: true,
+36 -27
View File
@@ -1,15 +1,15 @@
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU8, Ordering as AtomicOrdering};
#[cfg(target_os = "macos")]
use std::sync::atomic::AtomicU32;
use std::sync::atomic::{AtomicU8, Ordering as AtomicOrdering};
use std::sync::Mutex;
use sea_orm::DatabaseConnection;
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
use crate::app_error::AppCommandError;
use crate::db::AppDatabase;
use crate::db::service::app_metadata_service;
use crate::db::AppDatabase;
use crate::models::FolderDetail;
/// Base traffic-light position (logical px) at 100 % zoom.
@@ -123,12 +123,15 @@ fn is_system_dark_mode() -> bool {
// Output: " AppsUseLightTheme REG_DWORD 0x0"
// Extract the last token on the matching line to avoid
// substring false-positives (e.g. "0x00000001" contains "0x0").
stdout.lines().find(|l| l.contains("AppsUseLightTheme")).map(|line| {
line.split_whitespace()
.last()
.map(|val| val == "0x0" || val == "0x00000000")
.unwrap_or(false)
})
stdout
.lines()
.find(|l| l.contains("AppsUseLightTheme"))
.map(|line| {
line.split_whitespace()
.last()
.map(|val| val == "0x0" || val == "0x00000000")
.unwrap_or(false)
})
})
.unwrap_or(false)
})
@@ -263,6 +266,12 @@ impl SettingsWindowState {
}
}
impl Default for SettingsWindowState {
fn default() -> Self {
Self::new()
}
}
impl CommitWindowState {
pub fn new() -> 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 {
match section {
Some("appearance") => "settings/appearance",
@@ -508,6 +523,12 @@ impl MergeWindowState {
}
}
impl Default for MergeWindowState {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn open_merge_window(
@@ -738,11 +759,9 @@ pub async fn open_project_boot_window(
.inner_size(1400.0, 900.0)
.min_inner_size(1100.0, 700.0)
.center();
let window = apply_platform_window_style(builder)
.build()
.map_err(|e| {
AppCommandError::window("Failed to open project boot window", e.to_string())
})?;
let window = apply_platform_window_style(builder).build().map_err(|e| {
AppCommandError::window("Failed to open project boot window", e.to_string())
})?;
post_window_setup(&window);
Ok(())
@@ -764,12 +783,8 @@ pub async fn update_traffic_light_position(
CURRENT_ZOOM.store(clamped, AtomicOrdering::Relaxed);
// Persist to DB so the next launch reads the correct value.
let _ = app_metadata_service::upsert_value(
&db.conn,
ZOOM_LEVEL_DB_KEY,
&clamped.to_string(),
)
.await;
let _ =
app_metadata_service::upsert_value(&db.conn, ZOOM_LEVEL_DB_KEY, &clamped.to_string()).await;
let _ = app;
Ok(())
@@ -786,13 +801,7 @@ pub async fn update_appearance_mode(
) -> Result<(), AppCommandError> {
CACHED_APPEARANCE_MODE.store(mode_from_str(&mode), AtomicOrdering::Relaxed);
let _ = app_metadata_service::upsert_value(
&db.conn,
APPEARANCE_MODE_DB_KEY,
&mode,
)
.await;
let _ = app_metadata_service::upsert_value(&db.conn, APPEARANCE_MODE_DB_KEY, &mode).await;
Ok(())
}
@@ -20,22 +20,14 @@ impl MigrationTrait for Migration {
.primary_key(),
)
.col(ColumnDef::new(ChatChannel::Name).string().not_null())
.col(
ColumnDef::new(ChatChannel::ChannelType)
.string()
.not_null(),
)
.col(ColumnDef::new(ChatChannel::ChannelType).string().not_null())
.col(
ColumnDef::new(ChatChannel::Enabled)
.boolean()
.not_null()
.default(true),
)
.col(
ColumnDef::new(ChatChannel::ConfigJson)
.text()
.not_null(),
)
.col(ColumnDef::new(ChatChannel::ConfigJson).text().not_null())
.col(ColumnDef::new(ChatChannel::EventFilterJson).text().null())
.col(
ColumnDef::new(ChatChannel::DailyReportEnabled)
@@ -43,11 +35,7 @@ impl MigrationTrait for Migration {
.not_null()
.default(false),
)
.col(
ColumnDef::new(ChatChannel::DailyReportTime)
.string()
.null(),
)
.col(ColumnDef::new(ChatChannel::DailyReportTime).string().null())
.col(
ColumnDef::new(ChatChannel::CreatedAt)
.timestamp_with_time_zone()
@@ -138,11 +126,7 @@ impl MigrationTrait for Migration {
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(
Table::drop()
.table(ChatChannelMessageLog::Table)
.to_owned(),
)
.drop_table(Table::drop().table(ChatChannelMessageLog::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(ChatChannel::Table).to_owned())
@@ -10,7 +10,11 @@ impl MigrationTrait for Migration {
.alter_table(
Table::alter()
.table(AgentSetting::Table)
.add_column(ColumnDef::new(AgentSetting::ModelProviderId).integer().null())
.add_column(
ColumnDef::new(AgentSetting::ModelProviderId)
.integer()
.null(),
)
.to_owned(),
)
.await
@@ -1,7 +1,7 @@
use chrono::Utc;
use sea_orm::sea_query::OnConflict;
use sea_orm::{ConnectionTrait, DatabaseConnection};
use sea_orm::{ActiveValue::NotSet, ColumnTrait, EntityTrait, QueryFilter, Set};
use sea_orm::{ConnectionTrait, DatabaseConnection};
use crate::db::entities::app_metadata;
use crate::db::error::DbError;
@@ -207,8 +207,7 @@ pub async fn list_all(
sort_by: Option<String>,
status: Option<String>,
) -> Result<Vec<DbConversationSummary>, DbError> {
let mut query = conversation::Entity::find()
.filter(conversation::Column::DeletedAt.is_null());
let mut query = conversation::Entity::find().filter(conversation::Column::DeletedAt.is_null());
match folder_ids {
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())
}
pub async fn reorder_folders(
conn: &DatabaseConnection,
ids: Vec<i32>,
) -> Result<(), DbError> {
pub async fn reorder_folders(conn: &DatabaseConnection, ids: Vec<i32>) -> Result<(), DbError> {
if ids.is_empty() {
return Ok(());
}
+1 -4
View File
@@ -87,10 +87,7 @@ pub async fn delete_tabs_for_folder(
conn: &DatabaseConnection,
folder_id: i32,
) -> Result<(), DbError> {
let sql = format!(
"DELETE FROM opened_tab WHERE folder_id = {}",
folder_id
);
let sql = format!("DELETE FROM opened_tab WHERE folder_id = {}", folder_id);
conn.execute(Statement::from_string(DbBackend::Sqlite, sql))
.await?;
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.
pub async fn get_remote_url_by_name(
repo_path: &str,
remote_name: &str,
) -> Option<String> {
pub async fn get_remote_url_by_name(repo_path: &str, remote_name: &str) -> Option<String> {
let output = crate::process::tokio_command("git")
.args(["remote", "get-url", remote_name])
.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();
if url.is_empty() { None } else { Some(url) }
if url.is_empty() {
None
} else {
Some(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.
pub async fn load_github_accounts(
conn: &DatabaseConnection,
) -> Option<GitHubAccountsSettings> {
pub async fn load_github_accounts(conn: &DatabaseConnection) -> Option<GitHubAccountsSettings> {
let raw = app_metadata_service::get_value(conn, GITHUB_ACCOUNTS_KEY)
.await
.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 {
Some(url) => url,
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;
}
};
+4 -2
View File
@@ -62,8 +62,10 @@ pub fn ensure_git_repo(path: &str) -> Result<(), AppCommandError> {
git_path.display()
))
.with_detail(err.to_string())),
_ => Err(AppCommandError::io(err)
.with_detail(format!("Failed to inspect Git metadata: {}", git_path.display()))),
_ => Err(AppCommandError::io(err).with_detail(format!(
"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)
.map_err(|e| format!("failed to serialize tokens: {e}"))?;
std::fs::write(&path, json)
.map_err(|e| format!("failed to write token store: {e}"))
std::fs::write(&path, json).map_err(|e| format!("failed to write token store: {e}"))
}
#[cfg(not(feature = "tauri-runtime"))]
+3 -6
View File
@@ -181,14 +181,11 @@ mod tauri_app {
});
}
if label == "main"
&& matches!(event, tauri::WindowEvent::CloseRequested { .. })
{
if label == "main" && matches!(event, tauri::WindowEvent::CloseRequested { .. }) {
let app = window.app_handle();
if let Some(cm) = app.try_state::<ConnectionManager>() {
let disconnected = tauri::async_runtime::block_on(
cm.disconnect_by_owner_window(&label),
);
let disconnected =
tauri::async_runtime::block_on(cm.disconnect_by_owner_window(&label));
eprintln!(
"[ACP] main window closing disconnected_connections={}",
disconnected
+1 -1
View File
@@ -2,8 +2,8 @@ pub mod agent;
pub mod chat_channel;
pub mod conversation;
pub mod folder;
pub mod model_provider;
pub mod message;
pub mod model_provider;
pub mod system;
pub use agent::AgentType;
+13 -19
View File
@@ -586,19 +586,16 @@ impl ClaudeParser {
if tur.get("agentType").is_some() {
let mut stats = extract_agent_execution_stats(tur);
// Load tool calls from subagent's own JSONL transcript
if let Some(agent_id) =
tur.get("agentId").and_then(|v| v.as_str())
{
if let Some(agent_id) = tur.get("agentId").and_then(|v| v.as_str()) {
// Reject path traversal: agentId must be alphanumeric
if !agent_id.is_empty()
&& !agent_id.contains('/')
&& !agent_id.contains('\\')
&& !agent_id.contains("..")
{
let subagent_dir =
path.with_extension("").join("subagents");
let subagent_path = subagent_dir
.join(format!("agent-{}.jsonl", agent_id));
let subagent_dir = path.with_extension("").join("subagents");
let subagent_path =
subagent_dir.join(format!("agent-{}.jsonl", agent_id));
if subagent_path.exists() {
stats.tool_calls =
parse_subagent_tool_calls(&subagent_path);
@@ -775,9 +772,7 @@ impl ClaudeParser {
..
} = b
{
if tn == tool_name
&& !existing_result_ids.contains(id)
{
if tn == tool_name && !existing_result_ids.contains(id) {
return Some(id.clone());
}
}
@@ -980,7 +975,11 @@ fn extract_claude_user_image(item: &serde_json::Value) -> Option<ContentBlock> {
let mime_type = source
.and_then(|s| s.get("media_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(|| {
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("mime_type").and_then(|m| m.as_str()))
.map(str::trim)
@@ -1192,9 +1191,7 @@ fn parse_subagent_tool_calls(path: &PathBuf) -> Vec<AgentToolCall> {
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let input = item.get("input").map(|v| {
truncate_str(&v.to_string(), 500)
});
let input = item.get("input").map(|v| truncate_str(&v.to_string(), 500));
if !id.is_empty() {
calls.push((id, name, input));
}
@@ -1219,8 +1216,7 @@ fn parse_subagent_tool_calls(path: &PathBuf) -> Vec<AgentToolCall> {
.get("is_error")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let output = extract_tool_result_text(item)
.map(|s| truncate_str(&s, 500));
let output = extract_tool_result_text(item).map(|s| truncate_str(&s, 500));
if !id.is_empty() {
results.insert(id, (output, is_error));
}
@@ -1233,9 +1229,7 @@ fn parse_subagent_tool_calls(path: &PathBuf) -> Vec<AgentToolCall> {
calls
.into_iter()
.map(|(id, name, input)| {
let (output, is_error) = results
.remove(&id)
.unwrap_or((None, false));
let (output, is_error) = results.remove(&id).unwrap_or((None, false));
AgentToolCall {
tool_name: name,
input_preview: input,
+6 -21
View File
@@ -135,20 +135,13 @@ impl AgentParser for ClineParser {
fs::read_to_string(meta_path)
.ok()
.and_then(|raw| serde_json::from_str::<TaskMetadata>(&raw).ok())
.and_then(|meta| {
meta.model_usage
.first()
.and_then(|u| u.model_id.clone())
})
.and_then(|meta| meta.model_usage.first().and_then(|u| u.model_id.clone()))
});
let folder_path = entry.cwd_on_task_initialization.clone();
let folder_name = folder_path.as_deref().map(folder_name_from_path);
let title = entry
.task
.as_deref()
.map(|t| truncate_str(t.trim(), 100));
let title = entry.task.as_deref().map(|t| truncate_str(t.trim(), 100));
// Count messages from 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
.first()
.map(|t| t.timestamp)
.unwrap_or_else(Utc::now);
let started_at = turns.first().map(|t| t.timestamp).unwrap_or_else(Utc::now);
let ended_at = turns.last().map(|t| t.timestamp);
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:`
fn is_tool_result_text(text: &str) -> bool {
let trimmed = text.trim_start();
trimmed.starts_with('[')
&& trimmed.contains("] Result:")
trimmed.starts_with('[') && trimmed.contains("] Result:")
}
/// 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
let tool_name = trimmed
.strip_prefix('[')
.and_then(|s| {
s.find([']', ' '])
.map(|i| s[..i].to_string())
})
.and_then(|s| s.find([']', ' ']).map(|i| s[..i].to_string()))
.unwrap_or_default();
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())
.unwrap_or("unknown")
.to_string();
let tool_use_id =
item.get("id").and_then(|v| v.as_str()).map(String::from);
let tool_use_id = item.get("id").and_then(|v| v.as_str()).map(String::from);
let input_preview = item.get("input").map(|v| {
let s = v.to_string();
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" {
parse_codex_json_arg(payload)
.and_then(|a| {
a.get("cmd")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.and_then(|a| a.get("cmd").and_then(|v| v.as_str()).map(|s| s.to_string()))
.or_else(|| {
value_to_preview(
payload
.get("arguments")
.or_else(|| payload.get("input")),
payload.get("arguments").or_else(|| payload.get("input")),
)
})
} else {
value_to_preview(
payload
.get("arguments")
.or_else(|| payload.get("input")),
)
value_to_preview(payload.get("arguments").or_else(|| payload.get("input")))
};
let tc = AgentToolCall {
@@ -631,7 +621,11 @@ fn parse_codex_subagent_stats(
let total_duration_ms = match (first_ts, last_ts) {
(Some(f), Some(l)) => {
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,
};
@@ -955,10 +949,8 @@ impl CodexParser {
.and_then(|a| a.get("message"))
.and_then(|v| v.as_str())
.unwrap_or("");
let description = truncate_str(
message.lines().next().unwrap_or(""),
60,
);
let description =
truncate_str(message.lines().next().unwrap_or(""), 60);
if let Some(ref id) = tool_use_id {
spawn_agent_call_ids.insert(id.clone());
@@ -993,47 +985,43 @@ impl CodexParser {
"close_agent" => {
if let Some(ref id) = tool_use_id {
close_agent_call_ids.insert(id.clone());
let target = parse_codex_json_arg(payload)
.and_then(|a| {
let target =
parse_codex_json_arg(payload).and_then(|a| {
a.get("target")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
});
if let Some(target) = target {
close_agent_targets
.insert(id.clone(), target);
close_agent_targets.insert(id.clone(), target);
}
}
}
_ => {
if let Some(ref id) = tool_use_id {
call_id_tool_names.insert(
id.clone(),
raw_tool_name.to_string(),
);
call_id_tool_names
.insert(id.clone(), raw_tool_name.to_string());
}
let input_preview =
if raw_tool_name == "exec_command" {
parse_codex_json_arg(payload)
.and_then(|a| {
a.get("cmd")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.or_else(|| {
value_to_preview(
payload.get("arguments").or_else(
|| payload.get("input"),
),
)
})
} else {
value_to_preview(
payload
.get("arguments")
.or_else(|| payload.get("input")),
)
};
let input_preview = if raw_tool_name == "exec_command" {
parse_codex_json_arg(payload)
.and_then(|a| {
a.get("cmd")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.or_else(|| {
value_to_preview(
payload
.get("arguments")
.or_else(|| payload.get("input")),
)
})
} else {
value_to_preview(
payload
.get("arguments")
.or_else(|| payload.get("input")),
)
};
messages.push(UnifiedMessage {
id: format!("tool-{}", messages.len()),
role: MessageRole::Assistant,
@@ -1071,15 +1059,11 @@ impl CodexParser {
if is_spawn {
if let Some(output_obj) = parse_codex_json_output(payload) {
if let (Some(agent_id), Some(call_id)) = (
output_obj
.get("agent_id")
.and_then(|v| v.as_str()),
output_obj.get("agent_id").and_then(|v| v.as_str()),
tool_use_id.as_ref(),
) {
agent_id_to_spawn_call_id.insert(
agent_id.to_string(),
call_id.clone(),
);
agent_id_to_spawn_call_id
.insert(agent_id.to_string(), call_id.clone());
}
}
messages.push(UnifiedMessage {
@@ -1098,14 +1082,12 @@ impl CodexParser {
});
} else if is_wait {
if let Some(output_obj) = parse_codex_json_output(payload) {
if let Some(status) = output_obj
.get("status")
.and_then(|s| s.as_object())
if let Some(status) =
output_obj.get("status").and_then(|s| s.as_object())
{
for (agent_id, result) in status {
if let Some(text) = result
.get("completed")
.and_then(|v| v.as_str())
if let Some(text) =
result.get("completed").and_then(|v| v.as_str())
{
agent_final_results
.entry(agent_id.clone())
@@ -1115,8 +1097,7 @@ impl CodexParser {
}
}
} else if is_close {
active_agent_count =
active_agent_count.saturating_sub(1);
active_agent_count = active_agent_count.saturating_sub(1);
if let Some(output_obj) = parse_codex_json_output(payload) {
if let Some(agent_id) = tool_use_id
.as_ref()
@@ -1135,7 +1116,9 @@ impl CodexParser {
}
} else {
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 raw_output = value_to_preview(output_value);
@@ -1235,9 +1218,7 @@ impl CodexParser {
if let Some(dir) = session_dir {
let stats = agent_stats_cache
.entry(agent_id.to_string())
.or_insert_with(|| {
parse_codex_subagent_stats(dir, agent_id)
});
.or_insert_with(|| parse_codex_subagent_stats(dir, agent_id));
if stats.is_some() {
*agent_stats = stats.clone();
}
@@ -1677,9 +1658,7 @@ fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
// Only absorb immediately following Tool messages
// (stop at the next assistant message to keep turns small for virtualization)
while i < messages.len()
&& matches!(messages[i].role, MessageRole::Tool)
{
while i < messages.len() && matches!(messages[i].role, MessageRole::Tool) {
blocks.extend(messages[i].content.clone());
if usage.is_none() {
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
// (stop at the next assistant message to keep turns small for virtualization)
while i < messages.len()
&& matches!(messages[i].role, MessageRole::Tool)
{
while i < messages.len() && matches!(messages[i].role, MessageRole::Tool) {
blocks.extend(messages[i].content.clone());
if usage.is_none() {
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()) {
// Collect context lines from this hunk to find match position
let hunk_lines = collect_hunk_lines(&lines, i + 1);
if let Some((old_start, old_count, new_count)) =
find_hunk_position(fl, &hunk_lines)
if let Some((old_start, old_count, new_count)) = find_hunk_position(fl, &hunk_lines)
{
let new_start = old_start; // same start for context-based patches
output.push_str(&format!(
@@ -496,7 +495,11 @@ pub fn resolve_patch_text(patch: &str, cwd: Option<&str>) -> Option<String> {
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.
@@ -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> {
let mut result = Vec::new();
for &line in &lines[start..] {
if line == "@@"
|| line.starts_with("*** ")
{
if line == "@@" || line.starts_with("*** ") {
break;
}
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)
/// 2. Contiguous match of context+deleted lines (pre-patch file)
/// 3. Subsequence match of context-only lines (file has been further modified)
fn find_hunk_position(
file_lines: &[String],
hunk_lines: &[&str],
) -> Option<(usize, usize, usize)> {
fn find_hunk_position(file_lines: &[String], hunk_lines: &[&str]) -> Option<(usize, usize, usize)> {
let mut old_count = 0usize;
let mut new_count = 0usize;
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 {
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);
}
}
+31 -35
View File
@@ -19,9 +19,7 @@ use crate::parsers::{
/// timestamp prefix from OpenClaw user messages.
fn sender_block_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"(?s)^Sender \(untrusted metadata\):\s*```[^`]*```\s*").unwrap()
})
RE.get_or_init(|| Regex::new(r"(?s)^Sender \(untrusted metadata\):\s*```[^`]*```\s*").unwrap())
}
fn timestamp_prefix_regex() -> &'static Regex {
@@ -354,9 +352,7 @@ impl OpenClawParser {
}
/// Read sessions.json for a given agent directory.
fn read_session_index(
agent_dir: &Path,
) -> Result<HashMap<String, SessionMeta>, ParseError> {
fn read_session_index(agent_dir: &Path) -> Result<HashMap<String, SessionMeta>, ParseError> {
let index_path = agent_dir.join("sessions").join("sessions.json");
if !index_path.exists() {
return Ok(HashMap::new());
@@ -710,18 +706,12 @@ impl OpenClawParser {
if jsonl_path.exists() {
let meta = Self::read_session_index(&agent_dir)
.ok()
.and_then(|index| {
index
.into_values()
.find(|m| m.session_id == session_id)
});
.and_then(|index| index.into_values().find(|m| m.session_id == session_id));
return Ok((jsonl_path, leaf_id, meta));
}
// Try reset files
if let Some((path, meta)) =
Self::find_reset_file(&agent_dir, session_id)
{
if let Some((path, meta)) = Self::find_reset_file(&agent_dir, session_id) {
return Ok((path, leaf_id, meta));
}
}
@@ -747,9 +737,7 @@ impl OpenClawParser {
if jsonl_path.exists() {
let meta = Self::read_session_index(&agent_dir)
.ok()
.and_then(|index| {
index.into_values().find(|m| m.session_id == bare_id)
});
.and_then(|index| index.into_values().find(|m| m.session_id == bare_id));
return Ok((jsonl_path, None, meta));
}
@@ -768,9 +756,8 @@ impl OpenClawParser {
// Check if any leaf id matches the bare_id
let leaves = tree.leaf_ids();
if leaves.iter().any(|l| l == bare_id) {
let meta = Self::read_session_index(&agent_dir)
.ok()
.and_then(|index| {
let meta =
Self::read_session_index(&agent_dir).ok().and_then(|index| {
index.into_values().find(|m| m.session_id == *sid)
});
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()?;
for item in content {
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
@@ -987,8 +977,13 @@ fn extract_assistant_content(value: &serde_json::Value) -> Vec<ContentBlock> {
.to_string();
let is_edit_tool = matches!(
tool_name.to_lowercase().as_str(),
"edit" | "write" | "apply_patch" | "patch" | "applypatch"
| "edit_file" | "editfile"
"edit"
| "write"
| "apply_patch"
| "patch"
| "applypatch"
| "edit_file"
| "editfile"
);
let max_len = if is_edit_tool { 50000 } else { 500 };
let input_preview = item.get("arguments").map(|a| {
@@ -1068,10 +1063,7 @@ fn extract_usage(value: &serde_json::Value) -> Option<TurnUsage> {
.get("cacheWrite")
.and_then(|v| v.as_u64())
.unwrap_or(0),
cache_read_input_tokens: usage
.get("cacheRead")
.and_then(|v| v.as_u64())
.unwrap_or(0),
cache_read_input_tokens: usage.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
// (stop at the next assistant message to keep turns small for virtualization)
while i < messages.len()
&& matches!(messages[i].role, MessageRole::Tool)
{
while i < messages.len() && matches!(messages[i].role, MessageRole::Tool) {
blocks.extend(messages[i].content.clone());
if usage.is_none() {
usage = messages[i].usage.clone();
@@ -1162,7 +1152,8 @@ mod tests {
#[test]
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 home = dirs::home_dir().unwrap().to_string_lossy().to_string();
assert_eq!(wd, format!("{}/forway/agent-workspace", home));
@@ -1219,9 +1210,13 @@ mod tests {
});
let blocks = extract_assistant_content(&value);
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[2], ContentBlock::ToolUse { tool_name, .. } if tool_name == "read"));
assert!(
matches!(&blocks[2], ContentBlock::ToolUse { tool_name, .. } if tool_name == "read")
);
}
#[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}})
).unwrap();
let detail = OpenClawParser::parse_conversation_detail(&path, "test/test-session", None, None)
.expect("parse detail");
let detail =
OpenClawParser::parse_conversation_detail(&path, "test/test-session", None, None)
.expect("parse detail");
fs::remove_file(&path).unwrap();
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
// can batch-load their tool calls in a single query instead of N queries.
let subagent_session_ids = self
.scan_subagent_session_ids(conn, conversation_id)
.await;
let subagent_session_ids = self.scan_subagent_session_ids(conn, conversation_id).await;
let subagent_tools = batch_load_subagent_tool_calls(conn, &subagent_session_ids).await;
let mut messages = Vec::with_capacity(rows.len());
@@ -270,8 +268,9 @@ impl OpenCodeParser {
None
};
let (content_blocks, usage_from_step_finish) =
self.load_sqlite_parts(conn, &msg_id, &subagent_tools).await?;
let (content_blocks, usage_from_step_finish) = self
.load_sqlite_parts(conn, &msg_id, &subagent_tools)
.await?;
let usage = if is_assistant {
extract_opencode_usage(&value).or(usage_from_step_finish)
@@ -511,8 +510,7 @@ impl OpenCodeParser {
agent_stats,
});
} else {
let input_preview = state_input
.and_then(|v| value_to_preview(Some(v)));
let input_preview = state_input.and_then(|v| value_to_preview(Some(v)));
blocks.push(ContentBlock::ToolUse {
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
// (stop at the next assistant message to keep turns small for virtualization)
while i < messages.len()
&& matches!(messages[i].role, MessageRole::Tool)
{
while i < messages.len() && matches!(messages[i].role, MessageRole::Tool) {
blocks.extend(messages[i].content.clone());
if usage.is_none() {
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 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
{
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.
if let Some(nvm_home) =
resolve_dir(&[("NVM_HOME", &[]), ("APPDATA", &["nvm"])], None, &[])
if let Some(nvm_home) = resolve_dir(&[("NVM_HOME", &[]), ("APPDATA", &["nvm"])], None, &[])
{
if nvm_home.is_dir() {
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.
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.
+1 -5
View File
@@ -5,11 +5,7 @@ use axum::{
response::{IntoResponse, Response},
};
pub async fn require_token(
request: Request,
next: Next,
token: String,
) -> Response {
pub async fn require_token(request: Request, next: Next, token: String) -> Response {
// Allow WebSocket upgrade requests to authenticate via query param.
// The token value is URL-encoded by the client, so decode before comparing.
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).
pub fn emit_event(
emitter: &EventEmitter,
event: &str,
payload: impl Serialize + Clone,
) {
pub fn emit_event(emitter: &EventEmitter, event: &str, payload: impl Serialize + Clone) {
match emitter {
#[cfg(feature = "tauri-runtime")]
EventEmitter::Tauri(app) => {
+9 -14
View File
@@ -188,10 +188,9 @@ pub struct AcpListAgentSkillsParams {
pub async fn acp_list_agent_skills(
Json(params): Json<AcpListAgentSkillsParams>,
) -> Result<Json<AgentSkillsListResult>, AppCommandError> {
let result =
acp_commands::acp_list_agent_skills(params.agent_type, params.workspace_path)
.await
.map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?;
let result = acp_commands::acp_list_agent_skills(params.agent_type, params.workspace_path)
.await
.map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?;
Ok(Json(result))
}
@@ -482,10 +481,9 @@ pub async fn acp_detect_agent_local_version(
Json(params): Json<AgentTypeParams>,
) -> Result<Json<Option<String>>, AppCommandError> {
let db = &state.db;
let result =
acp_commands::acp_detect_agent_local_version_core(params.agent_type, &db.conn)
.await
.map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?;
let result = acp_commands::acp_detect_agent_local_version_core(params.agent_type, &db.conn)
.await
.map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?;
Ok(Json(result))
}
@@ -610,11 +608,8 @@ pub struct CodexPollDeviceCodeParams {
pub async fn codex_poll_device_code(
Json(params): Json<CodexPollDeviceCodeParams>,
) -> Result<Json<acp_commands::CodexDeviceCodePollResult>, AppCommandError> {
let result = acp_commands::codex_poll_device_code_core(
params.device_auth_id,
params.user_code,
)
.await
.map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?;
let result = acp_commands::codex_poll_device_code_core(params.device_auth_id, params.user_code)
.await
.map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?;
Ok(Json(result))
}
+3 -5
View File
@@ -5,8 +5,8 @@ use serde::Deserialize;
use crate::app_error::AppCommandError;
use crate::app_state::AppState;
use crate::commands::chat_channel as cc_commands;
use crate::chat_channel::backends::weixin::{WeixinQrcodeInfo, WeixinQrcodeStatusPublic};
use crate::commands::chat_channel as cc_commands;
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(
Extension(state): Extension<Arc<AppState>>,
) -> Result<Json<Vec<ChannelStatusInfo>>, AppCommandError> {
let result =
cc_commands::get_chat_channel_status_core(&state.chat_channel_manager).await?;
let result = cc_commands::get_chat_channel_status_core(&state.chat_channel_manager).await?;
Ok(Json(result))
}
@@ -270,7 +269,6 @@ pub async fn weixin_check_qrcode(
Json(params): Json<WeixinCheckQrcodeParams>,
) -> Result<Json<WeixinQrcodeStatusPublic>, AppCommandError> {
let result =
cc_commands::weixin_check_qrcode_core(&state.db, params.channel_id, &params.qrcode)
.await?;
cc_commands::weixin_check_qrcode_core(&state.db, params.channel_id, &params.qrcode).await?;
Ok(Json(result))
}
+6 -6
View File
@@ -98,8 +98,7 @@ pub struct GetConversationParams {
pub async fn get_conversation(
Json(params): Json<GetConversationParams>,
) -> Result<Json<ConversationDetail>, AppCommandError> {
let result =
conv_commands::get_conversation(params.agent_type, params.conversation_id).await?;
let result = conv_commands::get_conversation(params.agent_type, params.conversation_id).await?;
Ok(Json(result))
}
@@ -149,9 +148,10 @@ pub async fn import_local_conversations(
.await
.map_err(AppCommandError::from)?
.ok_or_else(|| AppCommandError::not_found("Folder not found"))?;
let result = import_service::import_local_conversations(&db.conn, params.folder_id, &folder.path)
.await
.map_err(AppCommandError::from)?;
let result =
import_service::import_local_conversations(&db.conn, params.folder_id, &folder.path)
.await
.map_err(AppCommandError::from)?;
Ok(Json(result))
}
@@ -251,4 +251,4 @@ pub async fn update_conversation_external_id(
.await
.map_err(AppCommandError::from)?;
Ok(Json(()))
}
}
+8 -18
View File
@@ -77,24 +77,21 @@ pub struct CreateFileTreeEntryParams {
pub async fn read_file_preview(
Json(params): Json<ReadFilePreviewParams>,
) -> Result<Json<folder_commands::FilePreviewContent>, AppCommandError> {
let result =
folder_commands::read_file_preview(params.root_path, params.path).await?;
let result = folder_commands::read_file_preview(params.root_path, params.path).await?;
Ok(Json(result))
}
pub async fn read_file_base64(
Json(params): Json<ReadFileBase64Params>,
) -> Result<Json<String>, AppCommandError> {
let result =
folder_commands::read_file_base64(params.path, params.max_bytes).await?;
let result = folder_commands::read_file_base64(params.path, params.max_bytes).await?;
Ok(Json(result))
}
pub async fn read_file_for_edit(
Json(params): Json<ReadFileForEditParams>,
) -> Result<Json<folder_commands::FileEditContent>, AppCommandError> {
let result =
folder_commands::read_file_for_edit(params.root_path, params.path).await?;
let result = folder_commands::read_file_for_edit(params.root_path, params.path).await?;
Ok(Json(result))
}
@@ -114,24 +111,17 @@ pub async fn save_file_content(
pub async fn save_file_copy(
Json(params): Json<SaveFileCopyParams>,
) -> Result<Json<folder_commands::FileSaveResult>, AppCommandError> {
let result = folder_commands::save_file_copy(
params.root_path,
params.path,
params.content,
)
.await?;
let result =
folder_commands::save_file_copy(params.root_path, params.path, params.content).await?;
Ok(Json(result))
}
pub async fn rename_file_tree_entry(
Json(params): Json<RenameFileTreeEntryParams>,
) -> Result<Json<String>, AppCommandError> {
let result = folder_commands::rename_file_tree_entry(
params.root_path,
params.path,
params.new_name,
)
.await?;
let result =
folder_commands::rename_file_tree_entry(params.root_path, params.path, params.new_name)
.await?;
Ok(Json(result))
}
+10 -12
View File
@@ -68,14 +68,10 @@ pub async fn create_folder_command(
Json(params): Json<CreateFolderCommandParams>,
) -> Result<Json<FolderCommandInfo>, AppCommandError> {
let db = &state.db;
let result = folder_command_service::create(
&db.conn,
params.folder_id,
&params.name,
&params.command,
)
.await
.map_err(AppCommandError::from)?;
let result =
folder_command_service::create(&db.conn, params.folder_id, &params.name, &params.command)
.await
.map_err(AppCommandError::from)?;
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)
})
.await
.map_err(|e| AppCommandError::new(
crate::app_error::AppErrorCode::TaskExecutionFailed,
format!("bootstrap task failed: {e}"),
))?;
.map_err(|e| {
AppCommandError::new(
crate::app_error::AppErrorCode::TaskExecutionFailed,
format!("bootstrap task failed: {e}"),
)
})?;
if commands_to_create.is_empty() {
return Ok(Json(existing));
+26 -66
View File
@@ -79,8 +79,7 @@ pub struct GitStatusParams {
pub async fn git_status(
Json(params): Json<GitStatusParams>,
) -> Result<Json<Vec<folder_commands::GitStatusEntry>>, AppCommandError> {
let result =
folder_commands::git_status(params.path, params.show_all_untracked).await?;
let result = folder_commands::git_status(params.path, params.show_all_untracked).await?;
Ok(Json(result))
}
@@ -101,8 +100,7 @@ pub struct GitCommitBranchesParams {
pub async fn git_commit_branches(
Json(params): Json<GitCommitBranchesParams>,
) -> Result<Json<Vec<String>>, AppCommandError> {
let result =
folder_commands::git_commit_branches(params.path, params.commit).await?;
let result = folder_commands::git_commit_branches(params.path, params.commit).await?;
Ok(Json(result))
}
@@ -117,9 +115,7 @@ pub struct GitShowFileParams {
pub async fn git_show_file(
Json(params): Json<GitShowFileParams>,
) -> Result<Json<String>, AppCommandError> {
let result =
folder_commands::git_show_file(params.path, params.file, params.ref_name)
.await?;
let result = folder_commands::git_show_file(params.path, params.file, params.ref_name).await?;
Ok(Json(result))
}
@@ -130,9 +126,7 @@ pub struct GitDiffParams {
pub file: Option<String>,
}
pub async fn git_diff(
Json(params): Json<GitDiffParams>,
) -> Result<Json<String>, AppCommandError> {
pub async fn git_diff(Json(params): Json<GitDiffParams>) -> Result<Json<String>, AppCommandError> {
let result = folder_commands::git_diff(params.path, params.file).await?;
Ok(Json(result))
}
@@ -160,13 +154,8 @@ pub struct GitLogParams {
pub async fn git_log(
Json(params): Json<GitLogParams>,
) -> Result<Json<folder_commands::GitLogResult>, AppCommandError> {
let result = folder_commands::git_log(
params.path,
params.limit,
params.branch,
params.remote,
)
.await?;
let result =
folder_commands::git_log(params.path, params.limit, params.branch, params.remote).await?;
Ok(Json(result))
}
@@ -174,9 +163,7 @@ pub async fn git_log(
// New pure git handlers (Pattern A – direct function calls)
// ---------------------------------------------------------------------------
pub async fn git_init(
Json(params): Json<PathParams>,
) -> Result<Json<()>, AppCommandError> {
pub async fn git_init(Json(params): Json<PathParams>) -> Result<Json<()>, AppCommandError> {
folder_commands::git_init(params.path).await?;
Ok(Json(()))
}
@@ -220,8 +207,7 @@ pub struct GitNewBranchParams {
pub async fn git_new_branch(
Json(params): Json<GitNewBranchParams>,
) -> Result<Json<()>, AppCommandError> {
folder_commands::git_new_branch(params.path, params.branch_name, params.start_point)
.await?;
folder_commands::git_new_branch(params.path, params.branch_name, params.start_point).await?;
Ok(Json(()))
}
@@ -236,12 +222,8 @@ pub struct GitWorktreeAddParams {
pub async fn git_worktree_add(
Json(params): Json<GitWorktreeAddParams>,
) -> Result<Json<()>, AppCommandError> {
folder_commands::git_worktree_add(
params.path,
params.branch_name,
params.worktree_path,
)
.await?;
folder_commands::git_worktree_add(params.path, params.branch_name, params.worktree_path)
.await?;
Ok(Json(()))
}
@@ -260,9 +242,7 @@ pub struct GitResetParams {
pub mode: String,
}
pub async fn git_reset(
Json(params): Json<GitResetParams>,
) -> Result<Json<()>, AppCommandError> {
pub async fn git_reset(Json(params): Json<GitResetParams>) -> Result<Json<()>, AppCommandError> {
folder_commands::git_reset(params.path, params.commit, params.mode).await?;
Ok(Json(()))
}
@@ -285,12 +265,8 @@ pub struct GitStashPushParams {
pub async fn git_stash_push(
Json(params): Json<GitStashPushParams>,
) -> Result<Json<String>, AppCommandError> {
let result = folder_commands::git_stash_push(
params.path,
params.message,
params.keep_index,
)
.await?;
let result =
folder_commands::git_stash_push(params.path, params.message, params.keep_index).await?;
Ok(Json(result))
}
@@ -304,8 +280,7 @@ pub struct GitStashPopParams {
pub async fn git_stash_pop(
Json(params): Json<GitStashPopParams>,
) -> Result<Json<String>, AppCommandError> {
let result =
folder_commands::git_stash_pop(params.path, params.stash_ref).await?;
let result = folder_commands::git_stash_pop(params.path, params.stash_ref).await?;
Ok(Json(result))
}
@@ -319,16 +294,14 @@ pub async fn git_stash_list(
pub async fn git_stash_apply(
Json(params): Json<PathStashRefParams>,
) -> Result<Json<String>, AppCommandError> {
let result =
folder_commands::git_stash_apply(params.path, params.stash_ref).await?;
let result = folder_commands::git_stash_apply(params.path, params.stash_ref).await?;
Ok(Json(result))
}
pub async fn git_stash_drop(
Json(params): Json<PathStashRefParams>,
) -> Result<Json<String>, AppCommandError> {
let result =
folder_commands::git_stash_drop(params.path, params.stash_ref).await?;
let result = folder_commands::git_stash_drop(params.path, params.stash_ref).await?;
Ok(Json(result))
}
@@ -342,16 +315,14 @@ pub async fn git_stash_clear(
pub async fn git_stash_show(
Json(params): Json<PathStashRefParams>,
) -> Result<Json<Vec<folder_commands::GitStatusEntry>>, AppCommandError> {
let result =
folder_commands::git_stash_show(params.path, params.stash_ref).await?;
let result = folder_commands::git_stash_show(params.path, params.stash_ref).await?;
Ok(Json(result))
}
pub async fn git_is_tracked(
Json(params): Json<PathFileParams>,
) -> Result<Json<bool>, AppCommandError> {
let result =
folder_commands::git_is_tracked(params.path, params.file).await?;
let result = folder_commands::git_is_tracked(params.path, params.file).await?;
Ok(Json(result))
}
@@ -366,12 +337,8 @@ pub struct GitDiffWithBranchParams {
pub async fn git_diff_with_branch(
Json(params): Json<GitDiffWithBranchParams>,
) -> Result<Json<String>, AppCommandError> {
let result = folder_commands::git_diff_with_branch(
params.path,
params.branch,
params.file,
)
.await?;
let result =
folder_commands::git_diff_with_branch(params.path, params.branch, params.file).await?;
Ok(Json(result))
}
@@ -386,9 +353,7 @@ pub struct GitShowDiffParams {
pub async fn git_show_diff(
Json(params): Json<GitShowDiffParams>,
) -> Result<Json<String>, AppCommandError> {
let result =
folder_commands::git_show_diff(params.path, params.commit, params.file)
.await?;
let result = folder_commands::git_show_diff(params.path, params.commit, params.file).await?;
Ok(Json(result))
}
@@ -437,16 +402,14 @@ pub async fn git_set_remote_url(
pub async fn git_merge(
Json(params): Json<PathBranchParams>,
) -> Result<Json<folder_commands::GitMergeResult>, AppCommandError> {
let result =
folder_commands::git_merge(params.path, params.branch_name).await?;
let result = folder_commands::git_merge(params.path, params.branch_name).await?;
Ok(Json(result))
}
pub async fn git_rebase(
Json(params): Json<PathBranchParams>,
) -> Result<Json<folder_commands::GitRebaseResult>, AppCommandError> {
let result =
folder_commands::git_rebase(params.path, params.branch_name).await?;
let result = folder_commands::git_rebase(params.path, params.branch_name).await?;
Ok(Json(result))
}
@@ -462,8 +425,7 @@ pub async fn git_delete_branch(
Json(params): Json<GitDeleteBranchParams>,
) -> Result<Json<String>, AppCommandError> {
let result =
folder_commands::git_delete_branch(params.path, params.branch_name, params.force)
.await?;
folder_commands::git_delete_branch(params.path, params.branch_name, params.force).await?;
Ok(Json(result))
}
@@ -503,16 +465,14 @@ pub async fn git_list_conflicts(
pub async fn git_conflict_file_versions(
Json(params): Json<PathFileParams>,
) -> Result<Json<folder_commands::GitConflictFileVersions>, AppCommandError> {
let result =
folder_commands::git_conflict_file_versions(params.path, params.file).await?;
let result = folder_commands::git_conflict_file_versions(params.path, params.file).await?;
Ok(Json(result))
}
pub async fn git_resolve_conflict(
Json(params): Json<PathFileContentParams>,
) -> Result<Json<()>, AppCommandError> {
folder_commands::git_resolve_conflict(params.path, params.file, params.content)
.await?;
folder_commands::git_resolve_conflict(params.path, params.file, params.content).await?;
Ok(Json(()))
}
+11 -23
View File
@@ -71,8 +71,7 @@ pub async fn mcp_scan_local() -> Result<Json<Vec<LocalMcpServer>>, AppCommandErr
Ok(Json(result))
}
pub async fn mcp_list_marketplaces(
) -> Result<Json<Vec<McpMarketplaceProvider>>, AppCommandError> {
pub async fn mcp_list_marketplaces() -> Result<Json<Vec<McpMarketplaceProvider>>, AppCommandError> {
let result = mcp_commands::mcp_list_marketplaces().await?;
Ok(Json(result))
}
@@ -80,23 +79,18 @@ pub async fn mcp_list_marketplaces(
pub async fn mcp_search_marketplace(
Json(params): Json<SearchMarketplaceParams>,
) -> Result<Json<Vec<McpMarketplaceItem>>, AppCommandError> {
let result = mcp_commands::mcp_search_marketplace(
params.provider_id,
params.query,
params.limit,
)
.await?;
let result =
mcp_commands::mcp_search_marketplace(params.provider_id, params.query, params.limit)
.await?;
Ok(Json(result))
}
pub async fn mcp_get_marketplace_server_detail(
Json(params): Json<GetMarketplaceServerDetailParams>,
) -> Result<Json<McpMarketplaceServerDetail>, AppCommandError> {
let result = mcp_commands::mcp_get_marketplace_server_detail(
params.provider_id,
params.server_id,
)
.await?;
let result =
mcp_commands::mcp_get_marketplace_server_detail(params.provider_id, params.server_id)
.await?;
Ok(Json(result))
}
@@ -119,27 +113,21 @@ pub async fn mcp_install_from_marketplace(
pub async fn mcp_upsert_local_server(
Json(params): Json<UpsertLocalServerParams>,
) -> Result<Json<LocalMcpServer>, AppCommandError> {
let result = mcp_commands::mcp_upsert_local_server(
params.server_id,
params.spec,
params.apps,
)
.await?;
let result =
mcp_commands::mcp_upsert_local_server(params.server_id, params.spec, params.apps).await?;
Ok(Json(result))
}
pub async fn mcp_set_server_apps(
Json(params): Json<SetServerAppsParams>,
) -> Result<Json<Option<LocalMcpServer>>, AppCommandError> {
let result =
mcp_commands::mcp_set_server_apps(params.server_id, params.apps).await?;
let result = mcp_commands::mcp_set_server_apps(params.server_id, params.apps).await?;
Ok(Json(result))
}
pub async fn mcp_remove_server(
Json(params): Json<RemoveServerParams>,
) -> Result<Json<bool>, AppCommandError> {
let result =
mcp_commands::mcp_remove_server(params.server_id, params.apps).await?;
let result = mcp_commands::mcp_remove_server(params.server_id, params.apps).await?;
Ok(Json(result))
}
+7 -16
View File
@@ -44,8 +44,7 @@ pub async fn get_system_language_settings(
Extension(state): Extension<Arc<AppState>>,
) -> Result<Json<SystemLanguageSettings>, AppCommandError> {
let db = &state.db;
let settings =
settings_commands::load_system_language_settings(&db.conn).await?;
let settings = settings_commands::load_system_language_settings(&db.conn).await?;
Ok(Json(settings))
}
@@ -67,13 +66,9 @@ pub async fn update_system_proxy_settings(
.with_detail(e.to_string())
})?;
app_metadata_service::upsert_value(
&db.conn,
SYSTEM_PROXY_SETTINGS_KEY,
&serialized,
)
.await
.map_err(AppCommandError::from)?;
app_metadata_service::upsert_value(&db.conn, SYSTEM_PROXY_SETTINGS_KEY, &serialized)
.await
.map_err(AppCommandError::from)?;
proxy::apply_system_proxy_settings(&settings)?;
Ok(Json(settings))
@@ -91,13 +86,9 @@ pub async fn update_system_language_settings(
.with_detail(e.to_string())
})?;
app_metadata_service::upsert_value(
&db.conn,
SYSTEM_LANGUAGE_SETTINGS_KEY,
&serialized,
)
.await
.map_err(AppCommandError::from)?;
app_metadata_service::upsert_value(&db.conn, SYSTEM_LANGUAGE_SETTINGS_KEY, &serialized)
.await
.map_err(AppCommandError::from)?;
crate::web::event_bridge::emit_event(
&state.emitter,
@@ -125,10 +125,8 @@ pub async fn get_github_accounts(
let settings = match raw {
Some(raw) => serde_json::from_str::<GitHubAccountsSettings>(&raw).map_err(|e| {
AppCommandError::configuration_invalid(
"Failed to parse stored GitHub accounts",
)
.with_detail(e.to_string())
AppCommandError::configuration_invalid("Failed to parse stored GitHub accounts")
.with_detail(e.to_string())
})?,
None => GitHubAccountsSettings::default(),
};
@@ -160,8 +158,7 @@ pub async fn update_github_accounts(
pub async fn validate_github_token(
Json(params): Json<ValidateGitHubTokenParams>,
) -> Result<Json<GitHubTokenValidation>, AppCommandError> {
let result =
vc_commands::validate_github_token(params.server_url, params.token).await?;
let result = vc_commands::validate_github_token(params.server_url, params.token).await?;
Ok(Json(result))
}
+38 -22
View File
@@ -121,11 +121,10 @@ async fn persist_web_service_config(
})
.await
.map_err(|e: TransactionError<AppCommandError>| match e {
TransactionError::Connection(db) => AppCommandError::new(
AppErrorCode::DatabaseError,
"Database transaction failed",
)
.with_detail(db.to_string()),
TransactionError::Connection(db) => {
AppCommandError::new(AppErrorCode::DatabaseError, "Database transaction failed")
.with_detail(db.to_string())
}
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 {
let web = dir.join("web");
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;
}
// Fallback: files at resource root.
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();
}
}
@@ -197,7 +202,10 @@ pub(crate) fn find_static_dir_fallback() -> PathBuf {
let project_out = manifest_dir.parent().map(|p| p.join("out"));
if let Some(ref out) = project_out {
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();
}
}
@@ -217,7 +225,10 @@ pub fn find_static_dir_standalone(explicit: Option<&str>) -> PathBuf {
if let Some(dir) = explicit {
let p = PathBuf::from(dir);
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;
}
}
@@ -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 token = resolve_web_service_token(&app_state.db.conn, token).await?;
let addr: SocketAddr = format!("{}:{}", host, port)
.parse()
.map_err(|e: std::net::AddrParseError| {
AppCommandError::new(AppErrorCode::InvalidInput, ERR_INVALID_ADDRESS)
.with_detail(e.to_string())
})?;
let addr: SocketAddr =
format!("{}:{}", host, port)
.parse()
.map_err(|e: std::net::AddrParseError| {
AppCommandError::new(AppErrorCode::InvalidInput, ERR_INVALID_ADDRESS)
.with_detail(e.to_string())
})?;
let listener = tokio::net::TcpListener::bind(addr)
.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 token = resolve_web_service_token(&db.conn, token).await?;
let addr: SocketAddr = format!("{}:{}", host_val, port_val)
.parse()
.map_err(|e: std::net::AddrParseError| {
AppCommandError::new(AppErrorCode::InvalidInput, ERR_INVALID_ADDRESS)
.with_detail(e.to_string())
})?;
let addr: SocketAddr =
format!("{}:{}", host_val, port_val)
.parse()
.map_err(|e: std::net::AddrParseError| {
AppCommandError::new(AppErrorCode::InvalidInput, ERR_INVALID_ADDRESS)
.with_detail(e.to_string())
})?;
let listener = tokio::net::TcpListener::bind(addr)
.await
@@ -410,7 +423,10 @@ pub async fn start_web_server(
},
connection_manager: (*app.state::<crate::acp::manager::ConnectionManager>()).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()),
data_dir: app.path().app_data_dir().unwrap_or_default(),
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",
post(handlers::folders::remove_folder_from_workspace),
)
.route(
"/reorder_folders",
post(handlers::folders::reorder_folders),
)
.route("/reorder_folders", post(handlers::folders::reorder_folders))
.route(
"/add_folder_to_history",
post(handlers::folders::add_folder_to_history),
+1 -1
View File
@@ -1,10 +1,10 @@
use std::sync::Arc;
use axum::extract::ws::{Message, WebSocket};
use axum::{
extract::{Extension, WebSocketUpgrade},
response::IntoResponse,
};
use axum::extract::ws::{Message, WebSocket};
use crate::app_state::AppState;
+15 -13
View File
@@ -321,7 +321,6 @@ impl WatchEventBatch {
"modify".to_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() {
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)?;
}
}
@@ -899,15 +900,13 @@ pub async fn start_workspace_state_stream_core(
let mut watcher = Some(
notify::recommended_watcher(
move |result: Result<notify::Event, notify::Error>| match result {
Ok(event) => {
match event_tx.try_send(event) {
Ok(()) => {}
Err(TrySendError::Full(_)) => {
dropped_events_for_callback.store(true, Ordering::Release);
}
Err(TrySendError::Closed(_)) => {}
Ok(event) => match event_tx.try_send(event) {
Ok(()) => {}
Err(TrySendError::Full(_)) => {
dropped_events_for_callback.store(true, Ordering::Release);
}
}
Err(TrySendError::Closed(_)) => {}
},
Err(err) => {
eprintln!(
"[workspace-state-watch] failed event for {}: {}",
@@ -1082,7 +1081,8 @@ mod tests {
#[test]
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(
"meta".to_string(),
@@ -1107,7 +1107,8 @@ mod tests {
#[test]
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(
"meta".to_string(),
@@ -1136,7 +1137,8 @@ mod tests {
#[test]
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.append_event(