diff --git a/apps/app-frontend/src/components/ui/MinecraftCrashModal.vue b/apps/app-frontend/src/components/ui/MinecraftCrashModal.vue index 997cca2..6198a91 100644 --- a/apps/app-frontend/src/components/ui/MinecraftCrashModal.vue +++ b/apps/app-frontend/src/components/ui/MinecraftCrashModal.vue @@ -1,15 +1,29 @@ + + diff --git a/apps/app-frontend/src/components/ui/minecraft-crash-logs.ts b/apps/app-frontend/src/components/ui/minecraft-crash-logs.ts new file mode 100644 index 0000000..a52c5f1 --- /dev/null +++ b/apps/app-frontend/src/components/ui/minecraft-crash-logs.ts @@ -0,0 +1,62 @@ +export interface CrashLogFile { + age: number + filename: string + log_type: 'InfoLog' | 'CrashReport' | 'JvmCrash' | 'LauncherLog' + output?: string | null +} + +const RUN_ASSOCIATION_WINDOW_SECONDS = 3 * 60 + +export function shouldUseLogShareAutoAnalysis(settings: { + ai_source: 'logshare' | 'custom' + auto_upload: boolean + no_storage?: boolean +}): boolean { + return settings.ai_source === 'logshare' && settings.auto_upload +} + +function isRelevantLog(file: CrashLogFile): boolean { + const lower = file.filename.toLowerCase() + if (file.log_type === 'CrashReport') return lower.endsWith('.txt') + if (file.log_type === 'JvmCrash') return lower.startsWith('hs_err') && lower.endsWith('.log') + return ['latest.log', 'debug.log', 'launcher_log.txt', 'latest_stdout.log'].includes(lower) +} + +export function selectCrashLogFiles(files: CrashLogFile[]): CrashLogFile[] { + const sorted = files.filter(isRelevantLog).sort((left, right) => right.age - left.age) + const anchor = sorted[0]?.age + if (anchor === undefined) return [] + + const singletonTypes = new Set() + return sorted.filter((file) => { + if (anchor - file.age > RUN_ASSOCIATION_WINDOW_SECONDS) return false + if (file.log_type !== 'CrashReport' && file.log_type !== 'JvmCrash') return true + if (singletonTypes.has(file.log_type)) return false + singletonTypes.add(file.log_type) + return true + }) +} + +export function crashLogKey(file: CrashLogFile): string { + return `${file.log_type}:${file.filename}` +} + +export function crashLogLabel(file: CrashLogFile): string { + return file.log_type === 'CrashReport' ? `crash-reports/${file.filename}` : file.filename +} + +export function preferredCrashLogKey(files: CrashLogFile[]): string { + const preferred = + files.find((file) => file.log_type === 'CrashReport') ?? + files.find((file) => file.log_type === 'JvmCrash') ?? + files.find((file) => file.filename.toLowerCase() === 'latest.log') ?? + files[0] + return preferred ? crashLogKey(preferred) : '' +} + +export function combineCrashLogs(files: CrashLogFile[]): string { + return files + .filter((file) => file.output) + .map((file) => `===== ${crashLogLabel(file)} =====\n${file.output}`) + .join('\n\n') +} diff --git a/apps/app-frontend/src/components/ui/settings/DefaultInstanceSettings.vue b/apps/app-frontend/src/components/ui/settings/DefaultInstanceSettings.vue index 68c5089..1f8f28b 100644 --- a/apps/app-frontend/src/components/ui/settings/DefaultInstanceSettings.vue +++ b/apps/app-frontend/src/components/ui/settings/DefaultInstanceSettings.vue @@ -6,6 +6,8 @@ import { ref, watch } from 'vue' import { get, set } from '@/helpers/settings.ts' import CrashAnalysisAISettings from './CrashAnalysisAISettings.vue' +import LogShareSettings from './LogShareSettings.vue' +import SharedLogsSettings from './SharedLogsSettings.vue' import SettingsRow from './SettingsRow.vue' import SettingsSection from './SettingsSection.vue' @@ -391,5 +393,7 @@ watch( + + diff --git a/apps/app-frontend/src/components/ui/settings/LogShareSettings.vue b/apps/app-frontend/src/components/ui/settings/LogShareSettings.vue new file mode 100644 index 0000000..ad3b5a2 --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/LogShareSettings.vue @@ -0,0 +1,354 @@ + + + diff --git a/apps/app-frontend/src/components/ui/settings/SharedLogsSettings.vue b/apps/app-frontend/src/components/ui/settings/SharedLogsSettings.vue new file mode 100644 index 0000000..62f17a3 --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/SharedLogsSettings.vue @@ -0,0 +1,129 @@ + + + diff --git a/apps/app-frontend/src/helpers/events.js b/apps/app-frontend/src/helpers/events.js index 79ca5d0..340f933 100644 --- a/apps/app-frontend/src/helpers/events.js +++ b/apps/app-frontend/src/helpers/events.js @@ -160,6 +160,10 @@ export async function notification_listener(callback) { return await listen('notification', (event) => callback(event.payload)) } +export async function logshare_ai_listener(callback) { + return await listen('logshare_ai', (event) => callback(event.payload)) +} + /// Payload for the 'log' event /* LogPayload { diff --git a/apps/app-frontend/src/helpers/logs.js b/apps/app-frontend/src/helpers/logs.js index 7ed4794..91a1840 100644 --- a/apps/app-frontend/src/helpers/logs.js +++ b/apps/app-frontend/src/helpers/logs.js @@ -95,6 +95,50 @@ export async function undo_added_mod(instanceId, filename, expectedHash) { return await invoke('plugin:logs|logs_undo_added_mod', { instanceId, filename, expectedHash }) } +export async function get_log_share_settings() { + return await invoke('plugin:logs|logs_get_log_share_settings') +} + +export async function update_log_share_settings(settings) { + return await invoke('plugin:logs|logs_update_log_share_settings', { settings }) +} + +export async function logshare_upload_crash(instanceId) { + return await invoke('plugin:logs|logs_logshare_upload_crash', { instanceId }) +} + +export async function logshare_get_insights(id) { + return await invoke('plugin:logs|logs_logshare_get_insights', { id }) +} + +export async function logshare_analyse_crash_direct(instanceId) { + return await invoke('plugin:logs|logs_logshare_analyse_crash_direct', { instanceId }) +} + +export async function logshare_ai_analyze_stored(instanceId, id) { + return await invoke('plugin:logs|logs_logshare_ai_analyze_stored', { instanceId, id }) +} + +export async function logshare_ai_analyze_direct(instanceId) { + return await invoke('plugin:logs|logs_logshare_ai_analyze_direct', { instanceId }) +} + +export async function logshare_delete(id, token) { + return await invoke('plugin:logs|logs_logshare_delete', { id, token }) +} + +export async function list_shared_logs() { + return await invoke('plugin:logs|logs_list_shared_logs') +} + +export async function record_shared_log(log) { + return await invoke('plugin:logs|logs_record_shared_log', { log }) +} + +export async function delete_shared_log(id, token) { + return await invoke('plugin:logs|logs_delete_shared_log', { id, token }) +} + /// Export the censored files and local analysis from an instance's latest run. export async function export_crash_context(instanceId, instanceName) { const timestamp = new Date().toISOString().replace(/[:.]/g, '-') diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 3f506b6..5589109 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -11601,6 +11601,58 @@ "app.library.group.new": {"message": "New group"}, "app.library.selection.new-group": {"message": "New group"}, "app.library.selection.remove-from-group": {"message": "Remove from group"}, + "app.log-share.settings.title": {"message": "Log sharing & AI analysis"}, + "app.log-share.settings.description": {"message": "Share crash diagnostics with LogShare for a structured summary and optional AI analysis. LogShare is preferred; mclo.gs is used as a fallback."}, + "app.log-share.settings.share-provider": {"message": "Log sharing service"}, + "app.log-share.settings.share-provider-description": {"message": "Where the diagnostic link is uploaded when you share it."}, + "app.log-share.settings.ai-source": {"message": "AI analysis source"}, + "app.log-share.settings.ai-source-logshare": {"message": "LogShare AI"}, + "app.log-share.settings.ai-source-custom": {"message": "Custom AI provider"}, + "app.log-share.settings.ai-source-description": {"message": "Both sources send a sanitized and shortened crash context. AI output may be inaccurate."}, + "app.log-share.settings.auto-upload": {"message": "Upload to get a structured summary automatically"}, + "app.log-share.settings.auto-upload-description": {"message": "Only effective in LogShare AI mode."}, + "app.log-share.settings.multi-file": {"message": "Multi-file upload"}, + "app.log-share.settings.multi-file-description": {"message": "Upload latest.log, crash reports, hs_err and launcher logs together."}, + "app.log-share.settings.no-storage": {"message": "Analyze without storing logs"}, + "app.log-share.settings.no-storage-description": {"message": "Submit content directly for analysis and skip the stored upload. Disables the automatic summary."}, + "app.log-share.settings.show-progress": {"message": "Show AI analysis progress"}, + "app.log-share.settings.show-progress-description": {"message": "Display thinking, tool and limit status while LogShare AI is working."}, + "app.log-share.shared-logs.title": {"message": "Shared logs"}, + "app.log-share.shared-logs.description": {"message": "Every diagnostic you have shared is listed here. Delete an entry to remove it from your history and request its removal from the sharing service."}, + "app.log-share.shared-logs.empty": {"message": "No shared logs yet."}, + "app.log-share.shared-logs.delete": {"message": "Delete"}, + "app.log-share.shared-logs.deleting": {"message": "Deleting…"}, + "app.log-share.shared-logs.deleted": {"message": "Shared log removed."}, + "app.log-share.shared-logs.delete-failed": {"message": "Failed to delete the shared log."}, + "app.log-share.shared-logs.truncated": {"message": "Truncated"}, + "app.minecraft-crash.share-fallback": {"message": "LogShare is unavailable, so the diagnostic was shared via mclo.gs."}, + "app.log-share.ai.action": {"message": "Deep AI analysis"}, + "app.log-share.summary.title": {"message": "LogShare analysis"}, + "app.log-share.summary.loading": {"message": "Loading structured summary..."}, + "app.log-share.summary.empty": {"message": "LogShare did not return a structured summary for these logs."}, + "app.log-share.summary.no-storage": {"message": "Structured summary is unavailable while no-storage mode is enabled."}, + "app.log-share.summary.failed": {"message": "Could not load the LogShare summary: {message}"}, + "app.log-share.summary.problems": {"message": "Problems"}, + "app.log-share.summary.information": {"message": "Information"}, + "app.minecraft-crash.logs.loading": {"message": "Loading crash logs..."}, + "app.minecraft-crash.logs.empty": {"message": "No logs from this launch were found."}, + "app.minecraft-crash.logs.failed": {"message": "Could not load the crash logs: {message}"}, + "app.minecraft-crash.log-file.loading": {"message": "Loading log file..."}, + "app.minecraft-crash.log-file.failed": {"message": "Could not load this log file: {message}"}, + "app.log-share.ai.tab": {"message": "LogAgent detailed analysis"}, + "app.log-share.ai.root-cause": {"message": "Core root cause"}, + "app.log-share.ai.confidence": {"message": "Diagnostic confidence"}, + "app.log-share.ai.steps": {"message": "Recommended troubleshooting steps"}, + "app.log-share.ai.evidence": {"message": "Supporting evidence"}, + "app.log-share.ai.thinking": {"message": "Thinking…"}, + "app.log-share.ai.queued": {"message": "Queued, about {position} ahead"}, + "app.log-share.ai.queued-front": {"message": "Queued, waiting for a free slot"}, + "app.log-share.ai.tool": {"message": "Using tool: {name}"}, + "app.log-share.ai.unknown-tool": {"message": "tool"}, + "app.log-share.ai.tool-result": {"message": "Tool result received"}, + "app.log-share.ai.limit": {"message": "Reached tool loop limit"}, + "app.log-share.ai.working": {"message": "Analyzing with LogAgent…"}, + "app.log-share.ai.failed": {"message": "LogAgent analysis failed: {message}"}, "app.shortcut-settings.change-shortcut": {"message": "Change the shortcut for {action}"}, "app.shortcut-settings.enable": {"message": "Enable quick scrolling"}, "app.shortcut-settings.end-action": {"message": "Scroll to the bottom"}, diff --git a/apps/app-frontend/src/locales/zh-CN/index.json b/apps/app-frontend/src/locales/zh-CN/index.json index 68e98b8..5cc97b3 100644 --- a/apps/app-frontend/src/locales/zh-CN/index.json +++ b/apps/app-frontend/src/locales/zh-CN/index.json @@ -11432,6 +11432,58 @@ "app.library.group.new": {"message": "新建分组"}, "app.library.selection.new-group": {"message": "新建分组"}, "app.library.selection.remove-from-group": {"message": "移出分组"}, + "app.log-share.settings.title": {"message": "日志分享与 AI 分析"}, + "app.log-share.settings.description": {"message": "将崩溃诊断分享到 LogShare,获取结构化摘要与可选的 AI 分析。优先使用 LogShare,不可用时回退到 mclo.gs。"}, + "app.log-share.settings.share-provider": {"message": "日志分享服务"}, + "app.log-share.settings.share-provider-description": {"message": "分享诊断链接时上传到的目标服务。"}, + "app.log-share.settings.ai-source": {"message": "AI 分析来源"}, + "app.log-share.settings.ai-source-logshare": {"message": "LogShare AI"}, + "app.log-share.settings.ai-source-custom": {"message": "自定义 AI 服务商"}, + "app.log-share.settings.ai-source-description": {"message": "LogShare AI 在云端分析;自定义则使用下方配置的服务商。"}, + "app.log-share.settings.auto-upload": {"message": "自动上传以获取结构化摘要"}, + "app.log-share.settings.auto-upload-description": {"message": "仅在 LogShare 模式下生效。"}, + "app.log-share.settings.multi-file": {"message": "多文件上传"}, + "app.log-share.settings.multi-file-description": {"message": "同时上传 latest.log、崩溃报告、hs_err 以及启动器日志。"}, + "app.log-share.settings.no-storage": {"message": "不落盘直接分析"}, + "app.log-share.settings.no-storage-description": {"message": "直接提交内容进行分析并跳过存储上传。会关闭自动摘要。"}, + "app.log-share.settings.show-progress": {"message": "显示 AI 分析进度"}, + "app.log-share.settings.show-progress-description": {"message": "展示 LogAgent 运行期间的思考、工具及上限状态。"}, + "app.log-share.shared-logs.title": {"message": "已分享日志"}, + "app.log-share.shared-logs.description": {"message": "你分享过的所有诊断都记录在这里。删除条目会将其从记录中移除,并向分享服务请求删除。"}, + "app.log-share.shared-logs.empty": {"message": "暂无已分享的日志。"}, + "app.log-share.shared-logs.delete": {"message": "删除"}, + "app.log-share.shared-logs.deleting": {"message": "删除中…"}, + "app.log-share.shared-logs.deleted": {"message": "已删除共享日志。"}, + "app.log-share.shared-logs.delete-failed": {"message": "删除已分享日志失败。"}, + "app.log-share.shared-logs.truncated": {"message": "已截断"}, + "app.minecraft-crash.share-fallback": {"message": "LogShare 不可用,已改用 mclo.gs 分享诊断信息。"}, + "app.log-share.ai.action": {"message": "AI 深度分析"}, + "app.log-share.summary.title": {"message": "LogShare 分析"}, + "app.log-share.summary.loading": {"message": "正在加载结构化摘要…"}, + "app.log-share.summary.empty": {"message": "LogShare 未返回这些日志的结构化摘要。"}, + "app.log-share.summary.no-storage": {"message": "启用不落盘模式时无法获取结构化摘要。"}, + "app.log-share.summary.failed": {"message": "无法加载 LogShare 摘要:{message}"}, + "app.log-share.summary.problems": {"message": "问题"}, + "app.log-share.summary.information": {"message": "信息"}, + "app.minecraft-crash.logs.loading": {"message": "正在加载崩溃日志…"}, + "app.minecraft-crash.logs.empty": {"message": "未找到本次启动产生的日志。"}, + "app.minecraft-crash.logs.failed": {"message": "无法加载崩溃日志:{message}"}, + "app.minecraft-crash.log-file.loading": {"message": "正在加载日志文件…"}, + "app.minecraft-crash.log-file.failed": {"message": "无法加载此日志文件:{message}"}, + "app.log-share.ai.tab": {"message": "LogAgent 深度分析"}, + "app.log-share.ai.root-cause": {"message": "核心根因"}, + "app.log-share.ai.confidence": {"message": "诊断置信度"}, + "app.log-share.ai.steps": {"message": "推荐排障步骤"}, + "app.log-share.ai.evidence": {"message": "支撑证据链"}, + "app.log-share.ai.thinking": {"message": "思考中…"}, + "app.log-share.ai.queued": {"message": "排队中,前方约 {position} 个任务"}, + "app.log-share.ai.queued-front": {"message": "排队中,正在等待空闲槽位"}, + "app.log-share.ai.tool": {"message": "正在使用工具:{name}"}, + "app.log-share.ai.unknown-tool": {"message": "工具"}, + "app.log-share.ai.tool-result": {"message": "已收到工具结果"}, + "app.log-share.ai.limit": {"message": "已达到工具调用上限"}, + "app.log-share.ai.working": {"message": "正在使用 LogAgent 分析…"}, + "app.log-share.ai.failed": {"message": "LogAgent 分析失败:{message}"}, "app.shortcut-settings.change-shortcut": {"message": "更改「{action}」的快捷键"}, "app.shortcut-settings.enable": {"message": "启用快捷滚动"}, "app.shortcut-settings.end-action": {"message": "滚动到页面底部"}, 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..13b2514 100644 --- a/apps/app/src/api/logs.rs +++ b/apps/app/src/api/logs.rs @@ -4,7 +4,8 @@ use std::path::PathBuf; use theseus::logs::LogType; use theseus::logs::{ self, CensoredString, CrashAnalysis, CrashAnalysisAiExplanation, - CrashAnalysisAiSettings, LatestLogCursor, LiveLogSnapshot, Logs, + CrashAnalysisAiSettings, LatestLogCursor, LiveLogSnapshot, + LogShareSettings, LogShareUploadResponse, Logs, SharedLog, }; /* @@ -34,6 +35,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 +184,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 d55fc46..8300e9e 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; diff --git a/packages/ui/src/components/base/buttons/Button.vue b/packages/ui/src/components/base/buttons/Button.vue index 6d9f4e2..613868d 100644 --- a/packages/ui/src/components/base/buttons/Button.vue +++ b/packages/ui/src/components/base/buttons/Button.vue @@ -7,6 +7,8 @@ import type { ButtonProps } from './types' const props = withDefaults(defineProps(), { 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" > diff --git a/packages/ui/src/components/base/buttons/ButtonFrame.vue b/packages/ui/src/components/base/buttons/ButtonFrame.vue index d24b75c..c2b2bd8 100644 --- a/packages/ui/src/components/base/buttons/ButtonFrame.vue +++ b/packages/ui/src/components/base/buttons/ButtonFrame.vue @@ -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 = { + '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 = { } const iconOnlySizeClasses: Record = { + '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 = { } const typeClasses: Record = { - 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 = { @@ -67,7 +82,17 @@ const colorVariables: Record = { 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 = { + 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, )` 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); } diff --git a/packages/ui/src/components/base/buttons/types.ts b/packages/ui/src/components/base/buttons/types.ts index dc17f92..9ded4c9 100644 --- a/packages/ui/src/components/base/buttons/types.ts +++ b/packages/ui/src/components/base/buttons/types.ts @@ -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 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 diff --git a/packages/ui/src/layouts/shared/console/index.ts b/packages/ui/src/layouts/shared/console/index.ts index 2a13af5..e7924c5 100644 --- a/packages/ui/src/layouts/shared/console/index.ts +++ b/packages/ui/src/layouts/shared/console/index.ts @@ -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' \ No newline at end of file diff --git a/packages/ui/src/utils/log-share.ts b/packages/ui/src/utils/log-share.ts index 0fd6bf9..fce5136 100644 --- a/packages/ui/src/utils/log-share.ts +++ b/packages/ui/src/utils/log-share.ts @@ -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 { + 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 { const uploadContent = truncateLogToMaxBytes(content, LOG_SHARE_MAX_BYTES) const truncated = uploadContent !== content diff --git a/scripts/axolotl/i18n-check.mjs b/scripts/axolotl/i18n-check.mjs index 5625955..9559f96 100644 --- a/scripts/axolotl/i18n-check.mjs +++ b/scripts/axolotl/i18n-check.mjs @@ -30,6 +30,7 @@ const allowedUntranslatedMessages = new Set([ 'Cleanroom', 'LiteLoader', 'LogShare.CN', + 'LogShare AI', 'NeoForge', 'OptiFine', 'Quilt',