Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions config/log-viewer.php
Original file line number Diff line number Diff line change
Expand Up @@ -326,4 +326,40 @@
*/

'root_folder_prefix' => 'root',

/*
|--------------------------------------------------------------------------
| AI Export Configuration
|--------------------------------------------------------------------------
| Configuration for exporting logs to AI providers for analysis.
| This feature allows users to send log errors to ChatGPT or Claude
| for automated troubleshooting assistance.
|
*/

'ai_export' => [
'enabled' => env('LOG_VIEWER_AI_EXPORT_ENABLED', true),

'providers' => [
'chatgpt' => [
'enabled' => true,
'url' => 'https://chat.openai.com/',
'max_characters' => 6950,
],
'claude' => [
'enabled' => true,
'url' => 'https://claude.ai/new',
'max_characters' => 10000,
],
],

// Maximum number of context lines to include in the export
'max_context_lines' => 50,

// Patterns to sanitize sensitive data before sending to AI
'sanitize_patterns' => [
// Add custom patterns here
// '/custom_pattern/i' => 'replacement',
],
],
];
2 changes: 1 addition & 1 deletion public/app.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/app.js

Large diffs are not rendered by default.

24 changes: 21 additions & 3 deletions public/app.js.LICENSE.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
/*!
* vue-router v4.4.3
* (c) 2024 Eduardo San Martin Morote
* @license MIT
*/

/*!
* The buffer module from node.js, for the browser.
*
Expand All @@ -6,8 +12,8 @@
*/

/*!
* pinia v2.2.2
* (c) 2024 Eduardo San Martin Morote
* pinia v2.3.1
* (c) 2025 Eduardo San Martin Morote
* @license MIT
*/

Expand All @@ -27,7 +33,19 @@
*/

/**
* @vue/shared v3.4.38
* @vue/reactivity v3.5.21
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/

/**
* @vue/runtime-dom v3.5.21
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/

/**
* @vue/shared v3.5.21
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/
4 changes: 2 additions & 2 deletions public/mix-manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"/app.js": "/app.js?id=74d3e481ad3e8fa14f1daaa0aa46e109",
"/app.css": "/app.css?id=5593a0331dd40729ff41e32a6035d872",
"/app.js": "/app.js?id=091af6695daccd58bf7cc06e8f81145d",
"/app.css": "/app.css?id=11b847c3f307927ccaa301f3ca1a0c3f",
"/img/log-viewer-128.png": "/img/log-viewer-128.png?id=d576c6d2e16074d3f064e60fe4f35166",
"/img/log-viewer-32.png": "/img/log-viewer-32.png?id=f8ec67d10f996aa8baf00df3b61eea6d",
"/img/log-viewer-64.png": "/img/log-viewer-64.png?id=8902d596fc883ca9eb8105bb683568c6"
Expand Down
12 changes: 12 additions & 0 deletions resources/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,15 @@ app.mixin({
});

app.mount('#log-viewer');

// Load AI providers only once at startup
// This avoids multiple requests when there are many logs on the screen
// Only loads if the feature is enabled
if (window.LogViewer?.ai_export_enabled) {
import('./stores/aiProviders').then(({ useAiProvidersStore }) => {
const aiProvidersStore = useAiProvidersStore(pinia);
aiProvidersStore.fetchProviders().catch(error => {
console.warn('Failed to preload AI providers:', error);
});
});
}
196 changes: 196 additions & 0 deletions resources/js/components/AskAiButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
<template>
<div class="relative inline-block">

<button
@click="toggleDropdown"
:disabled="loading"
class="inline-flex items-center px-2 py-1 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700"
:class="{ 'opacity-50 cursor-not-allowed': loading }"
title="Ask AI for help with this error"
>
<SparklesIcon class="w-3 h-3 mr-1" />
<span>Ask AI</span>
<ChevronDownIcon class="w-3 h-3 ml-1" />
</button>

<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<div
v-if="showDropdown"
class="absolute right-0 mt-1 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 dark:bg-gray-800"
style="min-width: 150px; z-index: 999999"
>
<div class="py-1">
<button
@click="copyAsMarkdown"
class="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700"
>
<ClipboardDocumentIcon class="w-4 h-4 mr-2" />
Copy as Markdown
</button>

<div class="border-t border-gray-100 dark:border-gray-700"></div>

<button
v-for="provider in providers"
:key="provider.key"
@click="askAi(provider.key)"
class="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700"
>
<span v-html="provider.icon" class="mr-2"></span>
{{ provider.name }}
</button>
</div>
</div>
</transition>

<div v-if="loading" class="fixed inset-0 z-40 bg-black bg-opacity-25 flex items-center justify-center">
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-xl">
<SpinnerIcon class="w-8 h-8" />
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Preparing AI export...</p>
</div>
</div>
</div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { SparklesIcon, ChevronDownIcon, ClipboardDocumentIcon } from '@heroicons/vue/24/solid';
import SpinnerIcon from './SpinnerIcon.vue';
import axios from 'axios';
import { useFileStore } from '../stores/files';
import { useAiProvidersStore } from '../stores/aiProviders';
import { useLogViewerStore } from '../stores/logViewer';

const props = defineProps({
log: {
type: Object,
required: true
},
logIndex: {
type: Number,
required: true
}
});

const fileStore = useFileStore();
const aiProvidersStore = useAiProvidersStore();
const logViewerStore = useLogViewerStore();
const showDropdown = ref(false);
const loading = ref(false);

// Usar computed para providers do store
const providers = computed(() => aiProvidersStore.providers);
const providersLoading = computed(() => aiProvidersStore.loading);

// Load providers only once (store takes care of caching)
onMounted(async () => {
// Only tries to load providers if AI Export is enabled
if (window.LogViewer?.ai_export_enabled) {
// The store will only load the first time or return from the cache
await aiProvidersStore.fetchProviders();
}

document.addEventListener('click', handleClickOutside);
});

onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});

const handleClickOutside = (event) => {
if (!event.target.closest('.relative.inline-block')) {
showDropdown.value = false;
}
};

const toggleDropdown = (event) => {
event.stopPropagation();

const rowIndex = logViewerStore.logs.findIndex(l => l.index === props.logIndex);

if (rowIndex >= 0 && !logViewerStore.isOpen(rowIndex)) {
logViewerStore.toggle(rowIndex);

setTimeout(() => {
showDropdown.value = !showDropdown.value;
}, 150);
} else {
showDropdown.value = !showDropdown.value;
}
};

const copyAsMarkdown = async () => {
loading.value = true;
showDropdown.value = false;

try {
const response = await axios.post(`${LogViewer.basePath}/api/ai/copy-markdown`, {
log_index: props.logIndex,
file_identifier: fileStore.selectedFile?.identifier
});

await navigator.clipboard.writeText(response.data.markdown);

showNotification('Markdown copied to clipboard!', 'success');
} catch (error) {
console.error('Failed to copy as markdown:', error);
showNotification('Failed to copy markdown', 'error');
} finally {
loading.value = false;
}
};

const askAi = async (providerKey) => {
loading.value = true;
showDropdown.value = false;

try {
const response = await axios.post(`${LogViewer.basePath}/api/ai/export`, {
provider: providerKey,
log_index: props.logIndex,
file_identifier: fileStore.selectedFile?.identifier
});

window.open(response.data.url, '_blank');

showNotification(`Opening ${response.data.provider.name}...`, 'success');
} catch (error) {
console.error('Failed to export to AI:', error);

if (error.response?.status === 429) {
showNotification('Too many requests. Please try again later.', 'error');
} else {
showNotification('Failed to export to AI', 'error');
}
} finally {
loading.value = false;
}
};

// FunΓ§Γ£o auxiliar para mostrar notificaΓ§Γ΅es
const showNotification = (message, type = 'info') => {
// Criar elemento de notificaΓ§Γ£o
const notification = document.createElement('div');
notification.className = `fixed bottom-4 right-4 z-50 px-4 py-2 rounded-md shadow-lg text-white transition-opacity duration-300 ${
type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-blue-500'
}`;
notification.textContent = message;

document.body.appendChild(notification);

// Remover apΓ³s 3 segundos
setTimeout(() => {
notification.style.opacity = '0';
setTimeout(() => {
document.body.removeChild(notification);
}, 300);
}, 3000);
};
</script>
9 changes: 7 additions & 2 deletions resources/js/components/BaseLogTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,19 @@
</template>

<td class="whitespace-nowrap text-gray-500 dark:text-gray-300 dark:opacity-90 text-xs hidden lg:table-cell">
<LogCopyButton :log="log" class="pr-2 large-screen" />
<div class="flex items-center space-x-1 pr-2">
<LogCopyButton :log="log" class="large-screen" />
<AskAiButton v-if="LogViewer.ai_export_enabled && (log.level_class === 'danger' || log.level_class === 'warning')" :log="log" :log-index="log.index" />
</div>
</td>
</tr>
<tr v-show="logViewerStore.isOpen(index)">
<td :colspan="tableColumns">
<div class="lg:hidden flex justify-between px-2 pt-2 pb-1 text-xs">
<div class="flex-1"><span class="font-semibold">Datetime:</span> {{ log.datetime }}</div>
<div>
<div class="flex items-center space-x-1">
<LogCopyButton :log="log" />
<AskAiButton v-if="LogViewer.ai_export_enabled && (log.level_class === 'danger' || log.level_class === 'warning')" :log="log" :log-index="log.index" />
</div>
</div>

Expand Down Expand Up @@ -145,6 +149,7 @@ import { useLogViewerStore } from '../stores/logViewer.js';
import { useSearchStore } from '../stores/search.js';
import { useFileStore } from '../stores/files.js';
import LogCopyButton from './LogCopyButton.vue';
import AskAiButton from './AskAiButton.vue';
import { handleLogToggleKeyboardNavigation } from '../keyboardNavigation';
import { useSeverityStore } from '../stores/severity.js';
import TabContainer from "./TabContainer.vue";
Expand Down
Loading