feat: 新增崩溃日志分享与 AI 分析(后端)
Sync source to CNB / Sync Git ref (push) Skipped

- 新增 LogShare 接入:上传崩溃日志、查询结构化分析、SSE 流式 AI 解释、删除分享
- 新增本地已分享日志记录(shared_logs)与分享设置(log_share_settings)
- 日志上传来源标识为 starlight/<版本>
- 新增 logshare_ai 事件用于向前端推送 AI 流式输出
- 暴露 11 个 tauri 命令并补齐 ACL
This commit is contained in:
AxTps committed 2026-10-11 12:15:33 +08:00
1 parent 47bdb1e79c
commit 8d61826c5f
8 files changed
+815 -1

No files matched your search

+11
View File
@@ -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,
+89
View File
@@ -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);
+9
View File
@@ -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,
+649
View File
@@ -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,
})
}
}
}
+22
View File
@@ -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(())
}
+8
View File
@@ -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)]
+1 -1
View File
@@ -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;