Merge remote-tracking branch 'origin/main' into feat/backend-logtype
Sync source to CNB / Sync Git ref (push) Skipped
Sync source to CNB / Sync Git ref (push) Skipped
This commit is contained in:
commit
9ccacba3fd
23 files changed
+2718
-144
No files matched your search
@@ -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;
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { ButtonProps } from './types'
|
||||
const props = withDefaults(defineProps<ButtonProps>(), {
|
||||
type: 'base',
|
||||
size: 'md',
|
||||
iconOnly: false,
|
||||
circular: false,
|
||||
nativeType: 'button',
|
||||
disabled: false,
|
||||
loading: false,
|
||||
@@ -26,8 +28,11 @@ defineExpose({ element })
|
||||
:color="props.color"
|
||||
:size="props.size"
|
||||
:interaction="props.interaction"
|
||||
:icon-only="props.iconOnly"
|
||||
:circular="props.circular"
|
||||
:native-type="props.nativeType"
|
||||
:disabled="props.disabled || props.loading"
|
||||
:aria-label="props.iconOnly ? props.label : undefined"
|
||||
:aria-busy="props.loading || undefined"
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -13,7 +13,10 @@ import type {
|
||||
const baseClasses = [
|
||||
// Base
|
||||
'relative inline-flex min-w-0 shrink-0 items-center justify-center',
|
||||
'whitespace-nowrap border-0 no-underline',
|
||||
// The transparent border is load-bearing, not decorative: it participates in
|
||||
// layout, so an auto-width button is 2px wider than it would be without it.
|
||||
// The ring participates in layout; dropping it would silently narrow every text button.
|
||||
'whitespace-nowrap border border-solid border-transparent no-underline',
|
||||
// Interactions
|
||||
'touch-manipulation cursor-pointer select-none transition-[background-color,color,box-shadow,filter,opacity,transform] duration-150 ease-out',
|
||||
'enabled:active:scale-[0.97]',
|
||||
@@ -26,6 +29,8 @@ const baseClasses = [
|
||||
].join(' ')
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
'2xs':
|
||||
'h-6 gap-1 rounded-lg px-1.5 text-sm font-semibold leading-5 [&>svg]:size-4 [&>svg]:min-h-4 [&>svg]:min-w-4 [&>svg]:shrink-0',
|
||||
xs: 'h-7 gap-1 rounded-lg px-1.5 text-sm font-semibold leading-5 [&>svg]:size-4 [&>svg]:min-h-4 [&>svg]:min-w-4 [&>svg]:shrink-0',
|
||||
sm: 'h-8 gap-1 rounded-[10px] px-1.5 text-sm font-semibold leading-5 [&>svg]:size-4 [&>svg]:min-h-4 [&>svg]:min-w-4 [&>svg]:shrink-0',
|
||||
md: 'h-9 gap-1.5 rounded-xl px-2.5 text-base font-semibold leading-5 [&>svg]:size-5 [&>svg]:min-h-5 [&>svg]:min-w-5 [&>svg]:shrink-0',
|
||||
@@ -34,6 +39,7 @@ const sizeClasses: Record<ButtonSize, string> = {
|
||||
}
|
||||
|
||||
const iconOnlySizeClasses: Record<ButtonSize, string> = {
|
||||
'2xs': 'min-w-6 w-6 !px-0',
|
||||
xs: 'min-w-7 w-7 !px-0',
|
||||
sm: 'min-w-8 w-8 !px-0',
|
||||
md: 'min-w-9 w-9 !px-0',
|
||||
@@ -42,14 +48,23 @@ const iconOnlySizeClasses: Record<ButtonSize, string> = {
|
||||
}
|
||||
|
||||
const typeClasses: Record<ButtonType, string> = {
|
||||
base: 'button-frame--base bg-surface-4 text-contrast [&>svg]:text-primary',
|
||||
base: 'button-frame--base bg-surface-4 text-[var(--color-text-primary)] [&>svg]:text-[var(--color-text-default)]',
|
||||
colored:
|
||||
'button-frame--colored bg-[--button-color] text-[var(--color-accent-contrast)] [&>svg]:text-inherit',
|
||||
'colored-text':
|
||||
'button-frame--colored-text bg-surface-4 text-[--button-color] [&>svg]:text-inherit',
|
||||
outlined:
|
||||
'button-frame--outlined bg-transparent text-[var(--button-color,var(--color-contrast))] [&>svg]:text-[var(--button-color,var(--color-base))]',
|
||||
'button-frame--outlined bg-transparent text-[var(--button-color,var(--color-text-primary))] [&>svg]:text-[var(--button-color,var(--color-text-default))]',
|
||||
quiet: 'button-frame--quiet bg-transparent [&>svg]:text-inherit',
|
||||
// A 25% tint of the accent colour. All three read the same tint; they differ
|
||||
// in label colour and whether they carry the raised shadow. Without a colour
|
||||
// the tint variable is unset and the fill falls back to the raised surface,
|
||||
// which is what the legacy uncoloured chip rendered as.
|
||||
chip: 'button-frame--chip bg-[--button-highlight,var(--surface-4)] text-[var(--button-color,var(--color-text-default))] [&>svg]:text-inherit',
|
||||
'chip-text':
|
||||
'button-frame--chip-text bg-[--button-highlight,var(--surface-4)] text-[var(--button-color,var(--color-text-default))] [&>svg]:text-inherit',
|
||||
highlight:
|
||||
'button-frame--highlight bg-[--button-highlight,var(--surface-4)] text-[var(--color-text-primary)] [&>svg]:text-inherit',
|
||||
}
|
||||
|
||||
const interactionClasses: Record<ButtonInteraction, string> = {
|
||||
@@ -67,7 +82,17 @@ const colorVariables: Record<ButtonColor, string> = {
|
||||
green: 'var(--color-green)',
|
||||
blue: 'var(--color-blue)',
|
||||
purple: 'var(--color-purple)',
|
||||
medal_promotion: 'var(--medal-promotion-text-orange, var(--color-orange))',
|
||||
}
|
||||
|
||||
// The 25%-opacity tint behind `chip`/`highlight`. Each accent has its own
|
||||
// token, so the tint must be looked up rather than derived.
|
||||
const highlightVariables: Record<ButtonColor, string> = {
|
||||
brand: 'var(--color-brand-highlight)',
|
||||
red: 'var(--color-red-highlight)',
|
||||
orange: 'var(--color-orange-highlight)',
|
||||
green: 'var(--color-green-highlight)',
|
||||
blue: 'var(--color-blue-highlight)',
|
||||
purple: 'var(--color-purple-highlight)',
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -101,7 +126,20 @@ const classes = computed(() => [
|
||||
props.circular ? '!rounded-full' : '',
|
||||
])
|
||||
const style = computed((): CSSProperties | undefined => {
|
||||
// `outlined` and `quiet` deliberately publish nothing without an explicit
|
||||
// colour, so their own `var(--button-color, <default>)` fallbacks apply —
|
||||
// that is what reproduces the legacy default ring and text colours.
|
||||
if ((props.type === 'outlined' || props.type === 'quiet') && !props.color) return undefined
|
||||
if (props.type === 'chip' || props.type === 'chip-text' || props.type === 'highlight') {
|
||||
// Without an accent these fall back to the page surface and the default
|
||||
// label, matching the legacy behaviour for an uncoloured chip.
|
||||
const accent = props.color
|
||||
if (!accent) return undefined
|
||||
return {
|
||||
'--button-color': colorVariables[accent],
|
||||
'--button-highlight': highlightVariables[accent],
|
||||
} as CSSProperties
|
||||
}
|
||||
if (
|
||||
props.type !== 'colored' &&
|
||||
props.type !== 'colored-text' &&
|
||||
@@ -163,11 +201,24 @@ defineExpose({ element })
|
||||
mask-composite: exclude;
|
||||
}
|
||||
|
||||
/* The ring is a real border so it participates in layout. Set here rather
|
||||
* than as a utility because it has to beat the transparent border colour in
|
||||
* `baseClasses`. */
|
||||
.button-frame--outlined {
|
||||
box-shadow: inset 0 0 0 1px var(--button-color, var(--surface-5));
|
||||
border-color: var(--button-color, var(--surface-5));
|
||||
}
|
||||
|
||||
.button-frame--quiet {
|
||||
color: var(--button-color, var(--color-base));
|
||||
color: var(--button-color, var(--color-text-default));
|
||||
}
|
||||
|
||||
/* `chip` and `highlight` are tinted fills rather than transparent ones, so they
|
||||
* keep the raised-button shadow. `chip-text` is the same tint without it --
|
||||
* the legacy `highlight-colored-text`, which never had a shadow. */
|
||||
.button-frame--chip,
|
||||
.button-frame--highlight {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--surface-5),
|
||||
0 1px 1px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
</style>
|
||||
@@ -1,56 +1,74 @@
|
||||
export type ButtonType = 'base' | 'colored' | 'colored-text' | 'outlined' | 'quiet'
|
||||
export type ButtonType =
|
||||
'base' | 'colored' | 'colored-text' | 'outlined' | 'quiet' | 'chip' | 'chip-text' | 'highlight'
|
||||
|
||||
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
export type ButtonSize = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
|
||||
export type ButtonInteraction = 'surface' | 'filled' | 'none'
|
||||
|
||||
// TODO: Standardized color string enum props across @modrinth/ui
|
||||
export type ButtonColor =
|
||||
| 'brand'
|
||||
| 'red'
|
||||
| 'orange'
|
||||
| 'green'
|
||||
| 'blue'
|
||||
| 'purple'
|
||||
| 'medal_promotion'
|
||||
export type ButtonColor = 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||
|
||||
export type ButtonVisualProps = {
|
||||
type ButtonVisualBase = {
|
||||
size?: ButtonSize
|
||||
interaction?: ButtonInteraction
|
||||
} & (
|
||||
}
|
||||
|
||||
type ButtonVisualWithoutInteraction =
|
||||
| {
|
||||
type?: 'base'
|
||||
color?: never
|
||||
interaction?: never
|
||||
}
|
||||
| {
|
||||
type: 'outlined'
|
||||
type: Exclude<ButtonType, 'base' | 'quiet'>
|
||||
color?: ButtonColor
|
||||
interaction?: never
|
||||
}
|
||||
| {
|
||||
type: 'colored'
|
||||
color?: ButtonColor
|
||||
}
|
||||
| {
|
||||
type: 'colored-text'
|
||||
color?: ButtonColor
|
||||
}
|
||||
| {
|
||||
type: 'quiet'
|
||||
color?: ButtonColor
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Public visual combinations supported by the current button frame.
|
||||
*
|
||||
* `interaction` only changes quiet buttons, so exposing it on any other type
|
||||
* would accept a prop that has no effect. Keeping that restriction here makes
|
||||
* Button and ButtonLink share one truthful visual contract.
|
||||
*/
|
||||
export type ButtonVisualProps = ButtonVisualBase &
|
||||
(
|
||||
| ButtonVisualWithoutInteraction
|
||||
| {
|
||||
type: 'quiet'
|
||||
color?: ButtonColor
|
||||
interaction?: ButtonInteraction
|
||||
}
|
||||
)
|
||||
|
||||
export type ButtonNativeType = 'button' | 'submit' | 'reset'
|
||||
|
||||
export interface ButtonProps {
|
||||
type?: ButtonType
|
||||
color?: ButtonColor
|
||||
size?: ButtonSize
|
||||
interaction?: ButtonInteraction
|
||||
nativeType?: ButtonNativeType
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
/** Icon-only controls need a name because their slot has no visible label. */
|
||||
export type ButtonContentProps =
|
||||
| {
|
||||
iconOnly: true
|
||||
label: string
|
||||
}
|
||||
| {
|
||||
iconOnly?: false
|
||||
label?: never
|
||||
}
|
||||
|
||||
export type ButtonProps = ButtonVisualProps &
|
||||
ButtonContentProps & {
|
||||
circular?: boolean
|
||||
nativeType?: ButtonNativeType
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export type ButtonLinkProps = ButtonVisualProps &
|
||||
ButtonContentProps & {
|
||||
as?: string | import('vue').Component
|
||||
href?: string
|
||||
disabled?: boolean
|
||||
circular?: boolean
|
||||
}
|
||||
|
||||
export interface ButtonElementHandle {
|
||||
element: HTMLElement | null
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { default as JLineCommandInput } from './components/JLineCommandInput.vue'
|
||||
export { default as LogViewport } from './components/LogViewport.vue'
|
||||
export * from './jline'
|
||||
export { default as ConsolePageLayout } from './layout.vue'
|
||||
export * from './providers'
|
||||
export * from './types'
|
||||
export * from './types'
|
||||
@@ -28,6 +28,13 @@ export function truncateLogToMaxBytes(content: string, maxBytes: number): string
|
||||
return textDecoder.decode(bytes.subarray(start))
|
||||
}
|
||||
|
||||
/**
|
||||
* Log sharing providers supported by the launcher. LogShare is the default
|
||||
* and is implemented in the desktop app through native Tauri commands; mclo.gs
|
||||
* is the network fallback used when LogShare is unreachable or unselected.
|
||||
*/
|
||||
export type LogShareProvider = 'logshare' | 'mclogs'
|
||||
|
||||
/**
|
||||
* Result of sharing log content. `truncated` is set when the content exceeded
|
||||
* `LOG_SHARE_MAX_BYTES` and only its tail was uploaded.
|
||||
@@ -39,12 +46,25 @@ export type LogShareResult = {
|
||||
|
||||
/**
|
||||
* Share log content through mclo.gs. Content larger than `LOG_SHARE_MAX_BYTES`
|
||||
* is trimmed to its last
|
||||
* `LOG_SHARE_MAX_BYTES` before uploading.
|
||||
* is trimmed to its last `LOG_SHARE_MAX_BYTES` before uploading.
|
||||
*/
|
||||
export async function shareLogs(
|
||||
client: AbstractModrinthClient,
|
||||
content: string,
|
||||
): Promise<LogShareResult> {
|
||||
return shareLogsWithProvider(client, content, 'mclogs')
|
||||
}
|
||||
|
||||
/**
|
||||
* Share log content through the given provider. The `logshare` provider is
|
||||
* only available inside the desktop app (native commands); this shared helper
|
||||
* routes the `mclogs` fallback so both launcher surfaces can share generic
|
||||
* log buffers.
|
||||
*/
|
||||
export async function shareLogsWithProvider(
|
||||
client: AbstractModrinthClient,
|
||||
content: string,
|
||||
_provider: LogShareProvider = 'mclogs',
|
||||
): Promise<LogShareResult> {
|
||||
const uploadContent = truncateLogToMaxBytes(content, LOG_SHARE_MAX_BYTES)
|
||||
const truncated = uploadContent !== content
|
||||
|
||||
Reference in new issue
Block a user