mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
Release v0.90.0
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user