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