diff --git a/apps/app/build.rs b/apps/app/build.rs index 3aee07d..5cef161 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -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, diff --git a/apps/app/src/api/logs.rs b/apps/app/src/api/logs.rs index 6cfb9d2..acb4846 100644 --- a/apps/app/src/api/logs.rs +++ b/apps/app/src/api/logs.rs @@ -34,6 +34,17 @@ pub fn init() -> tauri::plugin::TauriPlugin { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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?) +} diff --git a/packages/app-lib/migrations/20261005000000_log-share.sql b/packages/app-lib/migrations/20261005000000_log-share.sql new file mode 100644 index 0000000..deb5dcd --- /dev/null +++ b/packages/app-lib/migrations/20261005000000_log-share.sql @@ -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); \ No newline at end of file diff --git a/packages/app-lib/src/api/logs.rs b/packages/app-lib/src/api/logs.rs index 4ea104b..c2a9bfc 100644 --- a/packages/app-lib/src/api/logs.rs +++ b/packages/app-lib/src/api/logs.rs @@ -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, diff --git a/packages/app-lib/src/api/logs/logshare.rs b/packages/app-lib/src/api/logs/logshare.rs new file mode 100644 index 0000000..19b43d4 --- /dev/null +++ b/packages/app-lib/src/api/logs/logshare.rs @@ -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, + pub instance_name: Option, + 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, + pub failed: Vec, +} + +#[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, + source: String, +} + +pub async fn get_log_share_settings() -> crate::Result { + 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> { + let state = State::get().await?; + let rows = sqlx::query_as::<_, (String, String, String, String, String, Option, Option, 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, +} + +async fn collect_crash_logs( + instance_id: &str, +) -> crate::Result { + 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 { + 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::>() + } else { + Vec::new() + }; + Ok(UploadBody { + content: combined, + files, + source: source_identifier(), + }) +} + +async fn request_client() -> crate::Result { + 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 { + 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::() + )) + .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 { + 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::() + )) + .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 { + 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::() + )) + .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 { + 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::() + )) + .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 { + 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 { + 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, +) -> crate::Result { + 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::() + )) + .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, + status_event: Option, + done: bool, + error: Option, +} + +struct ParsedStatusEvent { + event_type: String, + data: Value, +} + +struct SseParser { + buffer: Vec, +} + +impl SseParser { + fn new() -> Self { + Self { buffer: Vec::new() } + } + + fn feed(&mut self, chunk: &[u8]) -> Vec { + 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 { + haystack.windows(needle.len()).position(|w| w == needle) +} + +fn parse_sse_block(block: &str) -> Option { + let mut event_name: Option = 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::(&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::(&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::(&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, + }) + } + } +} diff --git a/packages/app-lib/src/event/emit.rs b/packages/app-lib/src/event/emit.rs index 2c29614..e2f0c5d 100644 --- a/packages/app-lib/src/event/emit.rs +++ b/packages/app-lib/src/event/emit.rs @@ -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(()) +} diff --git a/packages/app-lib/src/event/mod.rs b/packages/app-lib/src/event/mod.rs index d575c34..52e5e1c 100644 --- a/packages/app-lib/src/event/mod.rs +++ b/packages/app-lib/src/event/mod.rs @@ -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)] diff --git a/packages/app-lib/src/lib.rs b/packages/app-lib/src/lib.rs index 2b3d70a..b557d3b 100644 --- a/packages/app-lib/src/lib.rs +++ b/packages/app-lib/src/lib.rs @@ -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;