Release v0.90.0

This commit is contained in:
coso
2026-03-18 10:39:02 +08:00
parent 157e400c97
commit 8c2cf9f97b
225 changed files with 11935 additions and 7890 deletions
+103 -372
View File
@@ -1,14 +1,13 @@
use chrono::Utc;
use lime_agent::TauriAgentEvent;
use lime_core::database::dao::agent_timeline::{
AgentRequestOption, AgentRequestQuestion, AgentThreadItem, AgentThreadItemPayload,
AgentThreadItemStatus, AgentThreadTurn, AgentThreadTurnStatus, AgentTimelineDao,
AgentThreadItem, AgentThreadItemPayload, AgentThreadItemStatus, AgentThreadTurn,
AgentThreadTurnStatus, AgentTimelineDao,
};
use lime_core::database::{lock_db, DbConnection};
use serde_json::{json, Value};
use std::collections::HashMap;
use tauri::{AppHandle, Emitter};
use uuid::Uuid;
const PROPOSED_PLAN_OPEN: &str = "<proposed_plan>";
const PROPOSED_PLAN_CLOSE: &str = "</proposed_plan>";
@@ -38,52 +37,10 @@ fn emit_event(app: &AppHandle, event_name: &str, event: &TauriAgentEvent) {
}
}
fn normalize_tool_name(name: &str) -> String {
name.replace([' ', '-', '_'], "").to_lowercase()
}
fn parse_json_str(raw: Option<&str>) -> Option<Value> {
let value = raw?.trim();
if value.is_empty() {
return None;
}
serde_json::from_str::<Value>(value).ok()
}
fn as_object(value: &Value) -> Option<&serde_json::Map<String, Value>> {
value.as_object()
}
fn pick_string_from_object(
object: Option<&serde_json::Map<String, Value>>,
keys: &[&str],
) -> Option<String> {
let object = object?;
for key in keys {
if let Some(value) = object.get(*key).and_then(Value::as_str) {
let trimmed = value.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
None
}
fn extract_tool_query(arguments: Option<&Value>) -> Option<String> {
pick_string_from_object(
arguments.and_then(as_object),
&["q", "query", "question", "search", "search_query", "url"],
)
}
fn extract_command_text(arguments: Option<&Value>) -> Option<String> {
pick_string_from_object(
arguments.and_then(as_object),
&["cmd", "command", "script", "text"],
)
}
#[derive(Debug, Clone)]
struct ExtractedFileArtifact {
path: String,
@@ -220,84 +177,6 @@ fn extract_proposed_plan_block(text: &str) -> Option<String> {
}
}
fn is_command_tool(name: &str) -> bool {
matches!(
normalize_tool_name(name).as_str(),
"bash" | "execcommand" | "terminal" | "shell" | "runcommand"
)
}
fn is_web_tool(name: &str) -> bool {
let normalized = normalize_tool_name(name);
normalized.contains("websearch")
|| normalized.contains("searchquery")
|| normalized.contains("webfetch")
|| normalized.contains("browser")
|| normalized.contains("playwright")
|| normalized == "search"
}
fn is_user_input_action(action_type: &str) -> bool {
matches!(action_type, "ask_user" | "elicitation")
}
fn map_questions(raw: Option<&Value>) -> Option<Vec<AgentRequestQuestion>> {
let items = raw?.as_array()?;
let mut questions = Vec::new();
for item in items {
let Some(object) = item.as_object() else {
continue;
};
let Some(question) = object.get("question").and_then(Value::as_str) else {
continue;
};
let options = object
.get("options")
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.filter_map(|value| {
let object = value.as_object()?;
let label = object.get("label")?.as_str()?.trim().to_string();
if label.is_empty() {
return None;
}
Some(AgentRequestOption {
label,
description: object
.get("description")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
})
})
.collect::<Vec<_>>()
});
questions.push(AgentRequestQuestion {
question: question.trim().to_string(),
header: object
.get("header")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
options: options.filter(|values| !values.is_empty()),
multi_select: object.get("multi_select").and_then(Value::as_bool),
});
}
if questions.is_empty() {
None
} else {
Some(questions)
}
}
#[derive(Debug)]
pub struct AgentTimelineRecorder {
db: DbConnection,
@@ -307,8 +186,6 @@ pub struct AgentTimelineRecorder {
sequence_counter: i64,
item_sequences: HashMap<String, i64>,
item_statuses: HashMap<String, AgentThreadItemStatus>,
assistant_text: String,
reasoning_text: String,
plan_text: Option<String>,
turn_summary_text: Option<String>,
}
@@ -317,13 +194,15 @@ impl AgentTimelineRecorder {
pub fn create(
db: DbConnection,
thread_id: impl Into<String>,
turn_id: impl Into<String>,
prompt_text: impl Into<String>,
) -> Result<Self, String> {
let thread_id = thread_id.into();
let turn_id = turn_id.into();
let prompt_text = prompt_text.into();
let now = Utc::now().to_rfc3339();
let turn = AgentThreadTurn {
id: Uuid::new_v4().to_string(),
id: turn_id.clone(),
thread_id: thread_id.clone(),
prompt_text,
status: AgentThreadTurnStatus::Running,
@@ -343,13 +222,11 @@ impl AgentTimelineRecorder {
Ok(Self {
db,
thread_id,
turn_id: turn.id.clone(),
turn_id,
turn,
sequence_counter: 0,
item_sequences: HashMap::new(),
item_statuses: HashMap::new(),
assistant_text: String::new(),
reasoning_text: String::new(),
plan_text: None,
turn_summary_text: None,
})
@@ -363,80 +240,50 @@ impl AgentTimelineRecorder {
&self.turn_id
}
pub fn emit_start(&mut self, app: &AppHandle, event_name: &str) -> Result<(), String> {
emit_event(
app,
event_name,
&TauriAgentEvent::ThreadStarted {
thread_id: self.thread_id.clone(),
},
);
emit_event(
app,
event_name,
&TauriAgentEvent::TurnStarted {
turn: self.turn.clone(),
},
);
let user_item = self.build_item(
format!("user:{}", self.turn_id),
AgentThreadItemStatus::Completed,
Some(self.turn.started_at.clone()),
AgentThreadItemPayload::UserMessage {
content: self.turn.prompt_text.clone(),
},
);
self.persist_and_emit_item(app, event_name, user_item)?;
Ok(())
}
pub fn record_legacy_event(
pub fn record_runtime_event(
&mut self,
app: &AppHandle,
event_name: &str,
event: &TauriAgentEvent,
workspace_root: &str,
_workspace_root: &str,
) -> Result<(), String> {
match event {
TauriAgentEvent::TextDelta { text } => {
self.assistant_text.push_str(text);
let item = self.build_item(
format!("assistant:{}", self.turn_id),
AgentThreadItemStatus::InProgress,
None,
AgentThreadItemPayload::AgentMessage {
text: self.assistant_text.clone(),
phase: None,
},
);
self.persist_and_emit_item(app, event_name, item)?;
TauriAgentEvent::ThreadStarted { .. } => {}
TauriAgentEvent::TurnStarted { turn } => {
self.thread_id = turn.thread_id.clone();
self.turn_id = turn.id.clone();
self.turn = turn.clone();
if let Some(plan_text) = extract_proposed_plan_block(&self.assistant_text) {
if self.plan_text.as_deref() != Some(plan_text.as_str()) {
self.plan_text = Some(plan_text.clone());
}
let plan_item = self.build_item(
format!("plan:{}", self.turn_id),
AgentThreadItemStatus::InProgress,
None,
AgentThreadItemPayload::Plan { text: plan_text },
);
self.persist_and_emit_item(app, event_name, plan_item)?;
}
let conn = lock_db(&self.db)?;
AgentTimelineDao::upsert_turn(&conn, &self.turn)
.map_err(|e| format!("同步 turn 启动态失败: {e}"))?;
}
TauriAgentEvent::ThinkingDelta { text } => {
self.reasoning_text.push_str(text);
let item = self.build_item(
format!("reasoning:{}", self.turn_id),
AgentThreadItemStatus::InProgress,
None,
AgentThreadItemPayload::Reasoning {
text: self.reasoning_text.clone(),
summary: None,
},
);
self.persist_and_emit_item(app, event_name, item)?;
TauriAgentEvent::ItemStarted { item } => {
self.persist_runtime_item(
app,
event_name,
item.clone(),
TauriAgentEvent::ItemStarted { item: item.clone() },
)?;
self.maybe_project_plan_item(app, event_name, item)?;
}
TauriAgentEvent::ItemUpdated { item } => {
self.persist_runtime_item(
app,
event_name,
item.clone(),
TauriAgentEvent::ItemUpdated { item: item.clone() },
)?;
self.maybe_project_plan_item(app, event_name, item)?;
}
TauriAgentEvent::ItemCompleted { item } => {
self.persist_runtime_item(
app,
event_name,
item.clone(),
TauriAgentEvent::ItemCompleted { item: item.clone() },
)?;
self.maybe_project_plan_item(app, event_name, item)?;
}
TauriAgentEvent::RuntimeStatus { status } => {
let text =
@@ -452,115 +299,15 @@ impl AgentTimelineRecorder {
self.persist_and_emit_item(app, event_name, item)?;
}
}
TauriAgentEvent::ToolStart {
tool_name,
tool_id,
arguments,
} => {
let arguments_value = parse_json_str(arguments.as_deref());
let payload = if is_command_tool(tool_name) {
AgentThreadItemPayload::CommandExecution {
command: extract_command_text(arguments_value.as_ref())
.unwrap_or_else(|| tool_name.clone()),
cwd: workspace_root.to_string(),
aggregated_output: None,
exit_code: None,
error: None,
}
} else if is_web_tool(tool_name) {
AgentThreadItemPayload::WebSearch {
query: extract_tool_query(arguments_value.as_ref()),
action: Some(tool_name.clone()),
output: None,
}
} else {
AgentThreadItemPayload::ToolCall {
tool_name: tool_name.clone(),
arguments: arguments_value,
output: None,
success: None,
error: None,
metadata: None,
}
};
let item = self.build_item(
tool_id.clone(),
AgentThreadItemStatus::InProgress,
None,
payload,
);
self.persist_and_emit_item(app, event_name, item)?;
}
TauriAgentEvent::ToolEnd { tool_id, result } => {
let existing = {
let conn = lock_db(&self.db)?;
AgentTimelineDao::get_item(&conn, tool_id)
.map_err(|e| format!("读取工具 item 失败: {e}"))?
};
let metadata_value = result
.metadata
.as_ref()
.and_then(|metadata| serde_json::to_value(metadata).ok());
let status = if result.success {
AgentThreadItemStatus::Completed
} else {
AgentThreadItemStatus::Failed
};
let payload = match existing.map(|item| item.payload) {
Some(AgentThreadItemPayload::CommandExecution { command, cwd, .. }) => {
AgentThreadItemPayload::CommandExecution {
command,
cwd,
aggregated_output: Some(result.output.clone()),
exit_code: metadata_value
.as_ref()
.and_then(|value| value.get("exit_code"))
.and_then(Value::as_i64),
error: result.error.clone(),
}
}
Some(AgentThreadItemPayload::WebSearch { query, action, .. }) => {
AgentThreadItemPayload::WebSearch {
query,
action,
output: Some(result.output.clone()),
}
}
Some(AgentThreadItemPayload::ToolCall {
tool_name,
arguments,
..
}) => AgentThreadItemPayload::ToolCall {
tool_name,
arguments,
output: Some(result.output.clone()),
success: Some(result.success),
error: result.error.clone(),
metadata: metadata_value.clone(),
},
_ => AgentThreadItemPayload::ToolCall {
tool_name: tool_id.clone(),
arguments: None,
output: Some(result.output.clone()),
success: Some(result.success),
error: result.error.clone(),
metadata: metadata_value.clone(),
},
};
let item = self.build_item(
tool_id.clone(),
status.clone(),
Some(Utc::now().to_rfc3339()),
payload,
);
self.persist_and_emit_item(app, event_name, item)?;
for artifact in extract_file_artifacts(None, metadata_value.as_ref()) {
let artifact_path = artifact.path.clone();
let status = resolve_artifact_item_status(metadata_value.as_ref());
let file_item = self.build_item(
artifact
.artifact_id
@@ -606,53 +353,10 @@ impl AgentTimelineRecorder {
self.persist_and_emit_item(app, event_name, item)?;
}
TauriAgentEvent::ActionRequired {
request_id,
action_type,
data,
} => {
let payload = if is_user_input_action(action_type) {
AgentThreadItemPayload::RequestUserInput {
request_id: request_id.clone(),
action_type: action_type.clone(),
prompt: data
.get("prompt")
.or_else(|| data.get("message"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
questions: map_questions(data.get("questions")),
response: None,
}
} else {
AgentThreadItemPayload::ApprovalRequest {
request_id: request_id.clone(),
action_type: action_type.clone(),
prompt: data
.get("prompt")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
tool_name: data
.get("tool_name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
arguments: data.get("arguments").cloned(),
response: None,
}
};
let item = self.build_item(
request_id.clone(),
AgentThreadItemStatus::InProgress,
None,
payload,
);
self.persist_and_emit_item(app, event_name, item)?;
}
request_id: _,
action_type: _,
data: _,
} => {}
TauriAgentEvent::Warning { code, message } => {
let item = self.build_item(
format!("warning:{}:{}", self.turn_id, self.sequence_counter + 1),
@@ -687,7 +391,7 @@ impl AgentTimelineRecorder {
app: &AppHandle,
event_name: &str,
) -> Result<(), String> {
self.complete_open_content_items(app, event_name, AgentThreadItemStatus::Completed)?;
self.complete_projection_items(app, event_name, AgentThreadItemStatus::Completed)?;
let now = Utc::now().to_rfc3339();
self.turn.status = AgentThreadTurnStatus::Completed;
self.turn.completed_at = Some(now.clone());
@@ -721,7 +425,7 @@ impl AgentTimelineRecorder {
event_name: &str,
message: &str,
) -> Result<(), String> {
self.complete_open_content_items(app, event_name, AgentThreadItemStatus::Completed)?;
self.complete_projection_items(app, event_name, AgentThreadItemStatus::Completed)?;
let error_item = self.build_item(
format!("error:{}", self.turn_id),
AgentThreadItemStatus::Failed,
@@ -760,38 +464,12 @@ impl AgentTimelineRecorder {
Ok(())
}
fn complete_open_content_items(
fn complete_projection_items(
&mut self,
app: &AppHandle,
event_name: &str,
status: AgentThreadItemStatus,
) -> Result<(), String> {
if !self.assistant_text.is_empty() {
let item = self.build_item(
format!("assistant:{}", self.turn_id),
status.clone(),
Some(Utc::now().to_rfc3339()),
AgentThreadItemPayload::AgentMessage {
text: self.assistant_text.clone(),
phase: None,
},
);
self.persist_and_emit_item(app, event_name, item)?;
}
if !self.reasoning_text.is_empty() {
let item = self.build_item(
format!("reasoning:{}", self.turn_id),
status.clone(),
Some(Utc::now().to_rfc3339()),
AgentThreadItemPayload::Reasoning {
text: self.reasoning_text.clone(),
summary: None,
},
);
self.persist_and_emit_item(app, event_name, item)?;
}
if let Some(plan_text) = self.plan_text.clone() {
let item = self.build_item(
format!("plan:{}", self.turn_id),
@@ -888,6 +566,59 @@ impl AgentTimelineRecorder {
emit_event(app, event_name, &event);
Ok(())
}
fn persist_runtime_item(
&mut self,
app: &AppHandle,
event_name: &str,
item: AgentThreadItem,
event: TauriAgentEvent,
) -> Result<(), String> {
self.sync_runtime_item_state(&item);
{
let conn = lock_db(&self.db)?;
AgentTimelineDao::upsert_item(&conn, &item)
.map_err(|e| format!("保存 runtime item 失败: {e}"))?;
}
emit_event(app, event_name, &event);
Ok(())
}
fn sync_runtime_item_state(&mut self, item: &AgentThreadItem) {
self.thread_id = item.thread_id.clone();
self.turn_id = item.turn_id.clone();
self.sequence_counter = self.sequence_counter.max(item.sequence);
self.item_sequences.insert(item.id.clone(), item.sequence);
self.item_statuses
.insert(item.id.clone(), item.status.clone());
if let AgentThreadItemPayload::AgentMessage { text, .. } = &item.payload {
self.plan_text = extract_proposed_plan_block(text);
}
}
fn maybe_project_plan_item(
&mut self,
app: &AppHandle,
event_name: &str,
item: &AgentThreadItem,
) -> Result<(), String> {
let AgentThreadItemPayload::AgentMessage { text, .. } = &item.payload else {
return Ok(());
};
let Some(plan_text) = extract_proposed_plan_block(text) else {
return Ok(());
};
self.plan_text = Some(plan_text.clone());
let plan_item = self.build_item(
format!("plan:{}", self.turn_id),
item.status.clone(),
item.completed_at.clone(),
AgentThreadItemPayload::Plan { text: plan_text },
);
self.persist_and_emit_item(app, event_name, plan_item)?;
Ok(())
}
}
pub fn complete_action_item(
+40 -155
View File
@@ -1,6 +1,7 @@
use crate::database::dao::agent::{AgentDao, AgentModelPatternMatch};
use crate::database::load_pending_general_messages;
use chrono::{Local, TimeZone};
use rusqlite::{params, Connection};
use rusqlite::Connection;
use std::collections::HashSet;
const GENERAL_MODE_PATTERN: &str = "general:%";
@@ -96,49 +97,26 @@ fn load_unified_general_candidates(
let from_datetime = from_timestamp.map(format_sqlite_datetime);
let to_datetime = to_timestamp.map(format_sqlite_datetime);
let mut stmt = conn
.prepare(
"SELECT m.session_id, m.role, m.content_json, m.timestamp
FROM agent_messages m
JOIN agent_sessions s ON s.id = m.session_id
WHERE s.model LIKE ?1
AND (?2 IS NULL OR datetime(m.timestamp) >= datetime(?2))
AND (?3 IS NULL OR datetime(m.timestamp) <= datetime(?3))
ORDER BY datetime(m.timestamp) DESC
LIMIT ?4",
)
.map_err(|e| format!("查询 unified general agent_messages 失败: {e}"))?;
let rows = AgentDao::list_message_text_rows_by_model_pattern(
conn,
GENERAL_MODE_PATTERN,
AgentModelPatternMatch::Like,
from_datetime.as_deref(),
to_datetime.as_deref(),
limit,
)
.map_err(|e| format!("读取 unified general agent_messages 失败: {e}"))?;
let rows = stmt
.query_map(
params![
GENERAL_MODE_PATTERN,
from_datetime,
to_datetime,
limit as i64
],
|row| {
let session_id: String = row.get(0)?;
let role: String = row.get(1)?;
let content_json: String = row.get(2)?;
let timestamp: String = row.get(3)?;
Ok((session_id, role, content_json, timestamp))
},
)
.map_err(|e| format!("读取 unified general agent_messages 失败: {e}"))?;
for row in rows.flatten() {
if let Some(timestamp_ms) = parse_rfc3339_to_timestamp(&row.3) {
push_candidate(
candidates,
seen,
row.0,
row.1,
extract_text_from_content_json(&row.2),
timestamp_ms,
min_message_length,
);
}
for row in rows {
push_candidate(
candidates,
seen,
row.session_id,
row.role,
row.content,
row.timestamp_ms,
min_message_length,
);
}
Ok(())
@@ -156,49 +134,26 @@ fn load_non_general_agent_candidates(
let from_datetime = from_timestamp.map(format_sqlite_datetime);
let to_datetime = to_timestamp.map(format_sqlite_datetime);
let mut stmt = conn
.prepare(
"SELECT m.session_id, m.role, m.content_json, m.timestamp
FROM agent_messages m
JOIN agent_sessions s ON s.id = m.session_id
WHERE s.model NOT LIKE ?1
AND (?2 IS NULL OR datetime(m.timestamp) >= datetime(?2))
AND (?3 IS NULL OR datetime(m.timestamp) <= datetime(?3))
ORDER BY datetime(m.timestamp) DESC
LIMIT ?4",
)
.map_err(|e| format!("查询非通用 agent_messages 失败: {e}"))?;
let rows = AgentDao::list_message_text_rows_by_model_pattern(
conn,
GENERAL_MODE_PATTERN,
AgentModelPatternMatch::NotLike,
from_datetime.as_deref(),
to_datetime.as_deref(),
limit,
)
.map_err(|e| format!("读取非通用 agent_messages 失败: {e}"))?;
let rows = stmt
.query_map(
params![
GENERAL_MODE_PATTERN,
from_datetime,
to_datetime,
limit as i64
],
|row| {
let session_id: String = row.get(0)?;
let role: String = row.get(1)?;
let content_json: String = row.get(2)?;
let timestamp: String = row.get(3)?;
Ok((session_id, role, content_json, timestamp))
},
)
.map_err(|e| format!("读取非通用 agent_messages 失败: {e}"))?;
for row in rows.flatten() {
if let Some(timestamp_ms) = parse_rfc3339_to_timestamp(&row.3) {
push_candidate(
candidates,
seen,
row.0,
row.1,
extract_text_from_content_json(&row.2),
timestamp_ms,
min_message_length,
);
}
for row in rows {
push_candidate(
candidates,
seen,
row.session_id,
row.role,
row.content,
row.timestamp_ms,
min_message_length,
);
}
Ok(())
@@ -269,76 +224,6 @@ fn format_sqlite_datetime(timestamp_ms: i64) -> String {
.unwrap_or_else(|| Local::now().format("%Y-%m-%d %H:%M:%S").to_string())
}
fn parse_rfc3339_to_timestamp(value: &str) -> Option<i64> {
chrono::DateTime::parse_from_rfc3339(value)
.ok()
.map(|dt| dt.timestamp_millis())
.or_else(|| parse_datetime_or_timestamp_to_millis(value))
}
fn parse_datetime_or_timestamp_to_millis(value: &str) -> Option<i64> {
if let Ok(v) = value.parse::<i64>() {
if v > 1_000_000_000_000 {
return Some(v);
}
return Some(v * 1000);
}
chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S")
.ok()
.and_then(|naive| {
Local
.from_local_datetime(&naive)
.single()
.map(|dt| dt.timestamp_millis())
})
}
fn extract_text_from_content_json(content_json: &str) -> String {
if let Ok(text) = serde_json::from_str::<String>(content_json) {
return text;
}
if let Ok(value) = serde_json::from_str::<serde_json::Value>(content_json) {
match value {
serde_json::Value::Array(items) => {
let texts = items
.iter()
.filter_map(extract_text_from_json_item)
.collect::<Vec<_>>();
if !texts.is_empty() {
return texts.join(" ");
}
}
serde_json::Value::Object(_) => {
if let Some(text) = extract_text_from_json_item(&value) {
return text;
}
}
_ => {}
}
}
content_json.to_string()
}
fn extract_text_from_json_item(value: &serde_json::Value) -> Option<String> {
if let Some(text) = value.get("Text").and_then(|v| v.as_str()) {
return Some(text.to_string());
}
if value.get("type").and_then(|v| v.as_str()) == Some("text") {
if let Some(text) = value.get("text").and_then(|v| v.as_str()) {
return Some(text.to_string());
}
}
value
.get("text")
.and_then(|v| v.as_str())
.map(|v| v.to_string())
}
#[cfg(test)]
mod tests {
use super::load_memory_source_candidates;
@@ -2,12 +2,14 @@
//!
//! 从数据库查询真实的对话和使用统计数据
use crate::database::dao::agent::{AgentDao, AgentModelPatternMatch};
use crate::database::dao::orchestrator::OrchestratorDao;
use crate::database::{
count_pending_general_messages, count_pending_general_sessions,
sum_pending_general_message_chars,
};
use chrono::{DateTime, Datelike, Duration, Local, TimeZone, Timelike};
use rusqlite::{params, Connection};
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
const GENERAL_MODE_PATTERN: &str = "general:%";
@@ -210,17 +212,14 @@ fn query_general_session_count(
let from_text = from_timestamp_ms.map(format_sqlite_datetime);
let to_text = to_timestamp_ms.map(format_sqlite_datetime);
let unified_count: i64 = conn
.query_row(
"SELECT COUNT(*)
FROM agent_sessions s
WHERE s.model LIKE ?1
AND (?2 IS NULL OR datetime(s.created_at) >= datetime(?2))
AND (?3 IS NULL OR datetime(s.created_at) < datetime(?3))",
params![GENERAL_MODE_PATTERN, from_text, to_text],
|row| row.get(0),
)
.map_err(|e| format!("查询 unified general 会话数失败: {e}"))?;
let unified_count = AgentDao::count_sessions_by_model_pattern(
conn,
GENERAL_MODE_PATTERN,
AgentModelPatternMatch::Like,
from_text.as_deref(),
to_text.as_deref(),
)
.map_err(|e| format!("查询 unified general 会话数失败: {e}"))?;
let pending_count = count_pending_general_sessions(conn, from_timestamp_ms, to_timestamp_ms)
.map_err(|e| format!("查询待迁移 general 会话数失败: {e}"))?;
@@ -236,18 +235,14 @@ fn query_general_message_count(
let from_text = from_timestamp_ms.map(format_sqlite_datetime);
let to_text = to_timestamp_ms.map(format_sqlite_datetime);
let unified_count: i64 = conn
.query_row(
"SELECT COUNT(*)
FROM agent_messages m
JOIN agent_sessions s ON s.id = m.session_id
WHERE s.model LIKE ?1
AND (?2 IS NULL OR datetime(m.timestamp) >= datetime(?2))
AND (?3 IS NULL OR datetime(m.timestamp) < datetime(?3))",
params![GENERAL_MODE_PATTERN, from_text, to_text],
|row| row.get(0),
)
.map_err(|e| format!("查询 unified general 消息数失败: {e}"))?;
let unified_count = AgentDao::count_messages_by_model_pattern(
conn,
GENERAL_MODE_PATTERN,
AgentModelPatternMatch::Like,
from_text.as_deref(),
to_text.as_deref(),
)
.map_err(|e| format!("查询 unified general 消息数失败: {e}"))?;
let pending_count = count_pending_general_messages(conn, from_timestamp_ms, to_timestamp_ms)
.map_err(|e| format!("查询待迁移 general 消息数失败: {e}"))?;
@@ -263,18 +258,14 @@ fn sum_general_message_chars(
let from_text = from_timestamp_ms.map(format_sqlite_datetime);
let to_text = to_timestamp_ms.map(format_sqlite_datetime);
let unified_chars: i64 = conn
.query_row(
"SELECT COALESCE(SUM(LENGTH(m.content_json)), 0)
FROM agent_messages m
JOIN agent_sessions s ON s.id = m.session_id
WHERE s.model LIKE ?1
AND (?2 IS NULL OR datetime(m.timestamp) >= datetime(?2))
AND (?3 IS NULL OR datetime(m.timestamp) < datetime(?3))",
params![GENERAL_MODE_PATTERN, from_text, to_text],
|row| row.get(0),
)
.map_err(|e| format!("估算 unified general Token 失败: {e}"))?;
let unified_chars = AgentDao::sum_message_chars_by_model_pattern(
conn,
GENERAL_MODE_PATTERN,
AgentModelPatternMatch::Like,
from_text.as_deref(),
to_text.as_deref(),
)
.map_err(|e| format!("估算 unified general Token 失败: {e}"))?;
let pending_chars = sum_pending_general_message_chars(conn, from_timestamp_ms, to_timestamp_ms)
.map_err(|e| format!("估算待迁移 general Token 失败: {e}"))?;
@@ -290,14 +281,12 @@ fn query_non_general_session_count(
let from_text = from_timestamp_ms.map(format_sqlite_datetime);
let to_text = to_timestamp_ms.map(format_sqlite_datetime);
conn.query_row(
"SELECT COUNT(*)
FROM agent_sessions s
WHERE s.model NOT LIKE ?1
AND (?2 IS NULL OR datetime(s.created_at) >= datetime(?2))
AND (?3 IS NULL OR datetime(s.created_at) < datetime(?3))",
params![GENERAL_MODE_PATTERN, from_text, to_text],
|row| row.get(0),
AgentDao::count_sessions_by_model_pattern(
conn,
GENERAL_MODE_PATTERN,
AgentModelPatternMatch::NotLike,
from_text.as_deref(),
to_text.as_deref(),
)
.map_err(|e| format!("查询非通用 unified 会话数失败: {e}"))
}
@@ -310,15 +299,12 @@ fn query_non_general_message_count(
let from_text = from_timestamp_ms.map(format_sqlite_datetime);
let to_text = to_timestamp_ms.map(format_sqlite_datetime);
conn.query_row(
"SELECT COUNT(*)
FROM agent_messages m
JOIN agent_sessions s ON s.id = m.session_id
WHERE s.model NOT LIKE ?1
AND (?2 IS NULL OR datetime(m.timestamp) >= datetime(?2))
AND (?3 IS NULL OR datetime(m.timestamp) < datetime(?3))",
params![GENERAL_MODE_PATTERN, from_text, to_text],
|row| row.get(0),
AgentDao::count_messages_by_model_pattern(
conn,
GENERAL_MODE_PATTERN,
AgentModelPatternMatch::NotLike,
from_text.as_deref(),
to_text.as_deref(),
)
.map_err(|e| format!("查询非通用 unified 消息数失败: {e}"))
}
@@ -331,15 +317,12 @@ fn sum_non_general_message_chars(
let from_text = from_timestamp_ms.map(format_sqlite_datetime);
let to_text = to_timestamp_ms.map(format_sqlite_datetime);
conn.query_row(
"SELECT COALESCE(SUM(LENGTH(m.content_json)), 0)
FROM agent_messages m
JOIN agent_sessions s ON s.id = m.session_id
WHERE s.model NOT LIKE ?1
AND (?2 IS NULL OR datetime(m.timestamp) >= datetime(?2))
AND (?3 IS NULL OR datetime(m.timestamp) < datetime(?3))",
params![GENERAL_MODE_PATTERN, from_text, to_text],
|row| row.get(0),
AgentDao::sum_message_chars_by_model_pattern(
conn,
GENERAL_MODE_PATTERN,
AgentModelPatternMatch::NotLike,
from_text.as_deref(),
to_text.as_deref(),
)
.map_err(|e| format!("估算非通用 unified Token 失败: {e}"))
}
@@ -413,41 +396,22 @@ fn query_model_usage_table_tokens(
today_start: &DateTime<Local>,
month_start: &DateTime<Local>,
) -> Result<Option<TokenStats>, String> {
let row_count: i64 = conn
.query_row("SELECT COUNT(*) FROM model_usage_stats", [], |row| {
row.get(0)
})
.map_err(|e| format!("查询 model_usage_stats 行数失败: {e}"))?;
if row_count <= 0 {
if !OrchestratorDao::has_model_usage_stats(conn)
.map_err(|e| format!("查询 model_usage_stats 行数失败: {e}"))?
{
return Ok(None);
}
let today_key = today_start.format("%Y-%m-%d").to_string();
let month_key = month_start.format("%Y-%m-%d").to_string();
let total_tokens: i64 = conn
.query_row(
"SELECT COALESCE(SUM(total_tokens), 0) FROM model_usage_stats",
[],
|row| row.get(0),
)
let total_tokens = OrchestratorDao::get_total_model_usage_tokens(conn)
.map_err(|e| format!("查询总 Token 失败: {e}"))?;
let monthly_tokens: i64 = conn
.query_row(
"SELECT COALESCE(SUM(total_tokens), 0) FROM model_usage_stats WHERE date >= ?",
[month_key],
|row| row.get(0),
)
let monthly_tokens = OrchestratorDao::get_model_usage_tokens_since(conn, &month_key)
.map_err(|e| format!("查询本月 Token 失败: {e}"))?;
let today_tokens: i64 = conn
.query_row(
"SELECT COALESCE(SUM(total_tokens), 0) FROM model_usage_stats WHERE date = ?",
[today_key],
|row| row.get(0),
)
let today_tokens = OrchestratorDao::get_model_usage_tokens_on(conn, &today_key)
.map_err(|e| format!("查询今日 Token 失败: {e}"))?;
Ok(Some(TokenStats {
@@ -499,150 +463,42 @@ fn query_model_usage_from_stats_table(
conn: &Connection,
range_start: Option<DateTime<Local>>,
) -> Result<Vec<RawModelUsage>, String> {
let mut result = Vec::new();
if let Some(start) = range_start {
let start_key = start.format("%Y-%m-%d").to_string();
let mut stmt = conn
.prepare(
"SELECT model_id,
COALESCE(SUM(request_count), 0) AS conversations,
COALESCE(SUM(total_tokens), 0) AS tokens
FROM model_usage_stats
WHERE date >= ?
GROUP BY model_id
ORDER BY tokens DESC, conversations DESC
LIMIT 20",
)
.map_err(|e| format!("准备模型统计查询失败: {e}"))?;
let rows = stmt
.query_map([start_key], |row| {
let model: String = row.get(0)?;
let conversations: i64 = row.get(1)?;
let tokens: i64 = row.get(2)?;
Ok(RawModelUsage {
model,
conversations: clamp_i64_to_u64(conversations),
tokens: clamp_i64_to_u64(tokens),
})
})
.map_err(|e| format!("执行模型统计查询失败: {e}"))?;
for row in rows {
result.push(row.map_err(|e| format!("读取模型统计行失败: {e}"))?);
}
return Ok(result);
}
let mut stmt = conn
.prepare(
"SELECT model_id,
COALESCE(SUM(request_count), 0) AS conversations,
COALESCE(SUM(total_tokens), 0) AS tokens
FROM model_usage_stats
GROUP BY model_id
ORDER BY tokens DESC, conversations DESC
LIMIT 20",
)
.map_err(|e| format!("准备模型统计查询失败: {e}"))?;
let rows = stmt
.query_map([], |row| {
let model: String = row.get(0)?;
let conversations: i64 = row.get(1)?;
let tokens: i64 = row.get(2)?;
Ok(RawModelUsage {
model,
conversations: clamp_i64_to_u64(conversations),
tokens: clamp_i64_to_u64(tokens),
})
})
let start_key = range_start.map(|start| start.format("%Y-%m-%d").to_string());
let rows = OrchestratorDao::list_model_usage_aggregates(conn, start_key.as_deref(), 20)
.map_err(|e| format!("执行模型统计查询失败: {e}"))?;
for row in rows {
result.push(row.map_err(|e| format!("读取模型统计行失败: {e}"))?);
}
Ok(result)
Ok(rows
.into_iter()
.map(|row| RawModelUsage {
model: row.model_id,
conversations: clamp_i64_to_u64(row.request_count),
tokens: clamp_i64_to_u64(row.total_tokens),
})
.collect())
}
fn query_model_usage_from_agent_messages(
conn: &Connection,
range_start: Option<DateTime<Local>>,
) -> Result<Vec<RawModelUsage>, String> {
let mut result = Vec::new();
let start_str = range_start.map(|start| start.format("%Y-%m-%d %H:%M:%S").to_string());
let rows = AgentDao::list_model_usage_by_model_pattern(
conn,
GENERAL_MODE_PATTERN,
AgentModelPatternMatch::NotLike,
start_str.as_deref(),
20,
)
.map_err(|e| format!("查询 Agent 模型排行失败: {e}"))?;
if let Some(start) = range_start {
let start_str = start.format("%Y-%m-%d %H:%M:%S").to_string();
let mut stmt = conn
.prepare(
"SELECT s.model,
COUNT(DISTINCT m.session_id) AS conversations,
COALESCE(SUM(LENGTH(m.content_json)), 0) AS content_chars
FROM agent_messages m
JOIN agent_sessions s ON s.id = m.session_id
WHERE s.model NOT LIKE ?1
AND datetime(m.timestamp) >= datetime(?2)
GROUP BY s.model
ORDER BY content_chars DESC, conversations DESC
LIMIT 20",
)
.map_err(|e| format!("准备 Agent 模型排行查询失败: {e}"))?;
let rows = stmt
.query_map(params![GENERAL_MODE_PATTERN, start_str], |row| {
let model: String = row.get(0)?;
let conversations: i64 = row.get(1)?;
let chars: i64 = row.get(2)?;
Ok(RawModelUsage {
model,
conversations: clamp_i64_to_u64(conversations),
tokens: chars_to_estimated_tokens(chars),
})
})
.map_err(|e| format!("执行 Agent 模型排行查询失败: {e}"))?;
for row in rows {
result.push(row.map_err(|e| format!("读取 Agent 模型排行行失败: {e}"))?);
}
return Ok(result);
}
let mut stmt = conn
.prepare(
"SELECT s.model,
COUNT(DISTINCT m.session_id) AS conversations,
COALESCE(SUM(LENGTH(m.content_json)), 0) AS content_chars
FROM agent_messages m
JOIN agent_sessions s ON s.id = m.session_id
WHERE s.model NOT LIKE ?1
GROUP BY s.model
ORDER BY content_chars DESC, conversations DESC
LIMIT 20",
)
.map_err(|e| format!("准备 Agent 模型排行查询失败: {e}"))?;
let rows = stmt
.query_map([GENERAL_MODE_PATTERN], |row| {
let model: String = row.get(0)?;
let conversations: i64 = row.get(1)?;
let chars: i64 = row.get(2)?;
Ok(RawModelUsage {
model,
conversations: clamp_i64_to_u64(conversations),
tokens: chars_to_estimated_tokens(chars),
})
Ok(rows
.into_iter()
.map(|row| RawModelUsage {
model: row.model,
conversations: row.conversations,
tokens: chars_to_estimated_tokens(row.content_chars as i64),
})
.map_err(|e| format!("执行 Agent 模型排行查询失败: {e}"))?;
for row in rows {
result.push(row.map_err(|e| format!("读取 Agent 模型排行行失败: {e}"))?);
}
Ok(result)
.collect())
}
fn build_model_usage_response(usages: Vec<RawModelUsage>) -> Vec<ModelUsage> {
@@ -689,12 +545,8 @@ pub fn get_daily_usage_trends_from_db(
) -> Result<Vec<DailyUsage>, String> {
let days = resolve_range_days(time_range)?;
let has_model_usage_data: i64 = conn
.query_row("SELECT COUNT(*) FROM model_usage_stats", [], |row| {
row.get(0)
})
let use_actual_tokens = OrchestratorDao::has_model_usage_stats(conn)
.map_err(|e| format!("检查 model_usage_stats 失败: {e}"))?;
let use_actual_tokens = has_model_usage_data > 0;
let mut daily_usage = Vec::new();
@@ -718,12 +570,7 @@ pub fn get_daily_usage_trends_from_db(
let total_conversations = conversations + agent_conversations;
let tokens = if use_actual_tokens {
let day_tokens: i64 = conn
.query_row(
"SELECT COALESCE(SUM(total_tokens), 0) FROM model_usage_stats WHERE date = ?",
[day_key.clone()],
|row| row.get(0),
)
let day_tokens = OrchestratorDao::get_model_usage_tokens_on(conn, &day_key)
.map_err(|e| format!("查询模型日 Token 失败: {e}"))?;
clamp_i64_to_u64(day_tokens)
@@ -751,7 +598,10 @@ pub fn get_daily_usage_trends_from_db(
#[cfg(test)]
mod tests {
use super::{query_agent_chat_stats, query_general_chat_stats, start_of_day, start_of_month};
use super::{
get_model_usage_ranking_from_db, query_agent_chat_stats, query_general_chat_stats,
start_of_day, start_of_month,
};
use chrono::{Local, TimeZone};
use rusqlite::{params, Connection};
@@ -897,4 +747,60 @@ mod tests {
assert_eq!(general_stats.monthly_conversations, 0);
assert_eq!(general_stats.today_messages, 0);
}
#[test]
fn model_usage_ranking_fallback_should_only_include_non_general_models() {
let conn = Connection::open_in_memory().expect("open in memory db");
create_test_schema(&conn);
conn.execute(
"INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at) VALUES (?1, ?2, NULL, ?3, ?4, ?5)",
params![
"general-1",
"general:default",
"通用会话",
"2026-03-12T10:00:00+08:00",
"2026-03-12T10:00:00+08:00"
],
)
.unwrap();
conn.execute(
"INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at) VALUES (?1, ?2, NULL, ?3, ?4, ?5)",
params![
"agent-1",
"claude-sonnet-4",
"Agent 会话",
"2026-03-12T10:05:00+08:00",
"2026-03-12T10:05:00+08:00"
],
)
.unwrap();
conn.execute(
"INSERT INTO agent_messages (session_id, role, content_json, timestamp) VALUES (?1, ?2, ?3, ?4)",
params![
"general-1",
"user",
r#"[{"type":"text","text":"这条 general 消息不应进入 Agent 排行"}]"#,
"2026-03-12T10:00:00+08:00"
],
)
.unwrap();
conn.execute(
"INSERT INTO agent_messages (session_id, role, content_json, timestamp) VALUES (?1, ?2, ?3, ?4)",
params![
"agent-1",
"assistant",
r#"[{"type":"text","text":"这是 agent 模型排行候选"}]"#,
"2026-03-12T10:05:00+08:00"
],
)
.unwrap();
let ranking = get_model_usage_ranking_from_db("all", &conn).expect("load ranking");
assert_eq!(ranking.len(), 1);
assert_eq!(ranking[0].model, "claude-sonnet-4");
assert_eq!(ranking[0].conversations, 1);
assert!(ranking[0].tokens > 0);
}
}
+406 -112
View File
@@ -212,6 +212,95 @@ struct OpenClawDirectUpgradeResult {
package_spec: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ResolvedOpenClawCommand {
Binary {
binary_path: PathBuf,
},
NodeCli {
node_path: PathBuf,
cli_path: PathBuf,
package_version: Option<String>,
},
}
impl ResolvedOpenClawCommand {
fn build_command(&self) -> Command {
let command_path = self.command_path();
let command_path_string = command_path.to_string_lossy().to_string();
let mut command = Command::new(command_path);
apply_binary_runtime_path(&mut command, &command_path_string);
if let Self::NodeCli { cli_path, .. } = self {
command.arg(cli_path);
}
command
}
fn command_path(&self) -> &Path {
match self {
Self::Binary { binary_path } => binary_path.as_path(),
Self::NodeCli { node_path, .. } => node_path.as_path(),
}
}
fn install_path_display(&self) -> String {
match self {
Self::Binary { binary_path } => binary_path.display().to_string(),
Self::NodeCli { cli_path, .. } => cli_path.display().to_string(),
}
}
fn invocation_display(&self) -> String {
match self {
Self::Binary { binary_path } => binary_path.display().to_string(),
Self::NodeCli {
node_path,
cli_path,
..
} => {
format!("{} {}", node_path.display(), cli_path.display())
}
}
}
fn preview_invocation(&self) -> String {
match self {
Self::Binary { binary_path } => shell_escape(binary_path.to_string_lossy().as_ref()),
Self::NodeCli {
node_path,
cli_path,
..
} => format!(
"{} {}",
shell_escape(node_path.to_string_lossy().as_ref()),
shell_escape(cli_path.to_string_lossy().as_ref())
),
}
}
fn fallback_version(&self) -> Option<String> {
match self {
Self::Binary { .. } => None,
Self::NodeCli {
package_version, ..
} => package_version.clone(),
}
}
fn dedupe_key(&self) -> String {
match self {
Self::Binary { binary_path } => format!("binary:{}", binary_path.display()),
Self::NodeCli {
node_path,
cli_path,
..
} => format!("node:{}:{}", node_path.display(), cli_path.display()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelInfo {
@@ -990,7 +1079,7 @@ impl OpenClawService {
});
}
let Some(binary) = find_command_in_shell("openclaw").await? else {
let Some(openclaw_command) = resolve_openclaw_command().await? else {
self.gateway_status = GatewayStatus::Error;
if let Some(app) = app {
emit_install_progress(app, "未检测到 OpenClaw 可执行文件,请先安装。", "error");
@@ -1011,9 +1100,8 @@ impl OpenClawService {
"info",
);
}
let mut command = Command::new(&binary);
let mut command = openclaw_command.build_command();
let start_args = gateway_start_args(self.gateway_port, &self.gateway_auth_token);
apply_binary_runtime_path(&mut command, &binary);
command
.args(&start_args)
.env(OPENCLAW_CONFIG_ENV, &config_path)
@@ -1162,8 +1250,8 @@ impl OpenClawService {
});
}
let stop_binaries = self.collect_gateway_stop_binaries().await?;
if stop_binaries.is_empty() {
let stop_commands = self.collect_gateway_stop_commands().await?;
if stop_commands.is_empty() {
if let Some(app) = app {
emit_install_progress(
app,
@@ -1172,8 +1260,9 @@ impl OpenClawService {
);
}
} else {
for binary in &stop_binaries {
self.request_gateway_stop_via_binary(binary, app).await;
for command_spec in &stop_commands {
self.request_gateway_stop_via_command(command_spec, app)
.await;
if self
.wait_for_gateway_shutdown(Duration::from_secs(4))
.await?
@@ -1271,7 +1360,7 @@ impl OpenClawService {
}
pub async fn check_update(&self) -> Result<UpdateInfo, String> {
let Some(binary) = find_command_in_shell("openclaw").await? else {
let Some(openclaw_command) = resolve_openclaw_command().await? else {
return Ok(UpdateInfo {
has_update: false,
current_version: None,
@@ -1288,7 +1377,7 @@ impl OpenClawService {
.await?
.and_then(|value| parse_openclaw_release_version(&value).or(Some(value)));
let payload = match read_openclaw_update_status_payload(&binary).await {
let payload = match read_openclaw_update_status_payload(&openclaw_command).await {
Ok(payload) => payload,
Err(message) => {
return Ok(UpdateInfo {
@@ -1336,13 +1425,16 @@ impl OpenClawService {
pub async fn perform_update(&mut self, app: &AppHandle) -> Result<ActionResult, String> {
emit_install_progress(app, "开始执行 OpenClaw 升级。", "info");
let Some(binary) = find_command_in_shell("openclaw").await? else {
let Some(openclaw_command) = resolve_openclaw_command().await? else {
return Ok(ActionResult {
success: false,
message: "未检测到 OpenClaw 可执行文件,请先安装。".to_string(),
});
};
let current_runtime_bin_dir = Path::new(&binary).parent().map(Path::to_path_buf);
let current_runtime_bin_dir = openclaw_command
.command_path()
.parent()
.map(Path::to_path_buf);
self.refresh_process_state().await?;
let gateway_was_running = self.gateway_status == GatewayStatus::Running;
@@ -1372,51 +1464,52 @@ impl OpenClawService {
);
}
let update_status_payload = match read_openclaw_update_status_payload(&binary).await {
Ok(payload) => payload,
Err(message) => {
emit_install_progress(app, &message, "warn");
match attempt_direct_openclaw_package_upgrade(
app,
current_runtime_bin_dir.as_deref(),
None,
None,
)
.await
{
Ok(result) => {
set_preferred_runtime_bin_dir(Some(result.runtime_bin_dir.clone()));
emit_install_progress(
app,
&format!(
"已自动切换后续执行环境到 {}。",
result.runtime_bin_dir.display()
),
"info",
);
return self
.finalize_successful_openclaw_update(
let update_status_payload =
match read_openclaw_update_status_payload(&openclaw_command).await {
Ok(payload) => payload,
Err(message) => {
emit_install_progress(app, &message, "warn");
match attempt_direct_openclaw_package_upgrade(
app,
current_runtime_bin_dir.as_deref(),
None,
None,
)
.await
{
Ok(result) => {
set_preferred_runtime_bin_dir(Some(result.runtime_bin_dir.clone()));
emit_install_progress(
app,
gateway_was_running,
Some(format!(
"OpenClaw 已通过 {} 的 {} 全局升级完成({})。",
result.runtime_source,
result.package_manager,
result.package_spec
)),
)
.await;
}
Err(fallback_error) => {
emit_install_progress(app, &fallback_error, "error");
return Ok(ActionResult {
success: false,
message,
});
&format!(
"已自动切换后续执行环境到 {}。",
result.runtime_bin_dir.display()
),
"info",
);
return self
.finalize_successful_openclaw_update(
app,
gateway_was_running,
Some(format!(
"OpenClaw 已通过 {} 的 {} 全局升级完成({})。",
result.runtime_source,
result.package_manager,
result.package_spec
)),
)
.await;
}
Err(fallback_error) => {
emit_install_progress(app, &fallback_error, "error");
return Ok(ActionResult {
success: false,
message,
});
}
}
}
}
};
};
let update_context = extract_openclaw_update_execution_context(&update_status_payload);
if let Some(root) = update_context.root.as_ref().filter(|root| root.is_dir()) {
emit_install_progress(
@@ -1437,8 +1530,7 @@ impl OpenClawService {
);
}
let mut command = Command::new(&binary);
apply_binary_runtime_path(&mut command, &binary);
let mut command = openclaw_command.build_command();
if let Some(root) = update_context.root.as_ref().filter(|root| root.is_dir()) {
command.current_dir(root);
}
@@ -1696,26 +1788,30 @@ impl OpenClawService {
Ok(false)
}
async fn collect_gateway_stop_binaries(&self) -> Result<Vec<PathBuf>, String> {
let mut binaries = Vec::new();
async fn collect_gateway_stop_commands(&self) -> Result<Vec<ResolvedOpenClawCommand>, String> {
let mut commands = Vec::new();
if let Some(binary) = find_command_in_shell("openclaw").await? {
binaries.push(PathBuf::from(binary));
if let Some(command) = resolve_openclaw_command().await? {
commands.push(command);
}
let mut runtime_candidates = list_openclaw_runtime_candidates().await?;
runtime_candidates.sort_by(compare_openclaw_runtime_candidates);
binaries.extend(
commands.extend(
runtime_candidates
.into_iter()
.filter_map(|candidate| candidate.openclaw_path.map(PathBuf::from)),
.iter()
.filter_map(resolve_openclaw_command_from_runtime_candidate),
);
Ok(dedupe_paths(binaries))
Ok(dedupe_openclaw_commands(commands))
}
async fn request_gateway_stop_via_binary(&self, binary_path: &Path, app: Option<&AppHandle>) {
let binary_label = binary_path.display().to_string();
async fn request_gateway_stop_via_command(
&self,
command_spec: &ResolvedOpenClawCommand,
app: Option<&AppHandle>,
) {
let binary_label = command_spec.invocation_display();
if let Some(app) = app {
emit_install_progress(
app,
@@ -1724,12 +1820,7 @@ impl OpenClawService {
);
}
let mut command = Command::new(binary_path);
if let Some(binary) = binary_path.to_str() {
apply_binary_runtime_path(&mut command, binary);
} else {
apply_windows_no_window(&mut command);
}
let mut command = command_spec.build_command();
let output = timeout(
Duration::from_secs(8),
command
@@ -1861,9 +1952,9 @@ impl OpenClawService {
self.gateway_started_at = None;
}
let binary = find_command_in_shell("openclaw").await?;
let running =
self.check_port_open().await || self.check_gateway_status(binary.as_deref()).await?;
let openclaw_command = resolve_openclaw_command().await?;
let running = self.check_port_open().await
|| self.check_gateway_status(openclaw_command.as_ref()).await?;
self.gateway_status = if running {
GatewayStatus::Running
@@ -1891,13 +1982,15 @@ impl OpenClawService {
.unwrap_or(false)
}
async fn check_gateway_status(&self, binary: Option<&str>) -> Result<bool, String> {
let Some(openclaw_path) = binary else {
async fn check_gateway_status(
&self,
command_spec: Option<&ResolvedOpenClawCommand>,
) -> Result<bool, String> {
let Some(command_spec) = command_spec else {
return Ok(false);
};
let mut command = Command::new(openclaw_path);
apply_binary_runtime_path(&mut command, &openclaw_path);
let mut command = command_spec.build_command();
let output = command
.arg("gateway")
.arg("status")
@@ -1925,26 +2018,11 @@ impl OpenClawService {
}
async fn read_openclaw_version(&self) -> Result<Option<String>, String> {
let Some(binary) = find_command_in_shell("openclaw").await? else {
let Some(command_spec) = resolve_openclaw_command().await? else {
return Ok(None);
};
let mut command = Command::new(&binary);
apply_binary_runtime_path(&mut command, &binary);
let output = command
.arg("--version")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| format!("读取 OpenClaw 版本失败: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if stdout.is_empty() {
Ok(None)
} else {
Ok(Some(stdout))
}
read_openclaw_version_from_command(&command_spec).await
}
fn gateway_ws_url(&self) -> String {
@@ -1977,12 +2055,11 @@ impl OpenClawService {
return None;
}
let Some(openclaw_path) = find_command_in_shell("openclaw").await.ok().flatten() else {
let Some(command_spec) = resolve_openclaw_command().await.ok().flatten() else {
return None;
};
let mut command = Command::new(&openclaw_path);
apply_binary_runtime_path(&mut command, &openclaw_path);
let mut command = command_spec.build_command();
let output = command
.arg("gateway")
.arg("health")
@@ -2154,7 +2231,7 @@ impl OpenClawService {
if self.gateway_auth_token.is_empty() {
self.gateway_auth_token = generate_auth_token();
}
let binary = find_command_in_shell("openclaw")
let openclaw_command = resolve_openclaw_command()
.await?
.ok_or_else(|| "未检测到 OpenClaw 可执行文件,请先安装。".to_string())?;
let config_path = openclaw_lime_config_path();
@@ -2173,7 +2250,7 @@ impl OpenClawService {
""
},
shell_escape(config_path.to_string_lossy().as_ref()),
shell_escape(&binary),
openclaw_command.preview_invocation(),
command
),
})
@@ -2187,7 +2264,7 @@ impl OpenClawService {
self.gateway_port = next_port.max(1);
}
self.restore_auth_token_from_config();
let binary = find_command_in_shell("openclaw")
let openclaw_command = resolve_openclaw_command()
.await?
.ok_or_else(|| "未检测到 OpenClaw 可执行文件,请先安装。".to_string())?;
let config_path = openclaw_lime_config_path();
@@ -2196,7 +2273,7 @@ impl OpenClawService {
command: format!(
"OPENCLAW_CONFIG_PATH={} {} gateway stop --url {} --token {}",
shell_escape(config_path.to_string_lossy().as_ref()),
shell_escape(&binary),
openclaw_command.preview_invocation(),
self.gateway_ws_url(),
shell_escape(&self.gateway_auth_token)
),
@@ -2474,7 +2551,7 @@ async fn inspect_git_dependency_status() -> Result<DependencyStatus, String> {
}
async fn inspect_openclaw_dependency_status() -> Result<DependencyStatus, String> {
let Some(path) = find_command_in_shell("openclaw").await? else {
let Some(command) = resolve_openclaw_command().await? else {
if let Some(status) = inspect_openclaw_package_reload_status().await? {
return Ok(status);
}
@@ -2488,7 +2565,9 @@ async fn inspect_openclaw_dependency_status() -> Result<DependencyStatus, String
});
};
let version_text = read_command_version_text(&path, &["--version"]).await?;
let version_text = read_openclaw_version_from_command(&command)
.await?
.unwrap_or_default();
Ok(DependencyStatus {
status: "ok".to_string(),
version: if version_text.is_empty() {
@@ -2496,8 +2575,14 @@ async fn inspect_openclaw_dependency_status() -> Result<DependencyStatus, String
} else {
Some(version_text.clone())
},
path: Some(path),
message: if version_text.is_empty() {
path: Some(command.install_path_display()),
message: if matches!(command, ResolvedOpenClawCommand::NodeCli { .. }) {
if version_text.is_empty() {
"已检测到 OpenClaw 包,Lime 将通过当前 Node 运行时直接启动。".to_string()
} else {
format!("已检测到 OpenClaw 包,Lime 将通过当前 Node 运行时直接启动:{version_text}")
}
} else if version_text.is_empty() {
"已检测到 OpenClaw。".to_string()
} else {
format!("已检测到 OpenClaw:{version_text}")
@@ -3175,9 +3260,10 @@ fn parse_openclaw_release_version(value: &str) -> Option<String> {
.and_then(|captures| captures.get(1).map(|value| value.as_str().to_string()))
}
async fn read_openclaw_update_status_payload(binary_path: &str) -> Result<Value, String> {
let mut command = Command::new(binary_path);
apply_binary_runtime_path(&mut command, binary_path);
async fn read_openclaw_update_status_payload(
command_spec: &ResolvedOpenClawCommand,
) -> Result<Value, String> {
let mut command = command_spec.build_command();
let output = command
.arg("update")
.arg("status")
@@ -4415,6 +4501,81 @@ fn read_package_version(manifest_path: &Path) -> Option<String> {
manifest.version.filter(|item| !item.trim().is_empty())
}
fn resolve_openclaw_cli_entry_from_package_manifest(manifest_path: &Path) -> Option<PathBuf> {
let package_root = manifest_path.parent()?;
let content = std::fs::read_to_string(manifest_path).ok()?;
let manifest = serde_json::from_str::<Value>(&content).ok()?;
let mut candidates = Vec::new();
if let Some(bin_value) = manifest.get("bin") {
let bin_entry = match bin_value {
Value::String(value) => Some(value.as_str()),
Value::Object(entries) => entries
.get("openclaw")
.and_then(Value::as_str)
.or_else(|| entries.values().find_map(Value::as_str)),
_ => None,
};
if let Some(entry) = bin_entry {
candidates.push(package_root.join(entry));
}
}
candidates.push(package_root.join("dist").join("index.js"));
candidates.push(package_root.join("dist").join("index.mjs"));
candidates.push(package_root.join("dist").join("entry.js"));
candidates.push(package_root.join("dist").join("entry.mjs"));
candidates.into_iter().find(|path| path.is_file())
}
fn resolve_openclaw_command_from_runtime_candidate(
candidate: &OpenClawRuntimeCandidate,
) -> Option<ResolvedOpenClawCommand> {
if let Some(openclaw_path) = candidate
.openclaw_path
.as_deref()
.map(PathBuf::from)
.filter(|path| path.is_file())
{
return Some(ResolvedOpenClawCommand::Binary {
binary_path: openclaw_path,
});
}
let node_path = PathBuf::from(candidate.node_path.as_str());
if !node_path.is_file() {
return None;
}
let manifest_path = candidate
.openclaw_package_path
.as_deref()
.map(PathBuf::from)?;
let cli_path = resolve_openclaw_cli_entry_from_package_manifest(&manifest_path)?;
Some(ResolvedOpenClawCommand::NodeCli {
node_path,
cli_path,
package_version: read_package_version(&manifest_path),
})
}
fn dedupe_openclaw_commands(
commands: Vec<ResolvedOpenClawCommand>,
) -> Vec<ResolvedOpenClawCommand> {
let mut deduped = Vec::with_capacity(commands.len());
let mut seen = HashSet::new();
for command in commands {
if seen.insert(command.dedupe_key()) {
deduped.push(command);
}
}
deduped
}
fn dedupe_paths(candidates: Vec<PathBuf>) -> Vec<PathBuf> {
let mut deduped = Vec::with_capacity(candidates.len());
let mut seen = HashSet::new();
@@ -4426,6 +4587,44 @@ fn dedupe_paths(candidates: Vec<PathBuf>) -> Vec<PathBuf> {
deduped
}
async fn resolve_openclaw_command() -> Result<Option<ResolvedOpenClawCommand>, String> {
if let Some(binary) = find_command_in_shell("openclaw").await? {
return Ok(Some(ResolvedOpenClawCommand::Binary {
binary_path: PathBuf::from(binary),
}));
}
let mut runtime_candidates = list_openclaw_runtime_candidates().await?;
runtime_candidates.sort_by(compare_openclaw_runtime_candidates);
Ok(runtime_candidates
.iter()
.find_map(resolve_openclaw_command_from_runtime_candidate))
}
async fn read_openclaw_version_from_command(
command_spec: &ResolvedOpenClawCommand,
) -> Result<Option<String>, String> {
if let Some(version) = command_spec.fallback_version() {
return Ok(Some(version));
}
let output = command_spec
.build_command()
.arg("--version")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| format!("读取 OpenClaw 版本失败: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if stdout.is_empty() {
Ok(None)
} else {
Ok(Some(stdout))
}
}
async fn list_openclaw_runtime_candidates() -> Result<Vec<OpenClawRuntimeCandidate>, String> {
let node_candidates =
dedupe_paths(collect_standard_command_candidates_without_preference("node").await?);
@@ -5062,15 +5261,17 @@ mod tests {
format_provider_base_url, gateway_start_args, has_api_version,
infer_openclaw_package_name_from_path, npm_global_command_dirs_for,
npm_global_node_modules_dirs_for, package_registry_for_package_spec,
parse_semver_from_text, resolve_windows_dependency_install_plan,
parse_semver_from_text, resolve_openclaw_cli_entry_from_package_manifest,
resolve_openclaw_command_from_runtime_candidate, resolve_windows_dependency_install_plan,
runtime_candidate_matches_install_root, sanitize_runtime_config,
select_best_semver_candidate, select_gateway_start_failure_detail,
select_openclaw_update_failure_detail, select_preferred_path_candidate,
shell_command_escape_for, shell_npm_prefix_assignment_for, shell_path_assignment_for,
trim_trailing_slash, windows_dependency_action_result, windows_dependency_setup_message,
windows_install_block_result, windows_manual_install_message, DependencyKind,
DependencyStatus, EnvironmentDiagnostics, OpenClawRuntimeCandidate, ShellPlatform,
WindowsDependencyInstallPlan, NPM_MIRROR_CN, OPENCLAW_CN_PACKAGE, OPENCLAW_DEFAULT_PACKAGE,
DependencyStatus, EnvironmentDiagnostics, OpenClawRuntimeCandidate,
ResolvedOpenClawCommand, ShellPlatform, WindowsDependencyInstallPlan, NPM_MIRROR_CN,
OPENCLAW_CN_PACKAGE, OPENCLAW_DEFAULT_PACKAGE,
};
use crate::database::dao::api_key_provider::{ApiKeyProvider, ApiProviderType, ProviderGroup};
use chrono::Utc;
@@ -5078,6 +5279,7 @@ mod tests {
use std::ffi::OsString;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn build_provider(provider_type: ApiProviderType, api_host: &str) -> ApiKeyProvider {
ApiKeyProvider {
@@ -5099,6 +5301,18 @@ mod tests {
}
}
fn build_unique_temp_dir(prefix: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("系统时间应晚于 Unix epoch")
.as_nanos();
std::env::temp_dir().join(format!(
"lime-openclaw-{prefix}-{}-{nanos}",
std::process::id()
))
}
#[test]
fn trims_trailing_slash() {
assert_eq!(
@@ -5762,6 +5976,86 @@ mod tests {
assert_eq!(detected, Some(("openclaw", Some("0.4.1".to_string()))));
}
#[test]
fn resolves_openclaw_cli_entry_from_dist_index_when_bin_target_missing() {
let temp_dir = build_unique_temp_dir("cli-entry");
let package_dir = temp_dir
.join("node_modules")
.join("@qingchencloud/openclaw-zh");
let dist_dir = package_dir.join("dist");
fs::create_dir_all(&dist_dir).unwrap();
fs::write(
package_dir.join("package.json"),
r#"{
"name":"@qingchencloud/openclaw-zh",
"version":"2026.3.13-zh.1",
"bin":{"openclaw":"openclaw.mjs"}
}"#,
)
.unwrap();
fs::write(dist_dir.join("index.js"), "console.log('openclaw');").unwrap();
let resolved =
resolve_openclaw_cli_entry_from_package_manifest(&package_dir.join("package.json"));
let _ = fs::remove_dir_all(&temp_dir);
assert_eq!(resolved, Some(package_dir.join("dist").join("index.js")));
}
#[test]
fn resolves_openclaw_command_from_runtime_candidate_as_node_cli() {
let temp_dir = build_unique_temp_dir("runtime-candidate");
let node_bin_dir = temp_dir.join("bin");
let package_dir = temp_dir
.join("node_modules")
.join("@qingchencloud/openclaw-zh");
let dist_dir = package_dir.join("dist");
fs::create_dir_all(&node_bin_dir).unwrap();
fs::create_dir_all(&dist_dir).unwrap();
let node_path = node_bin_dir.join("node");
fs::write(&node_path, "").unwrap();
fs::write(
package_dir.join("package.json"),
r#"{
"name":"@qingchencloud/openclaw-zh",
"version":"2026.3.13-zh.1",
"bin":{"openclaw":"openclaw.mjs"}
}"#,
)
.unwrap();
fs::write(dist_dir.join("index.js"), "console.log('openclaw');").unwrap();
let candidate = OpenClawRuntimeCandidate {
id: temp_dir.display().to_string(),
source: "nvm".to_string(),
bin_dir: node_bin_dir.display().to_string(),
node_path: node_path.display().to_string(),
node_version: Some("23.4.0".to_string()),
npm_path: None,
npm_global_prefix: None,
openclaw_path: None,
openclaw_version: Some("2026.3.13-zh.1".to_string()),
openclaw_package_path: Some(package_dir.join("package.json").display().to_string()),
is_active: true,
is_preferred: true,
};
let resolved = resolve_openclaw_command_from_runtime_candidate(&candidate);
let _ = fs::remove_dir_all(&temp_dir);
assert_eq!(
resolved,
Some(ResolvedOpenClawCommand::NodeCli {
node_path,
cli_path: package_dir.join("dist").join("index.js"),
package_version: Some("2026.3.13-zh.1".to_string()),
})
);
}
#[test]
fn windows_node_prefers_winget_when_available() {
assert_eq!(