- 新增 LogShare 接入:上传崩溃日志、查询结构化分析、SSE 流式 AI 解释、删除分享 - 新增本地已分享日志记录(shared_logs)与分享设置(log_share_settings) - 日志上传来源标识为 starlight/<版本> - 新增 logshare_ai 事件用于向前端推送 AI 流式输出 - 暴露 11 个 tauri 命令并补齐 ACL
This commit is contained in:
1 parent
47bdb1e79c
commit
8d61826c5f
8 files changed
+815
-1
No files matched your search
@@ -300,6 +300,17 @@ fn main() {
|
||||
"logs_explain_crash_with_ai",
|
||||
"logs_undo_added_mod",
|
||||
"logs_export_crash_context",
|
||||
"logs_get_log_share_settings",
|
||||
"logs_update_log_share_settings",
|
||||
"logs_logshare_upload_crash",
|
||||
"logs_logshare_get_insights",
|
||||
"logs_logshare_analyse_crash_direct",
|
||||
"logs_logshare_ai_analyze_stored",
|
||||
"logs_logshare_ai_analyze_direct",
|
||||
"logs_logshare_delete",
|
||||
"logs_list_shared_logs",
|
||||
"logs_record_shared_log",
|
||||
"logs_delete_shared_log",
|
||||
])
|
||||
.default_permission(
|
||||
DefaultPermissionRule::AllowAllCommands,
|
||||
|
||||
@@ -34,6 +34,17 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
logs_explain_crash_with_ai,
|
||||
logs_undo_added_mod,
|
||||
logs_export_crash_context,
|
||||
logs_get_log_share_settings,
|
||||
logs_update_log_share_settings,
|
||||
logs_logshare_upload_crash,
|
||||
logs_logshare_get_insights,
|
||||
logs_logshare_analyse_crash_direct,
|
||||
logs_logshare_ai_analyze_stored,
|
||||
logs_logshare_ai_analyze_direct,
|
||||
logs_logshare_delete,
|
||||
logs_list_shared_logs,
|
||||
logs_record_shared_log,
|
||||
logs_delete_shared_log,
|
||||
])
|
||||
.build()
|
||||
}
|
||||
@@ -172,3 +183,81 @@ pub async fn logs_export_crash_context(
|
||||
writer.close().await.map_err(crate::api::utils::zip_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the log share settings.
|
||||
#[tauri::command]
|
||||
pub async fn logs_get_log_share_settings() -> Result<LogShareSettings> {
|
||||
Ok(logs::get_log_share_settings().await?)
|
||||
}
|
||||
|
||||
/// Update the log share settings.
|
||||
#[tauri::command]
|
||||
pub async fn logs_update_log_share_settings(
|
||||
settings: LogShareSettings,
|
||||
) -> Result<()> {
|
||||
Ok(logs::update_log_share_settings(settings).await?)
|
||||
}
|
||||
|
||||
/// Upload the latest run's crash logs to LogShare.
|
||||
#[tauri::command]
|
||||
pub async fn logs_logshare_upload_crash(
|
||||
instance_id: &str,
|
||||
) -> Result<LogShareUploadResponse> {
|
||||
Ok(logs::upload_crash(instance_id).await?)
|
||||
}
|
||||
|
||||
/// Fetch structured insights for a shared log.
|
||||
#[tauri::command]
|
||||
pub async fn logs_logshare_get_insights(id: &str) -> Result<serde_json::Value> {
|
||||
Ok(logs::get_insights(id).await?)
|
||||
}
|
||||
|
||||
/// Analyse the latest run's crash logs directly.
|
||||
#[tauri::command]
|
||||
pub async fn logs_logshare_analyse_crash_direct(
|
||||
instance_id: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
Ok(logs::analyse_crash_direct(instance_id).await?)
|
||||
}
|
||||
|
||||
/// Stream an AI analysis for a stored shared log.
|
||||
#[tauri::command]
|
||||
pub async fn logs_logshare_ai_analyze_stored(
|
||||
instance_id: &str,
|
||||
id: &str,
|
||||
) -> Result<String> {
|
||||
Ok(logs::ai_analyze_stored(instance_id, id).await?)
|
||||
}
|
||||
|
||||
/// Stream an AI analysis for the latest run's crash logs.
|
||||
#[tauri::command]
|
||||
pub async fn logs_logshare_ai_analyze_direct(
|
||||
instance_id: &str,
|
||||
) -> Result<String> {
|
||||
Ok(logs::ai_analyze_direct(instance_id).await?)
|
||||
}
|
||||
|
||||
/// Delete a shared log remotely and locally.
|
||||
#[tauri::command]
|
||||
pub async fn logs_logshare_delete(id: &str, token: &str) -> Result<()> {
|
||||
logs::delete_log(id, token).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List locally recorded shared logs.
|
||||
#[tauri::command]
|
||||
pub async fn logs_list_shared_logs() -> Result<Vec<SharedLog>> {
|
||||
Ok(logs::list_shared_logs().await?)
|
||||
}
|
||||
|
||||
/// Record a shared log locally.
|
||||
#[tauri::command]
|
||||
pub async fn logs_record_shared_log(log: SharedLog) -> Result<()> {
|
||||
Ok(logs::record_shared_log(log).await?)
|
||||
}
|
||||
|
||||
/// Remove a shared log record locally and remotely.
|
||||
#[tauri::command]
|
||||
pub async fn logs_delete_shared_log(id: String, token: String) -> Result<()> {
|
||||
Ok(logs::delete_shared_log(id, token).await?)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE log_share_settings (
|
||||
id INTEGER NOT NULL CHECK (id = 0),
|
||||
share_provider TEXT NOT NULL DEFAULT 'logshare',
|
||||
ai_source TEXT NOT NULL DEFAULT 'logshare',
|
||||
auto_upload INTEGER NOT NULL DEFAULT TRUE,
|
||||
multi_file INTEGER NOT NULL DEFAULT TRUE,
|
||||
no_storage INTEGER NOT NULL DEFAULT FALSE,
|
||||
show_progress INTEGER NOT NULL DEFAULT TRUE,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
INSERT INTO log_share_settings (id) VALUES (0);
|
||||
|
||||
CREATE TABLE shared_logs (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
url TEXT NOT NULL,
|
||||
raw TEXT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
provider TEXT NOT NULL DEFAULT 'logshare',
|
||||
instance_id TEXT NULL,
|
||||
instance_name TEXT NULL,
|
||||
truncated INTEGER NOT NULL DEFAULT FALSE,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX shared_logs_created_at_idx ON shared_logs (created_at DESC);
|
||||
@@ -27,6 +27,15 @@ pub use crash_analysis::{
|
||||
update_crash_analysis_ai_settings,
|
||||
};
|
||||
|
||||
mod logshare;
|
||||
pub use logshare::{
|
||||
LogShareDeleteResponse, LogShareSettings, LogShareUploadResponse,
|
||||
SharedLog, ai_analyze_direct, ai_analyze_stored, analyse_crash_direct,
|
||||
delete_log, delete_shared_log, get_insights, get_log_share_settings,
|
||||
list_shared_logs, record_shared_log, update_log_share_settings,
|
||||
upload_crash,
|
||||
};
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct Logs {
|
||||
pub log_type: LogType,
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use futures::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::CensoredString;
|
||||
use super::crash_analysis::analyze_crash;
|
||||
use crate::State;
|
||||
use crate::emit_logshare_ai_event;
|
||||
|
||||
const LOGSHARE_BASE_URL: &str = "https://api.logshare.cn";
|
||||
const LOGSHARE_AI_READ_TIMEOUT_SECS: u64 = 300;
|
||||
const LOGSHARE_SOURCE_PREFIX: &str = "starlight";
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct LogShareSettings {
|
||||
pub share_provider: String,
|
||||
pub ai_source: String,
|
||||
pub auto_upload: bool,
|
||||
pub multi_file: bool,
|
||||
pub no_storage: bool,
|
||||
pub show_progress: bool,
|
||||
}
|
||||
|
||||
impl Default for LogShareSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
share_provider: "logshare".to_string(),
|
||||
ai_source: "logshare".to_string(),
|
||||
auto_upload: true,
|
||||
multi_file: true,
|
||||
no_storage: false,
|
||||
show_progress: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct SharedLog {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub raw: String,
|
||||
pub token: String,
|
||||
pub provider: String,
|
||||
pub instance_id: Option<String>,
|
||||
pub instance_name: Option<String>,
|
||||
pub truncated: bool,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct LogShareUploadResponse {
|
||||
pub success: bool,
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub raw: String,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct LogShareDeleteResponse {
|
||||
pub success: bool,
|
||||
pub deleted: Vec<String>,
|
||||
pub failed: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
struct UploadFile {
|
||||
name: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
struct UploadBody {
|
||||
content: String,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
files: Vec<UploadFile>,
|
||||
source: String,
|
||||
}
|
||||
|
||||
pub async fn get_log_share_settings() -> crate::Result<LogShareSettings> {
|
||||
let state = State::get().await?;
|
||||
let row = sqlx::query_as::<_, (String, String, i64, i64, i64, i64)>(
|
||||
"SELECT share_provider, ai_source, auto_upload, multi_file, no_storage, show_progress FROM log_share_settings WHERE id = 0",
|
||||
)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
Ok(LogShareSettings {
|
||||
share_provider: row.0,
|
||||
ai_source: row.1,
|
||||
auto_upload: row.2 != 0,
|
||||
multi_file: row.3 != 0,
|
||||
no_storage: row.4 != 0,
|
||||
show_progress: row.5 != 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn update_log_share_settings(
|
||||
settings: LogShareSettings,
|
||||
) -> crate::Result<()> {
|
||||
let settings = normalize_settings(settings);
|
||||
let state = State::get().await?;
|
||||
sqlx::query(
|
||||
"UPDATE log_share_settings SET share_provider = ?, ai_source = ?, auto_upload = ?, multi_file = ?, no_storage = ?, show_progress = ? WHERE id = 0",
|
||||
)
|
||||
.bind(&settings.share_provider)
|
||||
.bind(&settings.ai_source)
|
||||
.bind(settings.auto_upload)
|
||||
.bind(settings.multi_file)
|
||||
.bind(settings.no_storage)
|
||||
.bind(settings.show_progress)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_settings(mut settings: LogShareSettings) -> LogShareSettings {
|
||||
if settings.share_provider != "logshare" {
|
||||
settings.share_provider = "mclogs".to_string();
|
||||
}
|
||||
if settings.ai_source != "logshare" {
|
||||
settings.ai_source = "custom".to_string();
|
||||
}
|
||||
settings
|
||||
}
|
||||
|
||||
pub async fn list_shared_logs() -> crate::Result<Vec<SharedLog>> {
|
||||
let state = State::get().await?;
|
||||
let rows = sqlx::query_as::<_, (String, String, String, String, String, Option<String>, Option<String>, i64, i64)>(
|
||||
"SELECT id, url, raw, token, provider, instance_id, instance_name, truncated, created_at FROM shared_logs ORDER BY created_at DESC",
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| SharedLog {
|
||||
id: row.0,
|
||||
url: row.1,
|
||||
raw: row.2,
|
||||
token: row.3,
|
||||
provider: row.4,
|
||||
instance_id: row.5,
|
||||
instance_name: row.6,
|
||||
truncated: row.7 != 0,
|
||||
created_at: row.8,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn record_shared_log(log: SharedLog) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let created_at = log.created_at.max(now_seconds());
|
||||
sqlx::query(
|
||||
"INSERT INTO shared_logs (id, url, raw, token, provider, instance_id, instance_name, truncated, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET url = excluded.url, raw = excluded.raw, token = excluded.token, provider = excluded.provider, instance_id = excluded.instance_id, instance_name = excluded.instance_name, truncated = excluded.truncated",
|
||||
)
|
||||
.bind(&log.id)
|
||||
.bind(&log.url)
|
||||
.bind(&log.raw)
|
||||
.bind(&log.token)
|
||||
.bind(&log.provider)
|
||||
.bind(&log.instance_id)
|
||||
.bind(&log.instance_name)
|
||||
.bind(log.truncated)
|
||||
.bind(created_at)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_shared_log_record(id: &str) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
sqlx::query("DELETE FROM shared_logs WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_shared_log(id: String, token: String) -> crate::Result<()> {
|
||||
let remote_result = delete_log(&id, &token).await;
|
||||
if let Err(error) = remote_result {
|
||||
tracing::warn!(
|
||||
"Remote LogShare deletion failed for {id}; removing the local record anyway: {error}"
|
||||
);
|
||||
}
|
||||
remove_shared_log_record(&id).await
|
||||
}
|
||||
|
||||
fn now_seconds() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64
|
||||
}
|
||||
|
||||
fn source_identifier() -> String {
|
||||
format!("{LOGSHARE_SOURCE_PREFIX}/{}", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
struct CollectedCrashLog {
|
||||
name: String,
|
||||
content: CensoredString,
|
||||
}
|
||||
|
||||
struct CollectedCrashLogs {
|
||||
combined_log: CensoredString,
|
||||
sources: Vec<CollectedCrashLog>,
|
||||
}
|
||||
|
||||
async fn collect_crash_logs(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<CollectedCrashLogs> {
|
||||
let analysis = analyze_crash(instance_id).await?;
|
||||
Ok(CollectedCrashLogs {
|
||||
combined_log: analysis.combined_log,
|
||||
sources: analysis
|
||||
.sources
|
||||
.into_iter()
|
||||
.map(|source| CollectedCrashLog {
|
||||
name: source.filename,
|
||||
content: source.content,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
async fn prepare_upload(instance_id: &str) -> crate::Result<UploadBody> {
|
||||
let settings = get_log_share_settings().await?;
|
||||
let logs = collect_crash_logs(instance_id).await?;
|
||||
|
||||
let combined = logs.combined_log.as_str().to_string();
|
||||
let files = if settings.multi_file {
|
||||
logs.sources
|
||||
.iter()
|
||||
.map(|source| UploadFile {
|
||||
name: source.name.clone(),
|
||||
content: source.content.as_str().to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok(UploadBody {
|
||||
content: combined,
|
||||
files,
|
||||
source: source_identifier(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn request_client() -> crate::Result<reqwest::Client> {
|
||||
let state = State::get().await?;
|
||||
let proxy = state.proxy_config().await?;
|
||||
let builder = reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.read_timeout(Duration::from_secs(LOGSHARE_AI_READ_TIMEOUT_SECS))
|
||||
.user_agent(crate::launcher_user_agent());
|
||||
proxy.apply(builder)?.build().map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn upload_crash(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<LogShareUploadResponse> {
|
||||
let prepared = prepare_upload(instance_id).await?;
|
||||
let client = request_client().await?;
|
||||
let body_value = serde_json::to_value(&prepared)?;
|
||||
let request = client
|
||||
.post(format!("{LOGSHARE_BASE_URL}/v1/log"))
|
||||
.header("Accept", "application/json")
|
||||
.header("Accept-Encoding", "br, gzip");
|
||||
|
||||
let response = request.json(&body_value).send().await?;
|
||||
let status = response.status();
|
||||
let text = response.text().await?;
|
||||
if !status.is_success() {
|
||||
return Err(crate::ErrorKind::OtherError(format!(
|
||||
"LogShare upload failed with HTTP {status}: {}",
|
||||
text.chars().take(500).collect::<String>()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
let value: Value = serde_json::from_str(&text).map_err(|_| {
|
||||
crate::ErrorKind::OtherError(
|
||||
"LogShare returned invalid JSON".to_string(),
|
||||
)
|
||||
})?;
|
||||
if value.get("success").and_then(Value::as_bool) != Some(true) {
|
||||
return Err(crate::ErrorKind::OtherError(format!(
|
||||
"LogShare upload failed: {}",
|
||||
value
|
||||
.get("error")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown error")
|
||||
))
|
||||
.into());
|
||||
}
|
||||
Ok(LogShareUploadResponse {
|
||||
success: true,
|
||||
id: value
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
url: value
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
raw: value
|
||||
.get("raw")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
token: value
|
||||
.get("token")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_insights(id: &str) -> crate::Result<Value> {
|
||||
let client = request_client().await?;
|
||||
let response = client
|
||||
.get(format!("{LOGSHARE_BASE_URL}/v1/insights/{id}"))
|
||||
.header("Accept", "application/json")
|
||||
.header("Accept-Encoding", "br, gzip")
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
let text = response.text().await?;
|
||||
if !status.is_success() {
|
||||
return Err(crate::ErrorKind::OtherError(format!(
|
||||
"LogShare analysis failed with HTTP {status}: {}",
|
||||
text.chars().take(500).collect::<String>()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
serde_json::from_str(&text).map_err(|_| {
|
||||
crate::ErrorKind::OtherError(
|
||||
"LogShare returned invalid insights JSON".to_string(),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn analyse_crash_direct(instance_id: &str) -> crate::Result<Value> {
|
||||
let prepared = prepare_upload(instance_id).await?;
|
||||
let client = request_client().await?;
|
||||
let response = client
|
||||
.post(format!("{LOGSHARE_BASE_URL}/v1/analyse"))
|
||||
.header("Accept", "application/json")
|
||||
.header("Accept-Encoding", "br, gzip")
|
||||
.json(&prepared)
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
let text = response.text().await?;
|
||||
if !status.is_success() {
|
||||
return Err(crate::ErrorKind::OtherError(format!(
|
||||
"LogShare analysis failed with HTTP {status}: {}",
|
||||
text.chars().take(500).collect::<String>()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
serde_json::from_str(&text).map_err(|_| {
|
||||
crate::ErrorKind::OtherError(
|
||||
"LogShare returned invalid analysis JSON".to_string(),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete_log(
|
||||
id: &str,
|
||||
token: &str,
|
||||
) -> crate::Result<LogShareDeleteResponse> {
|
||||
let client = request_client().await?;
|
||||
let response = client
|
||||
.delete(format!("{LOGSHARE_BASE_URL}/v1/log/{id}"))
|
||||
.bearer_auth(token)
|
||||
.header("Accept", "application/json")
|
||||
.header("Accept-Encoding", "br, gzip")
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
let text = response.text().await?;
|
||||
if !status.is_success() {
|
||||
return Err(crate::ErrorKind::OtherError(format!(
|
||||
"LogShare deletion failed with HTTP {status}: {}",
|
||||
text.chars().take(500).collect::<String>()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
serde_json::from_str(&text).map_err(|_| {
|
||||
crate::ErrorKind::OtherError(
|
||||
"LogShare returned invalid deletion JSON".to_string(),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn ai_analyze_stored(
|
||||
instance_id: &str,
|
||||
id: &str,
|
||||
) -> crate::Result<String> {
|
||||
let url = format!("{LOGSHARE_BASE_URL}/v1/ai/{id}");
|
||||
stream_ai(instance_id, url, None).await
|
||||
}
|
||||
|
||||
pub async fn ai_analyze_direct(instance_id: &str) -> crate::Result<String> {
|
||||
let prepared = prepare_upload(instance_id).await?;
|
||||
let url = format!("{LOGSHARE_BASE_URL}/v1/ai/analyse");
|
||||
let body = serde_json::to_value(&prepared)?;
|
||||
stream_ai(instance_id, url, Some(body)).await
|
||||
}
|
||||
|
||||
async fn stream_ai(
|
||||
instance_id: &str,
|
||||
url: String,
|
||||
body: Option<Value>,
|
||||
) -> crate::Result<String> {
|
||||
let settings = get_log_share_settings().await?;
|
||||
let show_progress = settings.show_progress;
|
||||
let client = request_client().await?;
|
||||
let mut request = client
|
||||
.request(
|
||||
if body.is_some() {
|
||||
reqwest::Method::POST
|
||||
} else {
|
||||
reqwest::Method::GET
|
||||
},
|
||||
url.as_str(),
|
||||
)
|
||||
.header("Accept", "text/event-stream")
|
||||
.header("Accept-Encoding", "br, gzip");
|
||||
if let Some(payload) = body {
|
||||
request = request.json(&payload);
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
return Err(crate::ErrorKind::OtherError(
|
||||
"LogAgent queue is full, please retry later".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let text = response.text().await?;
|
||||
return Err(crate::ErrorKind::OtherError(format!(
|
||||
"LogAgent analysis failed with HTTP {status}: {}",
|
||||
text.chars().take(500).collect::<String>()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut parser = SseParser::new();
|
||||
let mut output = String::new();
|
||||
|
||||
loop {
|
||||
let maybe = tokio::time::timeout(
|
||||
Duration::from_secs(LOGSHARE_AI_READ_TIMEOUT_SECS),
|
||||
stream.next(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
crate::ErrorKind::OtherError(
|
||||
"LogAgent analysis timed out".to_string(),
|
||||
)
|
||||
})?;
|
||||
let Some(chunk) = maybe else {
|
||||
break;
|
||||
};
|
||||
let chunk = chunk?;
|
||||
for event in parser.feed(&chunk) {
|
||||
if let Some(delta) = event.content {
|
||||
output.push_str(&delta);
|
||||
if show_progress {
|
||||
emit_logshare_ai_event(
|
||||
instance_id,
|
||||
"delta",
|
||||
json!({ "content": delta }),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
if let Some(status_event) = event.status_event {
|
||||
if show_progress || status_event.event_type == "queued" {
|
||||
emit_logshare_ai_event(
|
||||
instance_id,
|
||||
&status_event.event_type,
|
||||
status_event.data,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
if let Some(message) = event.error {
|
||||
return Err(crate::ErrorKind::OtherError(format!(
|
||||
"LogAgent analysis failed: {message}"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
if event.done {
|
||||
return Ok(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A stream that ended without `event: done` is acceptable as long as text
|
||||
// was produced; otherwise it is a protocol error worth surfacing.
|
||||
if output.is_empty() {
|
||||
return Err(crate::ErrorKind::OtherError(
|
||||
"LogAgent analysis ended without output".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
struct ParsedEvent {
|
||||
content: Option<String>,
|
||||
status_event: Option<ParsedStatusEvent>,
|
||||
done: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
struct ParsedStatusEvent {
|
||||
event_type: String,
|
||||
data: Value,
|
||||
}
|
||||
|
||||
struct SseParser {
|
||||
buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
impl SseParser {
|
||||
fn new() -> Self {
|
||||
Self { buffer: Vec::new() }
|
||||
}
|
||||
|
||||
fn feed(&mut self, chunk: &[u8]) -> Vec<ParsedEvent> {
|
||||
self.buffer.extend_from_slice(chunk);
|
||||
let mut events = Vec::new();
|
||||
loop {
|
||||
let lf_pos = find_bytes(&self.buffer, b"\n\n");
|
||||
let crlf_pos = find_bytes(&self.buffer, b"\r\n\r\n");
|
||||
let (pos, separator_len) = match (lf_pos, crlf_pos) {
|
||||
(Some(lf_pos), Some(crlf_pos)) if crlf_pos < lf_pos => {
|
||||
(crlf_pos, 4)
|
||||
}
|
||||
(Some(lf_pos), _) => (lf_pos, 2),
|
||||
(None, Some(crlf_pos)) => (crlf_pos, 4),
|
||||
(None, None) => break,
|
||||
};
|
||||
let block =
|
||||
String::from_utf8_lossy(&self.buffer[..pos]).into_owned();
|
||||
self.buffer.drain(..pos + separator_len);
|
||||
if let Some(event) = parse_sse_block(&block) {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
}
|
||||
|
||||
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
haystack.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
|
||||
fn parse_sse_block(block: &str) -> Option<ParsedEvent> {
|
||||
let mut event_name: Option<String> = None;
|
||||
let mut data_lines = Vec::new();
|
||||
|
||||
for line in block.lines() {
|
||||
if let Some(rest) = line.strip_prefix("event:") {
|
||||
event_name = Some(rest.trim().to_string());
|
||||
} else if let Some(rest) = line.strip_prefix("data:") {
|
||||
data_lines.push(rest.trim_start().to_string());
|
||||
}
|
||||
}
|
||||
if event_name.is_none() && data_lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let data_text = data_lines.join("\n");
|
||||
let event_name = event_name.as_deref().unwrap_or("message");
|
||||
|
||||
match event_name {
|
||||
"done" => Some(ParsedEvent {
|
||||
content: None,
|
||||
status_event: None,
|
||||
done: true,
|
||||
error: None,
|
||||
}),
|
||||
"error" => Some(ParsedEvent {
|
||||
content: None,
|
||||
status_event: None,
|
||||
done: false,
|
||||
error: Some(
|
||||
serde_json::from_str::<Value>(&data_text)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("error")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
})
|
||||
.unwrap_or_else(|| data_text.clone()),
|
||||
),
|
||||
}),
|
||||
"status" => {
|
||||
let Ok(value) = serde_json::from_str::<Value>(&data_text) else {
|
||||
return None;
|
||||
};
|
||||
let event_type = value
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("status")
|
||||
.to_string();
|
||||
Some(ParsedEvent {
|
||||
content: None,
|
||||
status_event: Some(ParsedStatusEvent {
|
||||
event_type,
|
||||
data: value,
|
||||
}),
|
||||
done: false,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
let Ok(value) = serde_json::from_str::<Value>(&data_text) else {
|
||||
return None;
|
||||
};
|
||||
let content = value
|
||||
.pointer("/choices/0/delta/content")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
Some(ParsedEvent {
|
||||
content,
|
||||
status_event: None,
|
||||
done: false,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -612,3 +612,25 @@ where
|
||||
})
|
||||
.await
|
||||
}
|
||||
pub async fn emit_logshare_ai_event(
|
||||
instance_id: &str,
|
||||
event_type: &str,
|
||||
data: Value,
|
||||
) -> crate::Result<()> {
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
let event_state = crate::EventState::get()?;
|
||||
event_state
|
||||
.app
|
||||
.emit(
|
||||
"logshare_ai",
|
||||
crate::event::LogShareAiEventPayload {
|
||||
instance_id: instance_id.to_string(),
|
||||
event_type: event_type.to_string(),
|
||||
data,
|
||||
},
|
||||
)
|
||||
.map_err(EventError::from)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -472,6 +472,14 @@ mod log_types {
|
||||
#[serde(flatten)]
|
||||
pub event: LogEvent,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct LogShareAiEventPayload {
|
||||
pub instance_id: String,
|
||||
pub event_type: String,
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
||||
@@ -26,7 +26,7 @@ pub use api::*;
|
||||
pub use error::*;
|
||||
pub use event::{
|
||||
EventState, LoadingBar, LoadingBarType, emit::emit_loading,
|
||||
emit::init_loading,
|
||||
emit::emit_logshare_ai_event, emit::init_loading,
|
||||
};
|
||||
pub use logger::filter_log_contents;
|
||||
pub use logger::start_logger;
|
||||
|
||||
Reference in new issue
Block a user