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
+2717
-143
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -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<CrashLogFile['log_type']>()
|
||||||
|
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')
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import { ref, watch } from 'vue'
|
|||||||
import { get, set } from '@/helpers/settings.ts'
|
import { get, set } from '@/helpers/settings.ts'
|
||||||
|
|
||||||
import CrashAnalysisAISettings from './CrashAnalysisAISettings.vue'
|
import CrashAnalysisAISettings from './CrashAnalysisAISettings.vue'
|
||||||
|
import LogShareSettings from './LogShareSettings.vue'
|
||||||
|
import SharedLogsSettings from './SharedLogsSettings.vue'
|
||||||
import SettingsRow from './SettingsRow.vue'
|
import SettingsRow from './SettingsRow.vue'
|
||||||
import SettingsSection from './SettingsSection.vue'
|
import SettingsSection from './SettingsSection.vue'
|
||||||
|
|
||||||
@@ -391,5 +393,7 @@ watch(
|
|||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<CrashAnalysisAISettings />
|
<CrashAnalysisAISettings />
|
||||||
|
<LogShareSettings />
|
||||||
|
<SharedLogsSettings />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Combobox, defineMessages, injectNotificationManager, Toggle, useVIntl } from '@modrinth/ui'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import { type AIState, getAIState, sharedAIState } from '@/helpers/ai'
|
||||||
|
import {
|
||||||
|
get_crash_analysis_ai_settings,
|
||||||
|
get_log_share_settings,
|
||||||
|
update_crash_analysis_ai_settings,
|
||||||
|
update_log_share_settings,
|
||||||
|
} from '@/helpers/logs'
|
||||||
|
|
||||||
|
import SettingsRow from './SettingsRow.vue'
|
||||||
|
import SettingsSection from './SettingsSection.vue'
|
||||||
|
|
||||||
|
type CrashAISettings = {
|
||||||
|
enabled: boolean
|
||||||
|
provider_id: string
|
||||||
|
model_id: string
|
||||||
|
ai_source: 'logshare' | 'custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogShareSettings = {
|
||||||
|
share_provider: 'logshare' | 'mclogs'
|
||||||
|
ai_source: string
|
||||||
|
auto_upload: boolean
|
||||||
|
multi_file: boolean
|
||||||
|
no_storage: boolean
|
||||||
|
show_progress: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const { handleError } = injectNotificationManager()
|
||||||
|
const settings = ref<LogShareSettings>({
|
||||||
|
share_provider: 'logshare',
|
||||||
|
ai_source: 'logshare',
|
||||||
|
auto_upload: true,
|
||||||
|
multi_file: true,
|
||||||
|
no_storage: false,
|
||||||
|
show_progress: true,
|
||||||
|
})
|
||||||
|
const aiSettings = ref<CrashAISettings>({
|
||||||
|
enabled: false,
|
||||||
|
provider_id: '',
|
||||||
|
model_id: '',
|
||||||
|
ai_source: 'logshare',
|
||||||
|
})
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: { id: 'app.log-share.settings.title', defaultMessage: 'Log sharing & AI analysis' },
|
||||||
|
description: {
|
||||||
|
id: 'app.log-share.settings.description',
|
||||||
|
defaultMessage:
|
||||||
|
'Share crash diagnostics with LogShare for a structured summary and optional AI analysis. LogShare is preferred; mclo.gs is used as a fallback.',
|
||||||
|
},
|
||||||
|
shareProvider: {
|
||||||
|
id: 'app.log-share.settings.share-provider',
|
||||||
|
defaultMessage: 'Log sharing service',
|
||||||
|
},
|
||||||
|
shareProviderDescription: {
|
||||||
|
id: 'app.log-share.settings.share-provider-description',
|
||||||
|
defaultMessage: 'Where the diagnostic link is uploaded when you share it.',
|
||||||
|
},
|
||||||
|
aiSource: { id: 'app.log-share.settings.ai-source', defaultMessage: 'AI analysis source' },
|
||||||
|
aiSourceLogShare: {
|
||||||
|
id: 'app.log-share.settings.ai-source-logshare',
|
||||||
|
defaultMessage: 'LogShare AI',
|
||||||
|
},
|
||||||
|
aiSourceCustom: {
|
||||||
|
id: 'app.log-share.settings.ai-source-custom',
|
||||||
|
defaultMessage: 'Custom AI provider',
|
||||||
|
},
|
||||||
|
aiSourceDescription: {
|
||||||
|
id: 'app.log-share.settings.ai-source-description',
|
||||||
|
defaultMessage:
|
||||||
|
'Both sources send a sanitized and shortened crash context. AI output may be inaccurate.',
|
||||||
|
},
|
||||||
|
enabled: {
|
||||||
|
id: 'app.crash-analysis.ai.settings.enabled',
|
||||||
|
defaultMessage: 'Enable AI explanations',
|
||||||
|
},
|
||||||
|
enabledDescription: {
|
||||||
|
id: 'app.crash-analysis.ai.settings.enabled-description',
|
||||||
|
defaultMessage: 'Local rule-based crash analysis remains available without AI.',
|
||||||
|
},
|
||||||
|
provider: { id: 'app.crash-analysis.ai.settings.provider', defaultMessage: 'AI provider' },
|
||||||
|
model: { id: 'app.crash-analysis.ai.settings.model', defaultMessage: 'Text model' },
|
||||||
|
noProviders: {
|
||||||
|
id: 'app.crash-analysis.ai.settings.no-providers',
|
||||||
|
defaultMessage: 'Enable a provider and a text model above before using AI crash explanations.',
|
||||||
|
},
|
||||||
|
autoUpload: {
|
||||||
|
id: 'app.log-share.settings.auto-upload',
|
||||||
|
defaultMessage: 'Upload to get a structured summary automatically',
|
||||||
|
},
|
||||||
|
autoUploadDescription: {
|
||||||
|
id: 'app.log-share.settings.auto-upload-description',
|
||||||
|
defaultMessage: 'Only effective in LogShare AI mode.',
|
||||||
|
},
|
||||||
|
multiFile: { id: 'app.log-share.settings.multi-file', defaultMessage: 'Multi-file upload' },
|
||||||
|
multiFileDescription: {
|
||||||
|
id: 'app.log-share.settings.multi-file-description',
|
||||||
|
defaultMessage: 'Upload latest.log, crash reports, hs_err and launcher logs together.',
|
||||||
|
},
|
||||||
|
noStorage: {
|
||||||
|
id: 'app.log-share.settings.no-storage',
|
||||||
|
defaultMessage: 'Analyze without storing logs',
|
||||||
|
},
|
||||||
|
noStorageDescription: {
|
||||||
|
id: 'app.log-share.settings.no-storage-description',
|
||||||
|
defaultMessage:
|
||||||
|
'Submit content directly for analysis and skip the stored upload. Disables the automatic summary.',
|
||||||
|
},
|
||||||
|
showProgress: {
|
||||||
|
id: 'app.log-share.settings.show-progress',
|
||||||
|
defaultMessage: 'Show AI analysis progress',
|
||||||
|
},
|
||||||
|
showProgressDescription: {
|
||||||
|
id: 'app.log-share.settings.show-progress-description',
|
||||||
|
defaultMessage: 'Display thinking, tool and limit status while LogShare AI is working.',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emptyState: AIState = { settings: { enabled: false }, catalog_source: '', providers: [] }
|
||||||
|
const aiState = computed(() => sharedAIState.value ?? emptyState)
|
||||||
|
const providers = computed(() =>
|
||||||
|
aiState.value.providers.filter(
|
||||||
|
(provider) => provider.enabled && provider.models.some((model) => model.enabled),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const providerOptions = computed(() =>
|
||||||
|
providers.value.map((provider) => ({
|
||||||
|
value: provider.provider_id,
|
||||||
|
label: provider.custom_name || provider.provider_id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const modelOptions = computed(
|
||||||
|
() =>
|
||||||
|
providers.value
|
||||||
|
.find((provider) => provider.provider_id === aiSettings.value.provider_id)
|
||||||
|
?.models.filter((model) => model.enabled)
|
||||||
|
.map((model) => ({ value: model.id, label: model.name || model.id })) ?? [],
|
||||||
|
)
|
||||||
|
|
||||||
|
const isCustom = computed(() => aiSettings.value.ai_source === 'custom')
|
||||||
|
const noStorageEnabled = computed(() => aiSettings.value.ai_source === 'logshare')
|
||||||
|
|
||||||
|
const shareProviderOptions = [
|
||||||
|
{ value: 'logshare', label: 'LogShare' },
|
||||||
|
{ value: 'mclogs', label: 'mclo.gs' },
|
||||||
|
]
|
||||||
|
const aiSourceOptions = [
|
||||||
|
{ value: 'logshare', label: formatMessage(messages.aiSourceLogShare) },
|
||||||
|
{ value: 'custom', label: formatMessage(messages.aiSourceCustom) },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function saveShare(next: LogShareSettings): Promise<void> {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await update_log_share_settings(next)
|
||||||
|
settings.value = next
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAI(next: CrashAISettings): Promise<void> {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await update_crash_analysis_ai_settings(next)
|
||||||
|
aiSettings.value = next
|
||||||
|
settings.value = { ...settings.value, ai_source: next.ai_source }
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateShare(patch: Partial<LogShareSettings>): void {
|
||||||
|
void saveShare({ ...settings.value, ...patch })
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectShareProvider(value: unknown): void {
|
||||||
|
const provider = value === 'mclogs' ? 'mclogs' : 'logshare'
|
||||||
|
updateShare({ share_provider: provider, ai_source: aiSettings.value.ai_source })
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAiSource(value: unknown): void {
|
||||||
|
const source = value === 'custom' ? 'custom' : 'logshare'
|
||||||
|
void saveAI({ ...aiSettings.value, ai_source: source })
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEnabled(enabled: boolean): void {
|
||||||
|
void saveAI({ ...aiSettings.value, enabled })
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProvider(providerId: string): void {
|
||||||
|
const provider = providers.value.find((item) => item.provider_id === providerId)
|
||||||
|
const modelId = provider?.models.find((model) => model.enabled)?.id ?? ''
|
||||||
|
void saveAI({ ...aiSettings.value, provider_id: providerId, model_id: modelId })
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateModel(modelId: string): void {
|
||||||
|
void saveAI({ ...aiSettings.value, model_id: modelId })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const [nextShare, nextAI] = await Promise.all([
|
||||||
|
get_log_share_settings(),
|
||||||
|
get_crash_analysis_ai_settings(),
|
||||||
|
getAIState(),
|
||||||
|
])
|
||||||
|
settings.value = { ...nextShare, ai_source: nextAI.ai_source }
|
||||||
|
aiSettings.value = nextAI
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SettingsSection
|
||||||
|
v-if="!loading"
|
||||||
|
id="settings-target-log-share"
|
||||||
|
:title="formatMessage(messages.title)"
|
||||||
|
:description="formatMessage(messages.description)"
|
||||||
|
>
|
||||||
|
<SettingsRow>
|
||||||
|
<template #label>{{ formatMessage(messages.shareProvider) }}</template>
|
||||||
|
<template #description>{{ formatMessage(messages.shareProviderDescription) }}</template>
|
||||||
|
<template #control>
|
||||||
|
<Combobox
|
||||||
|
id="log-share-provider"
|
||||||
|
:model-value="settings.share_provider"
|
||||||
|
:options="shareProviderOptions"
|
||||||
|
:disabled="saving"
|
||||||
|
@update:model-value="selectShareProvider($event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow>
|
||||||
|
<template #label>{{ formatMessage(messages.aiSource) }}</template>
|
||||||
|
<template #description>{{ formatMessage(messages.aiSourceDescription) }}</template>
|
||||||
|
<template #control>
|
||||||
|
<Combobox
|
||||||
|
id="log-share-ai-source"
|
||||||
|
:model-value="aiSettings.ai_source"
|
||||||
|
:options="aiSourceOptions"
|
||||||
|
:disabled="saving"
|
||||||
|
@update:model-value="selectAiSource($event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</SettingsRow>
|
||||||
|
<template v-if="isCustom">
|
||||||
|
<SettingsRow>
|
||||||
|
<template #label>{{ formatMessage(messages.enabled) }}</template>
|
||||||
|
<template #description>{{ formatMessage(messages.enabledDescription) }}</template>
|
||||||
|
<template #control>
|
||||||
|
<Toggle
|
||||||
|
id="crash-analysis-ai-enabled"
|
||||||
|
:model-value="aiSettings.enabled"
|
||||||
|
:disabled="saving || !aiState.settings.enabled || !providers.length"
|
||||||
|
@update:model-value="updateEnabled(!!$event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</SettingsRow>
|
||||||
|
<template v-if="providers.length">
|
||||||
|
<SettingsRow>
|
||||||
|
<template #label>{{ formatMessage(messages.provider) }}</template>
|
||||||
|
<template #control>
|
||||||
|
<Combobox
|
||||||
|
id="crash-analysis-ai-provider"
|
||||||
|
:model-value="aiSettings.provider_id"
|
||||||
|
:options="providerOptions"
|
||||||
|
:disabled="saving"
|
||||||
|
@update:model-value="updateProvider(String($event))"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow>
|
||||||
|
<template #label>{{ formatMessage(messages.model) }}</template>
|
||||||
|
<template #control>
|
||||||
|
<Combobox
|
||||||
|
id="crash-analysis-ai-model"
|
||||||
|
:model-value="aiSettings.model_id"
|
||||||
|
:options="modelOptions"
|
||||||
|
:disabled="saving || !aiSettings.provider_id"
|
||||||
|
@update:model-value="updateModel(String($event))"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</SettingsRow>
|
||||||
|
</template>
|
||||||
|
<p v-else class="m-0 p-4 text-sm text-[var(--color-text-tertiary)]">
|
||||||
|
{{ formatMessage(messages.noProviders) }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<SettingsRow>
|
||||||
|
<template #label>{{ formatMessage(messages.autoUpload) }}</template>
|
||||||
|
<template #description>{{ formatMessage(messages.autoUploadDescription) }}</template>
|
||||||
|
<template #control>
|
||||||
|
<Toggle
|
||||||
|
id="log-share-auto-upload"
|
||||||
|
:model-value="settings.auto_upload"
|
||||||
|
:disabled="saving || !noStorageEnabled || settings.no_storage"
|
||||||
|
@update:model-value="updateShare({ auto_upload: !!$event })"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow>
|
||||||
|
<template #label>{{ formatMessage(messages.multiFile) }}</template>
|
||||||
|
<template #description>{{ formatMessage(messages.multiFileDescription) }}</template>
|
||||||
|
<template #control>
|
||||||
|
<Toggle
|
||||||
|
id="log-share-multi-file"
|
||||||
|
:model-value="settings.multi_file"
|
||||||
|
:disabled="saving"
|
||||||
|
@update:model-value="updateShare({ multi_file: !!$event })"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow>
|
||||||
|
<template #label>{{ formatMessage(messages.noStorage) }}</template>
|
||||||
|
<template #description>{{ formatMessage(messages.noStorageDescription) }}</template>
|
||||||
|
<template #control>
|
||||||
|
<Toggle
|
||||||
|
id="log-share-no-storage"
|
||||||
|
:model-value="settings.no_storage"
|
||||||
|
:disabled="saving || !noStorageEnabled"
|
||||||
|
@update:model-value="updateShare({ no_storage: !!$event })"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow>
|
||||||
|
<template #label>{{ formatMessage(messages.showProgress) }}</template>
|
||||||
|
<template #description>{{ formatMessage(messages.showProgressDescription) }}</template>
|
||||||
|
<template #control>
|
||||||
|
<Toggle
|
||||||
|
id="log-share-show-progress"
|
||||||
|
:model-value="settings.show_progress"
|
||||||
|
:disabled="saving"
|
||||||
|
@update:model-value="updateShare({ show_progress: !!$event })"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsSection>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Button, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import { delete_shared_log, list_shared_logs } from '@/helpers/logs'
|
||||||
|
|
||||||
|
import SettingsRow from './SettingsRow.vue'
|
||||||
|
import SettingsSection from './SettingsSection.vue'
|
||||||
|
|
||||||
|
type SharedLog = {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
raw: string
|
||||||
|
token: string
|
||||||
|
provider: string
|
||||||
|
instance_id: string | null
|
||||||
|
instance_name: string | null
|
||||||
|
truncated: boolean
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const { addNotification, handleError } = injectNotificationManager()
|
||||||
|
const logs = ref<SharedLog[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const deletingId = ref<string | null>(null)
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: { id: 'app.log-share.shared-logs.title', defaultMessage: 'Shared logs' },
|
||||||
|
description: {
|
||||||
|
id: 'app.log-share.shared-logs.description',
|
||||||
|
defaultMessage:
|
||||||
|
'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.',
|
||||||
|
},
|
||||||
|
empty: { id: 'app.log-share.shared-logs.empty', defaultMessage: 'No shared logs yet.' },
|
||||||
|
delete: { id: 'app.log-share.shared-logs.delete', defaultMessage: 'Delete' },
|
||||||
|
deleting: { id: 'app.log-share.shared-logs.deleting', defaultMessage: 'Deleting…' },
|
||||||
|
deleted: {
|
||||||
|
id: 'app.log-share.shared-logs.deleted',
|
||||||
|
defaultMessage: 'Shared log removed.',
|
||||||
|
},
|
||||||
|
deleteFailed: {
|
||||||
|
id: 'app.log-share.shared-logs.delete-failed',
|
||||||
|
defaultMessage: 'Failed to delete the shared log.',
|
||||||
|
},
|
||||||
|
truncated: { id: 'app.log-share.shared-logs.truncated', defaultMessage: 'Truncated' },
|
||||||
|
})
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
try {
|
||||||
|
logs.value = await list_shared_logs()
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(log: SharedLog): Promise<void> {
|
||||||
|
if (deletingId.value) return
|
||||||
|
deletingId.value = log.id
|
||||||
|
try {
|
||||||
|
await delete_shared_log(log.id, log.token)
|
||||||
|
logs.value = logs.value.filter((item) => item.id !== log.id)
|
||||||
|
addNotification({ title: formatMessage(messages.deleted), type: 'success' })
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
addNotification({ title: formatMessage(messages.deleteFailed), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
deletingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(timestamp: number): string {
|
||||||
|
return new Date(timestamp * 1000).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refresh)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SettingsSection
|
||||||
|
id="settings-target-shared-logs"
|
||||||
|
:title="formatMessage(messages.title)"
|
||||||
|
:description="formatMessage(messages.description)"
|
||||||
|
>
|
||||||
|
<SettingsRow stacked>
|
||||||
|
<template #control>
|
||||||
|
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||||
|
<p v-if="loading" class="m-0 p-2 text-sm text-[var(--color-text-tertiary)]">…</p>
|
||||||
|
<p v-else-if="!logs.length" class="m-0 p-2 text-sm text-[var(--color-text-tertiary)]">
|
||||||
|
{{ formatMessage(messages.empty) }}
|
||||||
|
</p>
|
||||||
|
<ul v-else class="m-0 flex max-h-72 list-none flex-col gap-1 overflow-y-auto p-2">
|
||||||
|
<li
|
||||||
|
v-for="log in logs"
|
||||||
|
:key="log.id"
|
||||||
|
class="flex items-center gap-3 rounded-lg bg-surface-2 px-3 py-2"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
:href="log.url || `https://logshare.cn/${log.id}`"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="min-w-0 flex-1 truncate text-[var(--color-text-default)] underline"
|
||||||
|
>
|
||||||
|
{{ log.url || log.id }}
|
||||||
|
</a>
|
||||||
|
<span class="shrink-0 text-xs text-[var(--color-text-tertiary)]">
|
||||||
|
{{ log.provider }}
|
||||||
|
<template v-if="log.truncated">
|
||||||
|
· {{ formatMessage(messages.truncated) }}
|
||||||
|
</template>
|
||||||
|
<template v-if="log.instance_name"> · {{ log.instance_name }} </template>
|
||||||
|
· {{ formatDate(log.created_at) }}
|
||||||
|
</span>
|
||||||
|
<Button type="outlined" :disabled="deletingId === log.id" @click="remove(log)"
|
||||||
|
>{{
|
||||||
|
deletingId === log.id
|
||||||
|
? formatMessage(messages.deleting)
|
||||||
|
: formatMessage(messages.delete)
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsSection>
|
||||||
|
</template>
|
||||||
@@ -160,6 +160,10 @@ export async function notification_listener(callback) {
|
|||||||
return await listen('notification', (event) => callback(event.payload))
|
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
|
/// Payload for the 'log' event
|
||||||
/*
|
/*
|
||||||
LogPayload {
|
LogPayload {
|
||||||
|
|||||||
@@ -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 })
|
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 the censored files and local analysis from an instance's latest run.
|
||||||
export async function export_crash_context(instanceId, instanceName) {
|
export async function export_crash_context(instanceId, instanceName) {
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||||
|
|||||||
@@ -11601,6 +11601,58 @@
|
|||||||
"app.library.group.new": {"message": "New group"},
|
"app.library.group.new": {"message": "New group"},
|
||||||
"app.library.selection.new-group": {"message": "New group"},
|
"app.library.selection.new-group": {"message": "New group"},
|
||||||
"app.library.selection.remove-from-group": {"message": "Remove from 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.change-shortcut": {"message": "Change the shortcut for {action}"},
|
||||||
"app.shortcut-settings.enable": {"message": "Enable quick scrolling"},
|
"app.shortcut-settings.enable": {"message": "Enable quick scrolling"},
|
||||||
"app.shortcut-settings.end-action": {"message": "Scroll to the bottom"},
|
"app.shortcut-settings.end-action": {"message": "Scroll to the bottom"},
|
||||||
|
|||||||
@@ -11432,6 +11432,58 @@
|
|||||||
"app.library.group.new": {"message": "新建分组"},
|
"app.library.group.new": {"message": "新建分组"},
|
||||||
"app.library.selection.new-group": {"message": "新建分组"},
|
"app.library.selection.new-group": {"message": "新建分组"},
|
||||||
"app.library.selection.remove-from-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.change-shortcut": {"message": "更改「{action}」的快捷键"},
|
||||||
"app.shortcut-settings.enable": {"message": "启用快捷滚动"},
|
"app.shortcut-settings.enable": {"message": "启用快捷滚动"},
|
||||||
"app.shortcut-settings.end-action": {"message": "滚动到页面底部"},
|
"app.shortcut-settings.end-action": {"message": "滚动到页面底部"},
|
||||||
|
|||||||
@@ -300,6 +300,17 @@ fn main() {
|
|||||||
"logs_explain_crash_with_ai",
|
"logs_explain_crash_with_ai",
|
||||||
"logs_undo_added_mod",
|
"logs_undo_added_mod",
|
||||||
"logs_export_crash_context",
|
"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(
|
.default_permission(
|
||||||
DefaultPermissionRule::AllowAllCommands,
|
DefaultPermissionRule::AllowAllCommands,
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ use std::path::PathBuf;
|
|||||||
use theseus::logs::LogType;
|
use theseus::logs::LogType;
|
||||||
use theseus::logs::{
|
use theseus::logs::{
|
||||||
self, CensoredString, CrashAnalysis, CrashAnalysisAiExplanation,
|
self, CensoredString, CrashAnalysis, CrashAnalysisAiExplanation,
|
||||||
CrashAnalysisAiSettings, LatestLogCursor, LiveLogSnapshot, Logs,
|
CrashAnalysisAiSettings, LatestLogCursor, LiveLogSnapshot,
|
||||||
|
LogShareSettings, LogShareUploadResponse, Logs, SharedLog,
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -34,6 +35,17 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
|||||||
logs_explain_crash_with_ai,
|
logs_explain_crash_with_ai,
|
||||||
logs_undo_added_mod,
|
logs_undo_added_mod,
|
||||||
logs_export_crash_context,
|
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()
|
.build()
|
||||||
}
|
}
|
||||||
@@ -172,3 +184,81 @@ pub async fn logs_export_crash_context(
|
|||||||
writer.close().await.map_err(crate::api::utils::zip_error)?;
|
writer.close().await.map_err(crate::api::utils::zip_error)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the log share settings.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn logs_get_log_share_settings() -> Result<LogShareSettings> {
|
||||||
|
Ok(logs::get_log_share_settings().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the log share settings.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn logs_update_log_share_settings(
|
||||||
|
settings: LogShareSettings,
|
||||||
|
) -> Result<()> {
|
||||||
|
Ok(logs::update_log_share_settings(settings).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload the latest run's crash logs to LogShare.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn logs_logshare_upload_crash(
|
||||||
|
instance_id: &str,
|
||||||
|
) -> Result<LogShareUploadResponse> {
|
||||||
|
Ok(logs::upload_crash(instance_id).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch structured insights for a shared log.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn logs_logshare_get_insights(id: &str) -> Result<serde_json::Value> {
|
||||||
|
Ok(logs::get_insights(id).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Analyse the latest run's crash logs directly.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn logs_logshare_analyse_crash_direct(
|
||||||
|
instance_id: &str,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
Ok(logs::analyse_crash_direct(instance_id).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream an AI analysis for a stored shared log.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn logs_logshare_ai_analyze_stored(
|
||||||
|
instance_id: &str,
|
||||||
|
id: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
Ok(logs::ai_analyze_stored(instance_id, id).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream an AI analysis for the latest run's crash logs.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn logs_logshare_ai_analyze_direct(
|
||||||
|
instance_id: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
Ok(logs::ai_analyze_direct(instance_id).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a shared log remotely and locally.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn logs_logshare_delete(id: &str, token: &str) -> Result<()> {
|
||||||
|
logs::delete_log(id, token).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List locally recorded shared logs.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn logs_list_shared_logs() -> Result<Vec<SharedLog>> {
|
||||||
|
Ok(logs::list_shared_logs().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a shared log locally.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn logs_record_shared_log(log: SharedLog) -> Result<()> {
|
||||||
|
Ok(logs::record_shared_log(log).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a shared log record locally and remotely.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn logs_delete_shared_log(id: String, token: String) -> Result<()> {
|
||||||
|
Ok(logs::delete_shared_log(id, token).await?)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
CREATE TABLE log_share_settings (
|
||||||
|
id INTEGER NOT NULL CHECK (id = 0),
|
||||||
|
share_provider TEXT NOT NULL DEFAULT 'logshare',
|
||||||
|
ai_source TEXT NOT NULL DEFAULT 'logshare',
|
||||||
|
auto_upload INTEGER NOT NULL DEFAULT TRUE,
|
||||||
|
multi_file INTEGER NOT NULL DEFAULT TRUE,
|
||||||
|
no_storage INTEGER NOT NULL DEFAULT FALSE,
|
||||||
|
show_progress INTEGER NOT NULL DEFAULT TRUE,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO log_share_settings (id) VALUES (0);
|
||||||
|
|
||||||
|
CREATE TABLE shared_logs (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
raw TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL DEFAULT 'logshare',
|
||||||
|
instance_id TEXT NULL,
|
||||||
|
instance_name TEXT NULL,
|
||||||
|
truncated INTEGER NOT NULL DEFAULT FALSE,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX shared_logs_created_at_idx ON shared_logs (created_at DESC);
|
||||||
@@ -27,6 +27,15 @@ pub use crash_analysis::{
|
|||||||
update_crash_analysis_ai_settings,
|
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)]
|
#[derive(Serialize, Debug)]
|
||||||
pub struct Logs {
|
pub struct Logs {
|
||||||
pub log_type: LogType,
|
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
|
.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)]
|
#[serde(flatten)]
|
||||||
pub event: LogEvent,
|
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)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ pub use api::*;
|
|||||||
pub use error::*;
|
pub use error::*;
|
||||||
pub use event::{
|
pub use event::{
|
||||||
EventState, LoadingBar, LoadingBarType, emit::emit_loading,
|
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::filter_log_contents;
|
||||||
pub use logger::start_logger;
|
pub use logger::start_logger;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type { ButtonProps } from './types'
|
|||||||
const props = withDefaults(defineProps<ButtonProps>(), {
|
const props = withDefaults(defineProps<ButtonProps>(), {
|
||||||
type: 'base',
|
type: 'base',
|
||||||
size: 'md',
|
size: 'md',
|
||||||
|
iconOnly: false,
|
||||||
|
circular: false,
|
||||||
nativeType: 'button',
|
nativeType: 'button',
|
||||||
disabled: false,
|
disabled: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -26,8 +28,11 @@ defineExpose({ element })
|
|||||||
:color="props.color"
|
:color="props.color"
|
||||||
:size="props.size"
|
:size="props.size"
|
||||||
:interaction="props.interaction"
|
:interaction="props.interaction"
|
||||||
|
:icon-only="props.iconOnly"
|
||||||
|
:circular="props.circular"
|
||||||
:native-type="props.nativeType"
|
:native-type="props.nativeType"
|
||||||
:disabled="props.disabled || props.loading"
|
:disabled="props.disabled || props.loading"
|
||||||
|
:aria-label="props.iconOnly ? props.label : undefined"
|
||||||
:aria-busy="props.loading || undefined"
|
:aria-busy="props.loading || undefined"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import type {
|
|||||||
const baseClasses = [
|
const baseClasses = [
|
||||||
// Base
|
// Base
|
||||||
'relative inline-flex min-w-0 shrink-0 items-center justify-center',
|
'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
|
// Interactions
|
||||||
'touch-manipulation cursor-pointer select-none transition-[background-color,color,box-shadow,filter,opacity,transform] duration-150 ease-out',
|
'touch-manipulation cursor-pointer select-none transition-[background-color,color,box-shadow,filter,opacity,transform] duration-150 ease-out',
|
||||||
'enabled:active:scale-[0.97]',
|
'enabled:active:scale-[0.97]',
|
||||||
@@ -26,6 +29,8 @@ const baseClasses = [
|
|||||||
].join(' ')
|
].join(' ')
|
||||||
|
|
||||||
const sizeClasses: Record<ButtonSize, string> = {
|
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',
|
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',
|
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',
|
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> = {
|
const iconOnlySizeClasses: Record<ButtonSize, string> = {
|
||||||
|
'2xs': 'min-w-6 w-6 !px-0',
|
||||||
xs: 'min-w-7 w-7 !px-0',
|
xs: 'min-w-7 w-7 !px-0',
|
||||||
sm: 'min-w-8 w-8 !px-0',
|
sm: 'min-w-8 w-8 !px-0',
|
||||||
md: 'min-w-9 w-9 !px-0',
|
md: 'min-w-9 w-9 !px-0',
|
||||||
@@ -42,14 +48,23 @@ const iconOnlySizeClasses: Record<ButtonSize, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const typeClasses: Record<ButtonType, 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:
|
colored:
|
||||||
'button-frame--colored bg-[--button-color] text-[var(--color-accent-contrast)] [&>svg]:text-inherit',
|
'button-frame--colored bg-[--button-color] text-[var(--color-accent-contrast)] [&>svg]:text-inherit',
|
||||||
'colored-text':
|
'colored-text':
|
||||||
'button-frame--colored-text bg-surface-4 text-[--button-color] [&>svg]:text-inherit',
|
'button-frame--colored-text bg-surface-4 text-[--button-color] [&>svg]:text-inherit',
|
||||||
outlined:
|
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',
|
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> = {
|
const interactionClasses: Record<ButtonInteraction, string> = {
|
||||||
@@ -67,7 +82,17 @@ const colorVariables: Record<ButtonColor, string> = {
|
|||||||
green: 'var(--color-green)',
|
green: 'var(--color-green)',
|
||||||
blue: 'var(--color-blue)',
|
blue: 'var(--color-blue)',
|
||||||
purple: 'var(--color-purple)',
|
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(
|
const props = withDefaults(
|
||||||
@@ -101,7 +126,20 @@ const classes = computed(() => [
|
|||||||
props.circular ? '!rounded-full' : '',
|
props.circular ? '!rounded-full' : '',
|
||||||
])
|
])
|
||||||
const style = computed((): CSSProperties | undefined => {
|
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 === '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 (
|
if (
|
||||||
props.type !== 'colored' &&
|
props.type !== 'colored' &&
|
||||||
props.type !== 'colored-text' &&
|
props.type !== 'colored-text' &&
|
||||||
@@ -163,11 +201,24 @@ defineExpose({ element })
|
|||||||
mask-composite: exclude;
|
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 {
|
.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 {
|
.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>
|
</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'
|
export type ButtonInteraction = 'surface' | 'filled' | 'none'
|
||||||
|
|
||||||
// TODO: Standardized color string enum props across @modrinth/ui
|
// TODO: Standardized color string enum props across @modrinth/ui
|
||||||
export type ButtonColor =
|
export type ButtonColor = 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||||
| 'brand'
|
|
||||||
| 'red'
|
|
||||||
| 'orange'
|
|
||||||
| 'green'
|
|
||||||
| 'blue'
|
|
||||||
| 'purple'
|
|
||||||
| 'medal_promotion'
|
|
||||||
|
|
||||||
export type ButtonVisualProps = {
|
type ButtonVisualBase = {
|
||||||
size?: ButtonSize
|
size?: ButtonSize
|
||||||
interaction?: ButtonInteraction
|
}
|
||||||
} & (
|
|
||||||
|
type ButtonVisualWithoutInteraction =
|
||||||
| {
|
| {
|
||||||
type?: 'base'
|
type?: 'base'
|
||||||
color?: never
|
color?: never
|
||||||
|
interaction?: never
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'outlined'
|
type: Exclude<ButtonType, 'base' | 'quiet'>
|
||||||
color?: ButtonColor
|
color?: ButtonColor
|
||||||
|
interaction?: never
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
type: 'colored'
|
/**
|
||||||
color?: ButtonColor
|
* Public visual combinations supported by the current button frame.
|
||||||
}
|
*
|
||||||
| {
|
* `interaction` only changes quiet buttons, so exposing it on any other type
|
||||||
type: 'colored-text'
|
* would accept a prop that has no effect. Keeping that restriction here makes
|
||||||
color?: ButtonColor
|
* Button and ButtonLink share one truthful visual contract.
|
||||||
}
|
*/
|
||||||
| {
|
export type ButtonVisualProps = ButtonVisualBase &
|
||||||
type: 'quiet'
|
(
|
||||||
color?: ButtonColor
|
| ButtonVisualWithoutInteraction
|
||||||
}
|
| {
|
||||||
)
|
type: 'quiet'
|
||||||
|
color?: ButtonColor
|
||||||
|
interaction?: ButtonInteraction
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
export type ButtonNativeType = 'button' | 'submit' | 'reset'
|
export type ButtonNativeType = 'button' | 'submit' | 'reset'
|
||||||
|
|
||||||
export interface ButtonProps {
|
/** Icon-only controls need a name because their slot has no visible label. */
|
||||||
type?: ButtonType
|
export type ButtonContentProps =
|
||||||
color?: ButtonColor
|
| {
|
||||||
size?: ButtonSize
|
iconOnly: true
|
||||||
interaction?: ButtonInteraction
|
label: string
|
||||||
nativeType?: ButtonNativeType
|
}
|
||||||
disabled?: boolean
|
| {
|
||||||
loading?: boolean
|
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 {
|
export interface ButtonElementHandle {
|
||||||
element: HTMLElement | null
|
element: HTMLElement | null
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { default as JLineCommandInput } from './components/JLineCommandInput.vue'
|
export { default as JLineCommandInput } from './components/JLineCommandInput.vue'
|
||||||
|
export { default as LogViewport } from './components/LogViewport.vue'
|
||||||
export * from './jline'
|
export * from './jline'
|
||||||
export { default as ConsolePageLayout } from './layout.vue'
|
export { default as ConsolePageLayout } from './layout.vue'
|
||||||
export * from './providers'
|
export * from './providers'
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ export function truncateLogToMaxBytes(content: string, maxBytes: number): string
|
|||||||
return textDecoder.decode(bytes.subarray(start))
|
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
|
* Result of sharing log content. `truncated` is set when the content exceeded
|
||||||
* `LOG_SHARE_MAX_BYTES` and only its tail was uploaded.
|
* `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`
|
* Share log content through mclo.gs. Content larger than `LOG_SHARE_MAX_BYTES`
|
||||||
* is trimmed to its last
|
* is trimmed to its last `LOG_SHARE_MAX_BYTES` before uploading.
|
||||||
* `LOG_SHARE_MAX_BYTES` before uploading.
|
|
||||||
*/
|
*/
|
||||||
export async function shareLogs(
|
export async function shareLogs(
|
||||||
client: AbstractModrinthClient,
|
client: AbstractModrinthClient,
|
||||||
content: string,
|
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> {
|
): Promise<LogShareResult> {
|
||||||
const uploadContent = truncateLogToMaxBytes(content, LOG_SHARE_MAX_BYTES)
|
const uploadContent = truncateLogToMaxBytes(content, LOG_SHARE_MAX_BYTES)
|
||||||
const truncated = uploadContent !== content
|
const truncated = uploadContent !== content
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const allowedUntranslatedMessages = new Set([
|
|||||||
'Cleanroom',
|
'Cleanroom',
|
||||||
'LiteLoader',
|
'LiteLoader',
|
||||||
'LogShare.CN',
|
'LogShare.CN',
|
||||||
|
'LogShare AI',
|
||||||
'NeoForge',
|
'NeoForge',
|
||||||
'OptiFine',
|
'OptiFine',
|
||||||
'Quilt',
|
'Quilt',
|
||||||
|
|||||||
Reference in new issue
Block a user