From c71c220446e1b606791ae30e1f49b642236ef2ae Mon Sep 17 00:00:00 2001 From: Ap_Tx <3045929398@qq.com> Date: Sun, 11 Oct 2026 14:05:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B4=A9=E6=BA=83=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=E9=9B=86=E6=88=90=E6=97=A5=E5=BF=97=E5=88=86=E4=BA=AB=E4=B8=8E?= =?UTF-8?q?=20AI=20=E5=88=86=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将崩溃弹窗替换为双栏布局:左侧展示诊断摘要与分享入口, 右侧提供日志文件浏览器与 LogAgent 流式分析输出。 - MinecraftCrashModal 改用新版的分享票据、结构化摘要与 AI 事件流 - 新增 minecraft-crash-logs 工具函数,用于筛选与合并崩溃日志 - 事件层新增 logshare_ai_listener 监听 AI 分析进度 - log-share 工具更新为支持 provider 路由的新版 - Button 组件对齐新版(补 icon-only、circular、2xs 与 chip/highlight 变体) - console 导出 LogViewport,供日志浏览器复用 - 补齐 en-US / zh-CN 相关文案 --- .../src/components/ui/MinecraftCrashModal.vue | 1156 +++++++++++++++-- .../src/components/ui/minecraft-crash-logs.ts | 62 + apps/app-frontend/src/helpers/events.js | 4 + .../app-frontend/src/locales/en-US/index.json | 30 +- .../app-frontend/src/locales/zh-CN/index.json | 30 +- .../ui/src/components/base/buttons/Button.vue | 5 + .../components/base/buttons/ButtonFrame.vue | 63 +- .../ui/src/components/base/buttons/types.ts | 90 +- .../ui/src/layouts/shared/console/index.ts | 3 +- packages/ui/src/utils/log-share.ts | 24 +- 10 files changed, 1323 insertions(+), 144 deletions(-) create mode 100644 apps/app-frontend/src/components/ui/minecraft-crash-logs.ts 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/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/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 741cadd..4c576dc 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -11624,5 +11624,33 @@ "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.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}"} } diff --git a/apps/app-frontend/src/locales/zh-CN/index.json b/apps/app-frontend/src/locales/zh-CN/index.json index c867159..ea424a7 100644 --- a/apps/app-frontend/src/locales/zh-CN/index.json +++ b/apps/app-frontend/src/locales/zh-CN/index.json @@ -11455,5 +11455,33 @@ "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.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}"} } 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