mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-06 00:09:22 +00:00
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1355 lines
44 KiB
Vue
1355 lines
44 KiB
Vue
<template>
|
|
<div
|
|
class="chat-container rounded-surface text-text-body bg-card-glass-solid shadow-hover-shadow flex h-full w-full flex-col overflow-hidden shadow-md"
|
|
:class="[{ 'chat-open': isOpen }]"
|
|
>
|
|
<div v-if="isOpen" class="chat-content-wrapper flex h-full flex-col bg-transparent">
|
|
<div
|
|
class="chat-header border-separator bg-surface-base z-2 flex shrink-0 items-end justify-between border-b px-3 pt-0 pb-1"
|
|
:style="{ height: headerHeight ? headerHeight + 'px' : '' }"
|
|
>
|
|
<div class="chat-title flex w-full items-center justify-between font-bold">
|
|
<div class="flex min-w-0 items-center gap-2">
|
|
<div class="inline-flex h-6 w-6 shrink-0 overflow-hidden rounded-full">
|
|
<img :src="o2AiTitleLogo" class="h-full w-full object-cover" />
|
|
</div>
|
|
|
|
<ODropdown @update:open="(v) => v && loadHistory()">
|
|
<template #trigger>
|
|
<OButton
|
|
variant="ghost"
|
|
size="sm"
|
|
class="chat-title-dropdown rounded-default hover:bg-interactive-hover-bg flex h-8 min-h-8 max-w-40 min-w-0 items-center overflow-hidden px-3 py-1.5 transition-colors duration-200"
|
|
>
|
|
<div class="flex min-w-0 items-center gap-2">
|
|
<span
|
|
class="chat-title-text text-text-body block min-w-0 truncate text-sm font-medium"
|
|
>
|
|
{{ displayedTitle || t("common.newChat") }}
|
|
<OTooltip
|
|
v-if="displayedTitle && displayedTitle.length > 25"
|
|
:sideOffset="8"
|
|
side="bottom"
|
|
align="center"
|
|
:content="raw(displayedTitle)"
|
|
/>
|
|
</span>
|
|
<OIcon name="arrow-drop-down" size="md" class="flex-shrink-0" />
|
|
</div>
|
|
</OButton>
|
|
</template>
|
|
<O2AIChatHistoryMenu
|
|
v-model:search-term="historySearchTerm"
|
|
:chats="filteredChatHistory"
|
|
@select="loadChat"
|
|
@delete="deleteChat"
|
|
@clear-all="clearAllConversations"
|
|
/>
|
|
</ODropdown>
|
|
</div>
|
|
|
|
<div class="chat-header-actions flex shrink-0 items-center gap-1">
|
|
<!-- Edit title button -->
|
|
<OButton
|
|
v-if="currentChatId"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
@click.stop="openEditTitleDialog"
|
|
>
|
|
<OIcon name="edit" size="sm" />
|
|
<OTooltip :content="t('aiAssistant.editTitleTooltip')" />
|
|
</OButton>
|
|
<OButton variant="ghost" size="icon-sm" @click="addNewChat">
|
|
<OIcon name="add" size="sm" />
|
|
</OButton>
|
|
<OButton
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
data-test="ai-chat-expand-btn"
|
|
@click="toggleExpand"
|
|
>
|
|
<OIcon
|
|
:name="store.state.isAiChatExpanded ? 'close-fullscreen' : 'open-in-full'"
|
|
size="sm"
|
|
/>
|
|
<OTooltip
|
|
:content="
|
|
t('common.collapseExpandShortcut', {
|
|
action: store.state.isAiChatExpanded
|
|
? t('common.collapse')
|
|
: t('common.expand'),
|
|
shortcut: isMac ? '⌘' : 'Ctrl+',
|
|
})
|
|
"
|
|
/>
|
|
</OButton>
|
|
<OButton variant="ghost" size="icon-sm" @click="$emit('close')">
|
|
<OIcon name="close" size="sm" />
|
|
</OButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- History Panel -->
|
|
<ODrawer
|
|
data-test="o2-ai-chat-history-drawer"
|
|
bleed
|
|
v-model:open="showHistory"
|
|
size="sm"
|
|
:title="t('aiAssistant.chatHistory')"
|
|
>
|
|
<ul class="divide-border flex flex-col divide-y">
|
|
<li
|
|
v-for="chat in chatHistory"
|
|
:key="chat.id"
|
|
:data-test="`o2-ai-chat-history-item-${chat.id}`"
|
|
class="hover:bg-muted/50 flex cursor-pointer flex-col px-3 py-2"
|
|
@click="loadChat(chat.id)"
|
|
>
|
|
<span class="text-sm">{{ chat.title }}</span>
|
|
<span class="text-muted-foreground block text-xs">
|
|
{{ new Date(chat.timestamp).toLocaleString() }}
|
|
</span>
|
|
<span class="text-muted-foreground block text-xs">
|
|
{{ t("aiAssistant.modelLabel") }} {{ chat.model }}
|
|
</span>
|
|
</li>
|
|
</ul>
|
|
</ODrawer>
|
|
|
|
<!-- Edit Title Dialog -->
|
|
<ODialog
|
|
data-test="o2-ai-chat-edit-title-dialog"
|
|
v-model:open="showEditTitleDialog"
|
|
size="sm"
|
|
:title="t('aiAssistant.editChatTitle')"
|
|
:secondary-button-label="t('common.cancel')"
|
|
:primary-button-label="t('common.save')"
|
|
@click:secondary="showEditTitleDialog = false"
|
|
@click:primary="saveEditedTitle"
|
|
>
|
|
<OInput
|
|
v-model="editingTitle"
|
|
autofocus
|
|
@keyup.enter="saveEditedTitle"
|
|
:placeholder="t('aiAssistant.enterChatTitle')"
|
|
/>
|
|
</ODialog>
|
|
|
|
<!-- Delete Chat Confirmation Dialog -->
|
|
<ConfirmDialog
|
|
v-model="showDeleteChatConfirmDialog"
|
|
:title="t('aiAssistant.deleteChat')"
|
|
:message="t('aiAssistant.deleteChatConfirmMessage')"
|
|
@update:ok="confirmDeleteChat"
|
|
@update:cancel="showDeleteChatConfirmDialog = false"
|
|
/>
|
|
|
|
<!-- Clear All Conversations Confirmation Dialog -->
|
|
<ConfirmDialog
|
|
v-model="showClearAllConfirmDialog"
|
|
:title="t('aiAssistant.clearAllConversationsTitle')"
|
|
:message="t('aiAssistant.clearAllConversationsMessage')"
|
|
@update:ok="confirmClearAllConversations"
|
|
@update:cancel="showClearAllConfirmDialog = false"
|
|
/>
|
|
|
|
<!-- Image Preview Dialog -->
|
|
<ODialog
|
|
data-test="o2-ai-chat-image-preview-dialog"
|
|
v-model:open="showImagePreview"
|
|
@update:open="(v) => !v && closeImagePreview()"
|
|
size="lg"
|
|
:title="raw(previewImage?.filename)"
|
|
>
|
|
<div class="flex justify-center">
|
|
<img
|
|
v-if="previewImage"
|
|
:src="'data:' + previewImage.mimeType + ';base64,' + previewImage.data"
|
|
:alt="previewImage.filename"
|
|
class="max-h-[80vh] max-w-full object-contain"
|
|
/>
|
|
</div>
|
|
</ODialog>
|
|
|
|
<div
|
|
class="chat-content relative flex min-h-0 flex-1 flex-col overflow-hidden bg-transparent"
|
|
>
|
|
<div
|
|
class="messages-container mx-auto flex min-h-0 w-full max-w-225 flex-1 flex-col gap-4 overflow-y-auto bg-transparent p-2"
|
|
ref="messagesContainer"
|
|
@scroll="checkIfShouldAutoScroll"
|
|
>
|
|
<div
|
|
v-if="chatMessages.length === 0"
|
|
class="welcome-section rounded-default mb-0 flex flex-1 items-center justify-center bg-transparent p-0"
|
|
>
|
|
<!-- Home tab: rich V2 welcome -->
|
|
<O2AIHomeWelcome v-if="centeredStart" @select-prompt="selectWelcomePrompt" />
|
|
<!-- Sidepanel: minimal logo + title -->
|
|
<div v-else class="flex h-full w-full flex-col items-center justify-center">
|
|
<div class="flex flex-col items-center gap-2">
|
|
<img :src="o2AiTitleLogo" />
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm font-[600]">{{
|
|
t("aiAssistant.welcome.taglineHighlight")
|
|
}}</span>
|
|
<!-- Same shared Beta tag as the Workflows screens. -->
|
|
<BetaBadge />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<O2AIChatMessage
|
|
v-for="(message, index) in processedMessages"
|
|
:key="index"
|
|
:message="message"
|
|
:index="index"
|
|
:is-loading="isLoading"
|
|
:current-analyzing-message="currentAnalyzingMessage"
|
|
:expanded-tool-calls="expandedToolCalls"
|
|
:expanded-log-entries="expandedLogEntries"
|
|
@toggle-tool-call="(blockIndex: number) => toggleToolCallExpanded(index, blockIndex)"
|
|
@toggle-log-entry="(blockIndex: number) => toggleLogEntryExpanded(index, blockIndex)"
|
|
@navigate="handleNavigationAction"
|
|
@retry="retryGeneration"
|
|
@like="likeCodeBlock(index)"
|
|
@dislike="dislikeCodeBlock(index)"
|
|
@preview-image="openImagePreview"
|
|
/>
|
|
<!-- Tool call indicator - shows outside message box -->
|
|
<O2AIChatToolCallIndicator
|
|
v-if="activeToolCall"
|
|
:message="activeToolCall.message"
|
|
:context="activeToolCall.context"
|
|
/>
|
|
<!-- Standalone loading indicator - only shown when loading with no tool calls -->
|
|
<div
|
|
v-if="isLoading && !activeToolCall"
|
|
class="tool-call-indicator rounded-default border-border-default my-2 flex items-center border px-4 py-3 [background:var(--color-chat-bubble-user)]"
|
|
>
|
|
<div class="tool-call-content flex w-full items-center gap-3">
|
|
<OSpinner variant="dots" size="xs" />
|
|
<span class="tool-call-message text-text-secondary text-sm font-semibold">{{
|
|
currentAnalyzingMessage
|
|
}}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Scroll to bottom button -->
|
|
<div
|
|
v-show="showScrollToBottom"
|
|
class="scroll-to-bottom-container pointer-events-none absolute bottom-2.5 left-1/2 z-1000 -translate-x-1/2 [transition:all_0.3s_ease]"
|
|
>
|
|
<OButton
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
class="scroll-to-bottom-btn border-text-link! text-text-link! bg-surface-base! dark:border-ai-accent! dark:text-ai-accent! dark:bg-surface-base! hover:border-text-link! hover:text-text-link! hover:bg-surface-base! dark:hover:border-ai-accent! dark:hover:text-ai-accent! dark:hover:bg-surface-base! pointer-events-auto border-2! shadow-sm [backdrop-filter:blur(0.5rem)] transition-all duration-300 hover:scale-110 hover:shadow-md active:scale-100"
|
|
@click="scrollToBottomSmooth"
|
|
>
|
|
<OIcon name="arrow-downward" size="sm" />
|
|
<OTooltip side="top" align="center" :content="t('aiAssistant.scrollToBottom')" />
|
|
</OButton>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Fixed loading indicator above input - only shown when scrolled up -->
|
|
<div
|
|
v-if="(isLoading || activeToolCall) && showScrollToBottom"
|
|
class="fixed-analyzing-indicator rounded-default border-border-default mx-4 mb-2 flex items-center justify-center border px-4 py-3 shadow-sm [background:var(--color-chat-bubble-user)]"
|
|
>
|
|
<!-- Show tool call if active -->
|
|
<div
|
|
v-if="activeToolCall"
|
|
class="analyzing-content flex w-full max-w-225 items-center gap-3"
|
|
>
|
|
<OSpinner variant="dots" size="xs" />
|
|
<span class="analyzing-message text-theme-accent text-sm font-medium">{{
|
|
activeToolCall.message
|
|
}}</span>
|
|
</div>
|
|
<!-- Show analyzing message if loading but no active tool call -->
|
|
<div
|
|
v-else-if="isLoading"
|
|
class="analyzing-content flex w-full max-w-225 items-center gap-3"
|
|
>
|
|
<OSpinner variant="dots" size="xs" />
|
|
<span class="analyzing-message text-theme-accent text-sm font-medium">{{
|
|
currentAnalyzingMessage
|
|
}}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="chat-input-container relative mx-auto my-2 w-full max-w-225 shrink-0 px-2">
|
|
<!-- Confirmation dialog -->
|
|
<O2AIConfirmDialog
|
|
:visible="pendingConfirmation !== null"
|
|
:confirmation="pendingConfirmation"
|
|
@confirm="handleToolConfirm"
|
|
@cancel="handleToolCancel"
|
|
@always-confirm="handleToolAlwaysConfirm"
|
|
/>
|
|
<O2AIPaidUsageConsent v-if="showPaidUsageConsent" />
|
|
|
|
<!-- Hidden file input for image upload -->
|
|
<input
|
|
ref="imageInputRef"
|
|
type="file"
|
|
accept="image/png,image/jpeg"
|
|
multiple
|
|
class="hidden"
|
|
@change="handleImageSelect"
|
|
/>
|
|
|
|
<O2AIChatInput
|
|
v-if="!pendingConfirmation && !showPaidUsageConsent"
|
|
v-model="inputMessage"
|
|
v-model:auto-navigation="isAutoNavigationEnabled"
|
|
:pending-images="pendingImages"
|
|
:placeholder="raw(inputPlaceholder)"
|
|
:is-loading="isLoading"
|
|
:theme="store.state.theme"
|
|
:references="contextReferences"
|
|
@dragover="handleDragOver"
|
|
@drop="handleDrop"
|
|
@paste="handlePaste"
|
|
@input-ref="(instance: any) => (chatInput = instance)"
|
|
@keydown="handleKeyDown"
|
|
@update:references="handleReferencesUpdate"
|
|
@send="sendMessage"
|
|
@cancel="cancelCurrentRequest"
|
|
@trigger-image-upload="triggerImageUpload"
|
|
@remove-image="removeImage"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { defineComponent, ref, onMounted, nextTick, watch, computed, onUnmounted } from "vue";
|
|
import { raw, useI18nTyped } from "@/types/i18n";
|
|
import { useRouter, useRoute } from "vue-router";
|
|
import { useTypewriterPlaceholder } from "@/components/ai-assistant/welcome/useTypewriterPlaceholder";
|
|
import "highlight.js/styles/github.css";
|
|
import "highlight.js/styles/github-dark.css";
|
|
import { useStore } from "vuex";
|
|
import { useTheme } from "@/composables/useTheme";
|
|
import useAiChat from "@/composables/useAiChat";
|
|
import { getImageURL } from "@/utils/zincutils";
|
|
import { ChatMessage } from "@/ts/interfaces/chat";
|
|
|
|
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
|
import { ReferenceChip } from "@/components/RichTextInput.vue";
|
|
import O2AIConfirmDialog from "@/components/O2AIConfirmDialog.vue";
|
|
import O2AIPaidUsageConsent from "@/components/ai-assistant/chat/O2AIPaidUsageConsent.vue";
|
|
import { useChatConsentSurface } from "@/composables/usePaidOverageConsent";
|
|
import O2AIHomeWelcome from "@/components/ai-assistant/welcome/O2AIHomeWelcome.vue";
|
|
import O2AIChatHistoryMenu from "@/components/ai-assistant/chat/O2AIChatHistoryMenu.vue";
|
|
import O2AIChatInput from "@/components/ai-assistant/chat/O2AIChatInput.vue";
|
|
import O2AIChatMessage from "@/components/ai-assistant/chat/O2AIChatMessage.vue";
|
|
import O2AIChatToolCallIndicator from "@/components/ai-assistant/chat/O2AIChatToolCallIndicator.vue";
|
|
import { useChatHistory } from "@/composables/useChatHistory";
|
|
import { useChatImages } from "@/composables/useChatImages";
|
|
import { useChatHistoryList } from "@/composables/useChatHistoryList";
|
|
import { usePromptHistory } from "@/composables/usePromptHistory";
|
|
import { useAutoNavigationPreferences } from "@/composables/useAutoNavigationPreferences";
|
|
import { useChatScroll } from "@/composables/useChatScroll";
|
|
import { useTypewriter } from "@/composables/useTypewriter";
|
|
import { abortBackgroundStreams, useChatStream } from "@/composables/useChatStream";
|
|
import { useShortcuts } from "@/lib/vue-shortcut-manager";
|
|
import OButton from "@/lib/core/Button/OButton.vue";
|
|
import BetaBadge from "@/components/common/BetaBadge.vue";
|
|
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
|
import ODrawer from "@/lib/overlay/Drawer/ODrawer.vue";
|
|
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
|
|
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
|
|
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
|
|
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
|
import OInput from "@/lib/forms/Input/OInput.vue";
|
|
import { toast } from "@/lib/feedback/Toast/useToast";
|
|
import { copyToClipboard } from "@/utils/clipboard";
|
|
import { computeUserOrgKey } from "@/utils/userOrgKey";
|
|
import {
|
|
createPreview,
|
|
formatLogEntryContent,
|
|
getLanguageDisplay,
|
|
parseLogEntries,
|
|
processHtmlBlock,
|
|
processMessageContent,
|
|
processTextBlock,
|
|
renderMarkdown,
|
|
} from "@/components/O2AIChat.content";
|
|
import {
|
|
formatContextKey,
|
|
formatContextValue,
|
|
getToolCallDisplayData,
|
|
hasToolCallDetails,
|
|
truncateQuery,
|
|
} from "@/components/O2AIChat.toolcall";
|
|
|
|
const { submitFeedback } = useAiChat();
|
|
|
|
export default defineComponent({
|
|
name: "O2AIChat",
|
|
components: {
|
|
OButton,
|
|
BetaBadge,
|
|
ConfirmDialog,
|
|
O2AIConfirmDialog,
|
|
O2AIPaidUsageConsent,
|
|
O2AIHomeWelcome,
|
|
O2AIChatHistoryMenu,
|
|
O2AIChatInput,
|
|
O2AIChatMessage,
|
|
O2AIChatToolCallIndicator,
|
|
ODropdown,
|
|
ODrawer,
|
|
ODialog,
|
|
OSpinner,
|
|
OIcon,
|
|
OTooltip,
|
|
OInput,
|
|
},
|
|
props: {
|
|
isOpen: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
headerHeight: {
|
|
type: Number,
|
|
default: 0,
|
|
},
|
|
aiChatInputContext: {
|
|
type: String,
|
|
default: "",
|
|
},
|
|
appendMode: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
aiChatPayload: {
|
|
type: Object as () => {
|
|
text: string;
|
|
autoSend: boolean;
|
|
id: number;
|
|
} | null,
|
|
default: null,
|
|
},
|
|
centeredStart: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
},
|
|
setup(props) {
|
|
const router = useRouter();
|
|
const route = useRoute();
|
|
const inputMessage = ref(props.aiChatInputContext ? props.aiChatInputContext : "");
|
|
const chatMessages = ref<ChatMessage[]>([]);
|
|
const messagesContainer = ref<HTMLElement | null>(null);
|
|
const chatInput = ref<any>(null);
|
|
const { showInChat: showPaidUsageConsent } = useChatConsentSurface(() => props.isOpen);
|
|
const currentTextSegment = ref("");
|
|
const currentChatId = ref<number | null>(null);
|
|
const store = useStore();
|
|
const { isDark } = useTheme();
|
|
const { t } = useI18nTyped();
|
|
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
|
const chatUpdated = computed(() => store.state.chatUpdated);
|
|
|
|
const typewriterPrompts = computed(() => [
|
|
t("aiAssistant.placeholderRotation.one"),
|
|
t("aiAssistant.placeholderRotation.two"),
|
|
t("aiAssistant.placeholderRotation.three"),
|
|
]);
|
|
const typewriterEnabled = computed(
|
|
() => !!props.centeredStart && chatMessages.value.length === 0,
|
|
);
|
|
const { placeholder: typewriterPlaceholder } = useTypewriterPlaceholder(typewriterPrompts, {
|
|
enabled: typewriterEnabled,
|
|
typeSpeedMs: 85,
|
|
eraseSpeedMs: 45,
|
|
holdMs: 2800,
|
|
initialDelayMs: 500,
|
|
});
|
|
const inputPlaceholder = computed(() =>
|
|
props.centeredStart && chatMessages.value.length === 0
|
|
? typewriterPlaceholder.value || t("common.writeYourPrompt")
|
|
: t("common.writeYourPrompt"),
|
|
);
|
|
|
|
const {
|
|
saveToHistory: dbSaveToHistory,
|
|
loadHistory: dbLoadHistory,
|
|
loadChat: dbLoadChat,
|
|
deleteChatById: dbDeleteChatById,
|
|
clearAllHistory: dbClearAllHistory,
|
|
updateChatTitle: dbUpdateChatTitle,
|
|
} = useChatHistory(
|
|
() => store.state.userInfo.email ?? "",
|
|
() => store.state.selectedOrganization.identifier ?? "",
|
|
t,
|
|
);
|
|
|
|
const userEmail = () => store.state.userInfo.email ?? "";
|
|
const orgIdentifier = () => store.state.selectedOrganization?.identifier ?? "";
|
|
// Local prompt history and auto-navigation are keyed by this hash, the same scope as chat history.
|
|
const userOrgKey = ref<string | null>(null);
|
|
watch(
|
|
() => `${userEmail()}:${orgIdentifier()}`,
|
|
async (scope) => {
|
|
userOrgKey.value = null;
|
|
const key = await computeUserOrgKey(userEmail(), orgIdentifier());
|
|
// A slower hash from the previous user or org must not overwrite the current one.
|
|
if (scope === `${userEmail()}:${orgIdentifier()}`) userOrgKey.value = key;
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
const currentChatTimestamp = ref<string | null>(null);
|
|
const {
|
|
shouldAutoScroll,
|
|
showScrollToBottom,
|
|
getScrollThreshold,
|
|
checkIfShouldAutoScroll,
|
|
scrollToBottom,
|
|
scrollToBottomSmooth,
|
|
scrollToLoadingIndicator,
|
|
} = useChatScroll(messagesContainer);
|
|
|
|
const {
|
|
autoNavigationPreferences,
|
|
pendingAutoNavigation,
|
|
isAutoNavigationEnabled,
|
|
saveAutoNavigationPreferences,
|
|
} = useAutoNavigationPreferences(currentChatId, userOrgKey);
|
|
|
|
const {
|
|
currentAnalyzingMessage,
|
|
startAnalyzingRotation,
|
|
stopAnalyzingRotation,
|
|
aiGeneratedTitle,
|
|
displayedTitle,
|
|
isTypingTitle,
|
|
animateTitle,
|
|
resetTitleState,
|
|
clearTitleInterval,
|
|
displayedStreamingContent,
|
|
typewriterAnimationId,
|
|
resetTypewriterState,
|
|
animateStreamingText,
|
|
} = useTypewriter(currentTextSegment, t);
|
|
|
|
const {
|
|
showHistory,
|
|
chatHistory,
|
|
historySearchTerm,
|
|
filteredChatHistory,
|
|
loadHistory,
|
|
openHistory,
|
|
showEditTitleDialog,
|
|
editingTitle,
|
|
openEditTitleDialog,
|
|
saveEditedTitle,
|
|
showDeleteChatConfirmDialog,
|
|
chatToDelete,
|
|
deleteChat,
|
|
confirmDeleteChat,
|
|
showClearAllConfirmDialog,
|
|
clearAllConversations,
|
|
confirmClearAllConversations,
|
|
} = useChatHistoryList({
|
|
loadHistoryFromDb: dbLoadHistory,
|
|
deleteChatById: dbDeleteChatById,
|
|
clearAllHistory: dbClearAllHistory,
|
|
updateChatTitle: dbUpdateChatTitle,
|
|
currentChatId,
|
|
displayedTitle,
|
|
aiGeneratedTitle,
|
|
addNewChat: () => addNewChat(),
|
|
});
|
|
|
|
const expandedToolCalls = ref<Set<string>>(new Set());
|
|
|
|
const expandedLogEntries = ref<Set<string>>(new Set());
|
|
|
|
const {
|
|
isLoading,
|
|
currentSessionId,
|
|
lastTraceId,
|
|
saveHistoryLoading,
|
|
pendingConfirmation,
|
|
activeToolCall,
|
|
currentAbortController,
|
|
streamOwnerUnavailable,
|
|
cancelCurrentRequest,
|
|
saveToHistory,
|
|
detachCurrentStream,
|
|
abortAllStreams,
|
|
handleToolConfirm,
|
|
handleToolCancel,
|
|
handleToolAlwaysConfirm,
|
|
handleNavigationAction,
|
|
sendConfirmation,
|
|
isSessionOwnerUnavailable,
|
|
appendErrorBlock,
|
|
runTurn,
|
|
tryReattach,
|
|
disposeRenderFlush,
|
|
} = useChatStream({
|
|
chatMessages,
|
|
currentChatId,
|
|
currentTextSegment,
|
|
dbSaveToHistory,
|
|
store,
|
|
router,
|
|
t,
|
|
scrollToBottom,
|
|
scrollToLoadingIndicator,
|
|
autoNavigationPreferences,
|
|
pendingAutoNavigation,
|
|
isAutoNavigationEnabled,
|
|
saveAutoNavigationPreferences,
|
|
startAnalyzingRotation,
|
|
stopAnalyzingRotation,
|
|
aiGeneratedTitle,
|
|
animateTitle,
|
|
displayedStreamingContent,
|
|
typewriterAnimationId,
|
|
resetTypewriterState,
|
|
animateStreamingText,
|
|
});
|
|
|
|
const {
|
|
pendingImages,
|
|
imageInputRef,
|
|
showImagePreview,
|
|
previewImage,
|
|
triggerImageUpload,
|
|
handleImageSelect,
|
|
removeImage,
|
|
clearPendingImages,
|
|
handleDragOver,
|
|
handleDrop,
|
|
handlePaste,
|
|
handleImageReferenceBackspace,
|
|
openImagePreview,
|
|
closeImagePreview,
|
|
} = useChatImages(chatInput, inputMessage, () => focusInput(), t);
|
|
|
|
const contextReferences = ref<ReferenceChip[]>([]);
|
|
|
|
const componentReady = ref(false);
|
|
const pendingChips = ref<ReferenceChip[]>([]);
|
|
|
|
// Set in onUnmounted so the chatUpdated watch can't re-attach a just-detached stream to this dying instance.
|
|
const isUnmounting = ref(false);
|
|
|
|
const { historyIndex, isOnFirstLine, navigateHistory, addToHistory } = usePromptHistory(
|
|
inputMessage,
|
|
userOrgKey,
|
|
);
|
|
|
|
const capabilities = [
|
|
"1. Create a SQL query for me",
|
|
"2. Convert this SPL query to SQL",
|
|
"3. What is happening on this log line",
|
|
"4. Write a VRL function to parse these log lines",
|
|
"5. What are golden signals for observability",
|
|
"6. How to monitor kubernetes cluster",
|
|
"7. How to monitor docker containers",
|
|
"8. How to monitor aws services",
|
|
"9. How to monitor azure services",
|
|
"10. How to monitor google cloud services",
|
|
];
|
|
|
|
const formatMessage = (content: string) => {
|
|
try {
|
|
return renderMarkdown(content);
|
|
} catch (e) {
|
|
console.error("Error formatting message:", e);
|
|
return content;
|
|
}
|
|
};
|
|
|
|
const processPendingChips = () => {
|
|
if (pendingChips.value.length > 0) {
|
|
nextTick(() => {
|
|
if (chatInput.value && typeof chatInput.value.insertChip === "function") {
|
|
focusInput();
|
|
|
|
// Check DOM directly for existing chips instead of relying on reactive state
|
|
const inputElement = chatInput.value.$el || chatInput.value;
|
|
const editableDiv =
|
|
inputElement?.querySelector(".rich-text-input") ||
|
|
inputElement?.querySelector("[contenteditable]");
|
|
const hasExistingChips = editableDiv?.querySelector(".reference-chip") !== null;
|
|
const hasExistingText = editableDiv?.textContent?.trim().length > 0;
|
|
|
|
if (!props.appendMode && !hasExistingChips && !hasExistingText) {
|
|
if (chatInput.value && typeof chatInput.value.clear === "function") {
|
|
chatInput.value.clear();
|
|
}
|
|
inputMessage.value = "";
|
|
}
|
|
|
|
pendingChips.value.forEach((chip) => {
|
|
chatInput.value.insertChip(chip);
|
|
});
|
|
pendingChips.value = [];
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
watch(
|
|
() => props.aiChatInputContext,
|
|
(newAiChatInputContext: string) => {
|
|
if (newAiChatInputContext) {
|
|
const contextChip: ReferenceChip = {
|
|
id: `context-${Date.now()}`,
|
|
// Not translated: this filename is spliced into the prompt's `--- Log Entry ---` delimiter, which must stay stable across locales.
|
|
filename: raw("Log Entry"),
|
|
preview: createPreview(newAiChatInputContext, 10),
|
|
fullContent: newAiChatInputContext,
|
|
charCount: newAiChatInputContext.length,
|
|
type: "context",
|
|
};
|
|
|
|
pendingChips.value.push(contextChip);
|
|
|
|
if (
|
|
componentReady.value &&
|
|
chatInput.value &&
|
|
typeof chatInput.value.insertChip === "function"
|
|
) {
|
|
// Use a small delay to ensure input is focused and ready
|
|
nextTick(() => {
|
|
setTimeout(() => {
|
|
processPendingChips();
|
|
}, 50);
|
|
});
|
|
}
|
|
// Not ready yet: queued chips are processed when componentReady becomes true.
|
|
}
|
|
},
|
|
);
|
|
|
|
// Atomic payload watcher — text + autoSend arrive together, no timing race
|
|
watch(
|
|
() => props.aiChatPayload,
|
|
(payload) => {
|
|
if (!payload?.text) return;
|
|
if (payload.autoSend) {
|
|
inputMessage.value = payload.text;
|
|
nextTick(() => {
|
|
setTimeout(() => {
|
|
sendMessage();
|
|
}, 50);
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
const fetchInitialMessage = async () => {
|
|
isLoading.value = true;
|
|
try {
|
|
chatMessages.value = [];
|
|
} catch (error) {
|
|
chatMessages.value = [
|
|
{
|
|
role: "assistant",
|
|
content: t("aiAssistant.backendConnectionError"),
|
|
},
|
|
];
|
|
console.error("Error fetching initial message:", error);
|
|
}
|
|
isLoading.value = false;
|
|
stopAnalyzingRotation();
|
|
scrollToBottom();
|
|
};
|
|
|
|
const toggleExpand = () => {
|
|
if (!store.state.isAiChatEnabled) {
|
|
store.dispatch("setIsAiChatEnabled", true);
|
|
store.dispatch("setIsAiChatExpanded", false);
|
|
} else if (!store.state.isAiChatExpanded) {
|
|
store.dispatch("setIsAiChatExpanded", true);
|
|
} else {
|
|
store.dispatch("setIsAiChatExpanded", false);
|
|
}
|
|
window.dispatchEvent(new Event("resize"));
|
|
};
|
|
|
|
useShortcuts([
|
|
{
|
|
id: "aiChatClose",
|
|
key: "escape",
|
|
description: t("shortcuts.actions.aiChatClose"),
|
|
// Escape must close the chat even while typing a message in its input.
|
|
allowInInput: true,
|
|
handler: () => {
|
|
if (store.state.isAiChatEnabled) {
|
|
store.dispatch("setIsAiChatEnabled", false);
|
|
store.dispatch("setIsAiChatExpanded", false);
|
|
window.dispatchEvent(new Event("resize"));
|
|
}
|
|
},
|
|
},
|
|
]);
|
|
|
|
const addNewChat = () => {
|
|
detachCurrentStream();
|
|
|
|
chatMessages.value = [];
|
|
currentChatId.value = null;
|
|
currentSessionId.value = null; // Will be generated on first save
|
|
lastTraceId.value = null;
|
|
showHistory.value = false;
|
|
currentChatTimestamp.value = null;
|
|
shouldAutoScroll.value = true;
|
|
resetTitleState();
|
|
resetTypewriterState();
|
|
pendingAutoNavigation.value = true;
|
|
showScrollToBottom.value = false;
|
|
store.dispatch("setCurrentChatTimestamp", null);
|
|
store.dispatch("setChatUpdated", true);
|
|
};
|
|
|
|
const loadChat = async (chatId: number) => {
|
|
try {
|
|
if (chatId == null) {
|
|
addNewChat();
|
|
return;
|
|
}
|
|
|
|
detachCurrentStream();
|
|
|
|
const chat = await dbLoadChat(chatId);
|
|
|
|
if (chat) {
|
|
if (!tryReattach(chat, chatId)) {
|
|
const formattedMessages = chat.messages.map((msg: any) => ({
|
|
role: msg.role,
|
|
content: msg.content,
|
|
...(msg.contentBlocks ? { contentBlocks: msg.contentBlocks } : {}),
|
|
...(msg.images ? { images: msg.images } : {}),
|
|
...(msg.feedback ? { feedback: msg.feedback } : {}),
|
|
}));
|
|
|
|
chatMessages.value = formattedMessages;
|
|
currentChatId.value = chatId;
|
|
currentSessionId.value = chat.sessionId || null;
|
|
}
|
|
|
|
showHistory.value = false;
|
|
shouldAutoScroll.value = true;
|
|
|
|
displayedTitle.value = chat.title || "";
|
|
aiGeneratedTitle.value = chat.title || null;
|
|
isTypingTitle.value = false;
|
|
|
|
if (chatId !== store.state.currentChatTimestamp) {
|
|
store.dispatch("setCurrentChatTimestamp", chatId);
|
|
store.dispatch("setChatUpdated", true);
|
|
}
|
|
|
|
await nextTick();
|
|
scrollToBottom();
|
|
}
|
|
} catch (error) {
|
|
console.error("Error loading chat:", error);
|
|
}
|
|
};
|
|
|
|
const sendMessage = async () => {
|
|
const hasText = inputMessage.value.trim().length > 0;
|
|
const hasImages = pendingImages.value.length > 0;
|
|
if ((!hasText && !hasImages) || isLoading.value) return;
|
|
|
|
let backendMessage = inputMessage.value;
|
|
if (chatInput.value && typeof chatInput.value.getMessageForBackend === "function") {
|
|
backendMessage = chatInput.value.getMessageForBackend();
|
|
}
|
|
|
|
const userMessage = inputMessage.value;
|
|
const messagesToSend = [...pendingImages.value]; // Capture images before clearing
|
|
|
|
// Add to query history before clearing input
|
|
if (hasText) {
|
|
addToHistory(userMessage);
|
|
}
|
|
|
|
chatMessages.value.push({
|
|
role: "user",
|
|
content: raw(backendMessage),
|
|
...(hasImages && { images: messagesToSend }),
|
|
});
|
|
inputMessage.value = "";
|
|
contextReferences.value = [];
|
|
if (chatInput.value && typeof chatInput.value.clear === "function") {
|
|
chatInput.value.clear();
|
|
}
|
|
clearPendingImages();
|
|
shouldAutoScroll.value = true;
|
|
await scrollToBottom();
|
|
await saveToHistory();
|
|
|
|
await runTurn(hasImages, messagesToSend);
|
|
};
|
|
|
|
const selectCapability = (capability: string) => {
|
|
inputMessage.value = capability.replace(/^\d+\.\s/, "");
|
|
};
|
|
|
|
const selectWelcomePrompt = (prompt: string) => {
|
|
inputMessage.value = prompt;
|
|
nextTick(() => {
|
|
chatInput.value?.focus?.();
|
|
});
|
|
};
|
|
|
|
// RichTextInput ignores modelValue while focused, so a recalled prompt must be pushed in.
|
|
const recallHistory = (direction: "up" | "down") => {
|
|
const before = inputMessage.value;
|
|
navigateHistory(direction);
|
|
// setContent rewrites textContent, so it must not run when nothing was recalled: it would flatten the chips.
|
|
if (inputMessage.value === before) return;
|
|
// The recalled text replaced the chips in the composer, so their references must not linger.
|
|
contextReferences.value = [];
|
|
if (chatInput.value && typeof chatInput.value.setContent === "function") {
|
|
chatInput.value.setContent(inputMessage.value);
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
sendMessage();
|
|
} else if (e.key === "Backspace") {
|
|
handleImageReferenceBackspace(e);
|
|
} else if (e.key === "ArrowUp") {
|
|
if (isOnFirstLine(e.target as HTMLElement)) {
|
|
e.preventDefault();
|
|
recallHistory("up");
|
|
}
|
|
} else if (e.key === "ArrowDown" && historyIndex.value > -1) {
|
|
e.preventDefault();
|
|
recallHistory("down");
|
|
}
|
|
};
|
|
|
|
const focusInput = () => {
|
|
if (chatInput.value) {
|
|
if (typeof chatInput.value.focusInput === "function") {
|
|
chatInput.value.focusInput();
|
|
} else {
|
|
chatInput.value.focus();
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleReferencesUpdate = (refs: ReferenceChip[]) => {
|
|
contextReferences.value = refs;
|
|
};
|
|
|
|
watch(
|
|
() => props.isOpen,
|
|
(newValue) => {
|
|
if (newValue) {
|
|
if (chatMessages.value.length === 0) {
|
|
fetchInitialMessage();
|
|
}
|
|
loadHistory();
|
|
|
|
// Slight delay so RichTextInput is fully mounted before pending chips are processed.
|
|
nextTick(() => {
|
|
setTimeout(() => {
|
|
componentReady.value = true;
|
|
processPendingChips();
|
|
focusInput();
|
|
}, 100);
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
watch(
|
|
() => store.state.isAiChatExpanded,
|
|
(isExpanded) => {
|
|
if (isExpanded) {
|
|
nextTick(() => {
|
|
setTimeout(() => {
|
|
focusInput();
|
|
}, 150);
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
// Reset on org switch so users never see cross-org chat history.
|
|
watch(
|
|
() => store.state.selectedOrganization?.identifier,
|
|
(newOrgId, oldOrgId) => {
|
|
if (newOrgId && newOrgId !== oldOrgId) {
|
|
// A turn started under the old org must not keep streaming and writing history after the switch.
|
|
abortBackgroundStreams();
|
|
addNewChat();
|
|
if (props.isOpen) {
|
|
loadHistory();
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
onMounted(() => {
|
|
if (props.isOpen) {
|
|
fetchInitialMessage();
|
|
loadHistory();
|
|
loadChat(store.state.currentChatTimestamp);
|
|
|
|
// Slight delay so RichTextInput is fully mounted before pending chips are processed.
|
|
nextTick(() => {
|
|
setTimeout(() => {
|
|
componentReady.value = true;
|
|
processPendingChips();
|
|
focusInput();
|
|
}, 100);
|
|
});
|
|
}
|
|
|
|
window.addEventListener("o2:abort-ai-streams", abortAllStreams);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener("o2:abort-ai-streams", abortAllStreams);
|
|
// Mark unmounting FIRST so the chatUpdated watch fired by the dispatch below can't re-attach the detached stream here.
|
|
isUnmounting.value = true;
|
|
|
|
// Detach, not abort: most mount sites sit behind a v-if, so ordinary navigation unmounts this instance mid-answer.
|
|
const wasStreaming = !!currentAbortController.value;
|
|
detachCurrentStream();
|
|
// detachCurrentStream early-returns without a controller, so clear the rotation interval directly.
|
|
stopAnalyzingRotation();
|
|
|
|
// Leaving Home mid-stream opens the sidebar so its instance can re-attach; skip on Home, where it would show beside the AI tab.
|
|
if (
|
|
wasStreaming &&
|
|
props.centeredStart &&
|
|
route.name !== "home" &&
|
|
!store.state.isAiChatEnabled
|
|
) {
|
|
store.dispatch("setIsAiChatEnabled", true);
|
|
}
|
|
|
|
// Background streams are intentionally NOT aborted: surviving unmount is why they were detached.
|
|
|
|
if (typewriterAnimationId.value) {
|
|
cancelAnimationFrame(typewriterAnimationId.value);
|
|
typewriterAnimationId.value = null;
|
|
}
|
|
|
|
clearTitleInterval();
|
|
|
|
// The stream outlives this instance, so a pending render flush would write into dead chatMessages and pin the closure.
|
|
disposeRenderFlush();
|
|
|
|
// Only publish the handoff: loadChat() here would re-attach the stream to this dying instance and steal it from the survivor.
|
|
store.dispatch("setCurrentChatTimestamp", currentChatId.value);
|
|
store.dispatch("setChatUpdated", true);
|
|
});
|
|
watch(chatUpdated, (newChatUpdated: boolean) => {
|
|
// A dying instance must not react to its own handoff pulse, or it steals its detached stream back from the survivor.
|
|
if (isUnmounting.value) return;
|
|
if (newChatUpdated && store.state.currentChatTimestamp) {
|
|
loadChat(store.state.currentChatTimestamp);
|
|
}
|
|
if (newChatUpdated && !store.state.currentChatTimestamp) {
|
|
addNewChat();
|
|
}
|
|
store.dispatch("setChatUpdated", false);
|
|
});
|
|
|
|
const processedMessages = computed(() => {
|
|
return chatMessages.value.map((message) => {
|
|
if (message.role === "user") {
|
|
const orderedBlocks = parseLogEntries(message.content);
|
|
|
|
const combinedContentBlocks =
|
|
orderedBlocks.length > 0
|
|
? [...orderedBlocks, ...(message.contentBlocks || [])]
|
|
: message.contentBlocks || [];
|
|
|
|
return {
|
|
...message,
|
|
blocks: orderedBlocks.length > 0 ? [] : processMessageContent(message.content),
|
|
contentBlocks: combinedContentBlocks,
|
|
};
|
|
}
|
|
|
|
return {
|
|
...message,
|
|
blocks: processMessageContent(message.content),
|
|
contentBlocks: message.contentBlocks || [],
|
|
};
|
|
});
|
|
});
|
|
|
|
const retryGeneration = async (message: any) => {
|
|
if (!message || message.role !== "assistant") return;
|
|
|
|
const messageIndex = chatMessages.value.findIndex((m) => m.content === message.content);
|
|
if (messageIndex === -1) return;
|
|
|
|
let userMessageIndex = messageIndex - 1;
|
|
while (userMessageIndex >= 0) {
|
|
if (chatMessages.value[userMessageIndex].role === "user") {
|
|
inputMessage.value = chatMessages.value[userMessageIndex].content;
|
|
await sendMessage();
|
|
break;
|
|
}
|
|
userMessageIndex--;
|
|
}
|
|
};
|
|
|
|
const formatTime = (timestamp: string) => {
|
|
const date = new Date(timestamp);
|
|
return date.toLocaleString();
|
|
};
|
|
|
|
const toggleToolCallExpanded = (messageIndex: number, blockIndex: number) => {
|
|
const key = `${messageIndex}-${blockIndex}`;
|
|
if (expandedToolCalls.value.has(key)) {
|
|
expandedToolCalls.value.delete(key);
|
|
} else {
|
|
expandedToolCalls.value.add(key);
|
|
}
|
|
};
|
|
|
|
const isToolCallExpanded = (messageIndex: number, blockIndex: number) => {
|
|
return expandedToolCalls.value.has(`${messageIndex}-${blockIndex}`);
|
|
};
|
|
|
|
const toggleLogEntryExpanded = (messageIndex: number, blockIndex: number) => {
|
|
const key = `${messageIndex}-${blockIndex}`;
|
|
if (expandedLogEntries.value.has(key)) {
|
|
expandedLogEntries.value.delete(key);
|
|
} else {
|
|
expandedLogEntries.value.add(key);
|
|
}
|
|
};
|
|
|
|
const isLogEntryExpanded = (messageIndex: number, blockIndex: number) => {
|
|
return expandedLogEntries.value.has(`${messageIndex}-${blockIndex}`);
|
|
};
|
|
|
|
const likeCodeBlock = async (messageIndex: number) => {
|
|
const message = chatMessages.value[messageIndex];
|
|
if (!message || message.feedback === "thumbs_up") return;
|
|
const orgId = store.state.selectedOrganization?.identifier;
|
|
if (!orgId) return;
|
|
// Each user+assistant pair = 1 query turn, so queryIndex = floor(index / 2)
|
|
const queryIndex = Math.floor(messageIndex / 2);
|
|
const success = await submitFeedback(
|
|
"thumbs_up",
|
|
orgId,
|
|
currentSessionId.value || undefined,
|
|
queryIndex,
|
|
lastTraceId.value || undefined,
|
|
);
|
|
if (success) {
|
|
message.feedback = "thumbs_up";
|
|
await saveToHistory();
|
|
toast({
|
|
variant: "success",
|
|
message: t("toastMessages.components.thanksForYourFeedback"),
|
|
});
|
|
}
|
|
};
|
|
|
|
const dislikeCodeBlock = async (messageIndex: number) => {
|
|
const message = chatMessages.value[messageIndex];
|
|
if (!message || message.feedback === "thumbs_down") return;
|
|
const orgId = store.state.selectedOrganization?.identifier;
|
|
if (!orgId) return;
|
|
const queryIndex = Math.floor(messageIndex / 2);
|
|
const success = await submitFeedback(
|
|
"thumbs_down",
|
|
orgId,
|
|
currentSessionId.value || undefined,
|
|
queryIndex,
|
|
lastTraceId.value || undefined,
|
|
);
|
|
if (success) {
|
|
message.feedback = "thumbs_down";
|
|
await saveToHistory();
|
|
toast({
|
|
variant: "success",
|
|
message: t("toastMessages.components.thanksForYourFeedback"),
|
|
});
|
|
}
|
|
};
|
|
const o2AiTitleLogo = computed(() => {
|
|
return isDark.value
|
|
? getImageURL("images/common/o2_ai_logo_dark.svg")
|
|
: getImageURL("images/common/o2_ai_logo.svg");
|
|
});
|
|
|
|
return {
|
|
raw,
|
|
showPaidUsageConsent,
|
|
inputMessage,
|
|
chatMessages,
|
|
isLoading,
|
|
currentAnalyzingMessage,
|
|
sendMessage,
|
|
handleKeyDown,
|
|
focusInput,
|
|
messagesContainer,
|
|
chatInput,
|
|
formatMessage,
|
|
capabilities,
|
|
selectCapability,
|
|
selectWelcomePrompt,
|
|
inputPlaceholder,
|
|
showHistory,
|
|
chatHistory,
|
|
currentChatId,
|
|
addNewChat,
|
|
toggleExpand,
|
|
openHistory,
|
|
loadChat,
|
|
showEditTitleDialog,
|
|
editingTitle,
|
|
openEditTitleDialog,
|
|
saveEditedTitle,
|
|
deleteChat,
|
|
confirmDeleteChat,
|
|
showDeleteChatConfirmDialog,
|
|
chatToDelete,
|
|
clearAllConversations,
|
|
showClearAllConfirmDialog,
|
|
confirmClearAllConversations,
|
|
pendingConfirmation,
|
|
handleToolConfirm,
|
|
handleToolCancel,
|
|
handleToolAlwaysConfirm,
|
|
handleNavigationAction,
|
|
sendConfirmation,
|
|
isSessionOwnerUnavailable,
|
|
appendErrorBlock,
|
|
streamOwnerUnavailable,
|
|
currentSessionId,
|
|
isAutoNavigationEnabled,
|
|
processedMessages,
|
|
processTextBlock,
|
|
copyToClipboard,
|
|
retryGeneration,
|
|
getLanguageDisplay,
|
|
processHtmlBlock,
|
|
formatTime,
|
|
loadHistory,
|
|
store,
|
|
isMac,
|
|
likeCodeBlock,
|
|
dislikeCodeBlock,
|
|
currentChatTimestamp,
|
|
o2AiTitleLogo,
|
|
saveHistoryLoading,
|
|
historySearchTerm,
|
|
filteredChatHistory,
|
|
shouldAutoScroll,
|
|
checkIfShouldAutoScroll,
|
|
getScrollThreshold,
|
|
scrollToLoadingIndicator,
|
|
scrollToBottomSmooth,
|
|
showScrollToBottom,
|
|
cancelCurrentRequest,
|
|
currentAbortController,
|
|
activeToolCall,
|
|
truncateQuery,
|
|
formatContextKey,
|
|
expandedToolCalls,
|
|
toggleToolCallExpanded,
|
|
isToolCallExpanded,
|
|
hasToolCallDetails,
|
|
getToolCallDisplayData,
|
|
formatContextValue,
|
|
expandedLogEntries,
|
|
toggleLogEntryExpanded,
|
|
isLogEntryExpanded,
|
|
formatLogEntryContent,
|
|
aiGeneratedTitle,
|
|
displayedTitle,
|
|
isTypingTitle,
|
|
pendingImages,
|
|
imageInputRef,
|
|
triggerImageUpload,
|
|
handleImageSelect,
|
|
removeImage,
|
|
handleDragOver,
|
|
handleDrop,
|
|
handlePaste,
|
|
showImagePreview,
|
|
previewImage,
|
|
openImagePreview,
|
|
closeImagePreview,
|
|
contextReferences,
|
|
handleReferencesUpdate,
|
|
t,
|
|
};
|
|
},
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* keep(keyframes): @keyframes and its consuming `animation:` must share a block; the scoped compiler renames both together. */
|
|
|
|
/* Deliberate copy in O2AIChatToolCallIndicator.vue: the standalone loading box here shares this class. */
|
|
.tool-call-indicator {
|
|
animation: fadeIn 0.3s ease;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(-0.625rem);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
.fixed-analyzing-indicator {
|
|
animation: fadeInSlide 0.3s ease;
|
|
}
|
|
|
|
@keyframes fadeInSlide {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(-0.625rem);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
.scroll-to-bottom-btn {
|
|
animation: fadeInUp 0.3s ease;
|
|
}
|
|
|
|
@keyframes fadeInUp {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(0.625rem) scale(0.9);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0) scale(1);
|
|
}
|
|
}
|
|
</style>
|