mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 08:09:22 +00:00
### **User description** 1. Remove SRE chat completely , use existing ai component ___ ### **PR Type** Enhancement ___ ### **Description** - Remove SRE chat and related state/actions - Consolidate AI chat integration across components - Update tests to dispatch AI chat actions - Prune unused payload fields and optional context injections ___ <details> <summary><h3> File Walkthrough</h3></summary> <table><thead><tr><th></th><th align="left">Relevant files</th></tr></thead><tbody><tr><td><strong>Tests</strong></td><td><details><summary>2 files</summary><table> <tr> <td><strong>IncidentDetailDrawer.spec.ts</strong><dd><code>Remove SRE chat mocks in tests</code> </dd></td> <td><a href="https://github.com/openobserve/openobserve/pull/10197/files#diff-2f7f4d7de8c794b899f5ca1d765d18a4a13e92c1e595f580068ca4a7176bda10">+0/-7</a> </td> </tr> <tr> <td><strong>IncidentList.spec.ts</strong><dd><code>Rename SRE chat tests to AI chat</code> </dd></td> <td><a href="https://github.com/openobserve/openobserve/pull/10197/files#diff-633b95716cea25470b98f260f00e6fbc641c1fbe8ed82713e346eef014ca4d50">+9/-17</a> </td> </tr> </table></details></td></tr><tr><td><strong>Enhancement</strong></td><td><details><summary>2 files</summary><table> <tr> <td><strong>incidentsContextProvider.ts</strong><dd><code>Remove optional context fields injection</code> </dd></td> <td><a href="https://github.com/openobserve/openobserve/pull/10197/files#diff-012246fbc305620dd85432d34ec1fecc32c7bb377474b7a02dc6ed3b6beba78c">+0/-14</a> </td> </tr> <tr> <td><strong>useAiChat.ts</strong><dd><code>Strip agent_type and model from payload builder</code> </dd></td> <td><a href="https://github.com/openobserve/openobserve/pull/10197/files#diff-830ee4f21bdbbd0fa5c5c0f43b0a1d0200831e07ccddc91982cf0371c801b046">+0/-8</a> </td> </tr> </table></details></td></tr><tr><td><strong>Configuration changes</strong></td><td><details><summary>1 files</summary><table> <tr> <td><strong>index.ts</strong><dd><code>Remove SRE chat state and mutations</code> </dd></td> <td><a href="https://github.com/openobserve/openobserve/pull/10197/files#diff-955a15c7ee060f1545e82be22911dd0c14307dee5a70071a394e9fd583bd2c55">+0/-12</a> </td> </tr> </table></details></td></tr><tr><td><strong>Refactoring</strong></td><td><details><summary>6 files</summary><table> <tr> <td><strong>O2AIChat.vue</strong><dd><code>Remove model selector UI and logic</code> </dd></td> <td><a href="https://github.com/openobserve/openobserve/pull/10197/files#diff-a6a125719a7b32b31b53fcfe05c12081d82c139a401699affdda0db8e73887e3">+1/-31</a> </td> </tr> <tr> <td><strong>SREChat.vue</strong><dd><code>Delete entire SREChat component</code> </dd></td> <td><a href="https://github.com/openobserve/openobserve/pull/10197/files#diff-a24e3f6392181aa741979fe1c0cbc6f0f02337de20e6ad2fd6668dd7abe9bada">+0/-2074</a></td> </tr> <tr> <td><strong>AlertList.vue</strong><dd><code>Remove openSREChat handler</code> </dd></td> <td><a href="https://github.com/openobserve/openobserve/pull/10197/files#diff-be3e41b823e163b226ffc270d95cc4981e59c600851e4e0d10ee5e4ee994ac8e">+0/-9</a> </td> </tr> <tr> <td><strong>IncidentDetailDrawer.vue</strong><dd><code>Clean up logs and remove openSREChat</code> </dd></td> <td><a href="https://github.com/openobserve/openobserve/pull/10197/files#diff-6973d58a5f49658b07d3533d4db5d32cc6430433b51732481e7497243dadb0b0">+0/-18</a> </td> </tr> <tr> <td><strong>IncidentList.vue</strong><dd><code>Remove openSREChat method</code> </dd></td> <td><a href="https://github.com/openobserve/openobserve/pull/10197/files#diff-953ad015996758a8c86e489499cd3b0a614501b99b0c9c5c242a299e34c24ff2">+0/-8</a> </td> </tr> <tr> <td><strong>MainLayout.vue</strong><dd><code>Remove SREChat panel and imports</code> </dd></td> <td><a href="https://github.com/openobserve/openobserve/pull/10197/files#diff-91e4134141264c4624d4eb4cfa987b33ed31eb6f551e089c0b26f2610dd54362">+1/-29</a> </td> </tr> </table></details></td></tr></tr></tbody></table> </details> ___
2935 lines
95 KiB
Vue
2935 lines
95 KiB
Vue
<template>
|
|
<div class="chat-container" :class="[{ 'chat-open': isOpen }, store.state.theme == 'dark' ? 'dark-mode' : 'light-mode']"
|
|
>
|
|
<div v-if="isOpen" class="chat-content-wrapper" :class="store.state.theme == 'dark' ? 'dark-mode' : 'light-mode'">
|
|
<div class="chat-header" :style="{ height: headerHeight ? headerHeight + 'px' : '' }">
|
|
<div class="chat-title tw:flex tw:justify-between tw:items-center tw:w-full">
|
|
|
|
<div class="tw:flex tw:items-center tw:gap-2">
|
|
<q-avatar size="24px">
|
|
<img :src="o2AiTitleLogo" />
|
|
</q-avatar>
|
|
<div class="tw:flex tw:items-center">
|
|
<span class="tw:mr-[5.5px]">O2 Assistant
|
|
</span>
|
|
<span class="o2-ai-beta-text"
|
|
>Beta</span>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div>
|
|
<q-btn flat round dense icon="add" @click="addNewChat" />
|
|
<q-btn flat round dense icon="history" @click="loadHistory">
|
|
<q-menu>
|
|
<!-- here we will show the history menu -->
|
|
<!-- and also the search functionality to search the history -->
|
|
<div class="history-menu-container">
|
|
<div class="search-history-bar-sticky">
|
|
<q-input
|
|
v-model="historySearchTerm"
|
|
placeholder="Search chat history"
|
|
dense
|
|
filled
|
|
borderless
|
|
class="tw:mb-2"
|
|
>
|
|
<template #prepend>
|
|
<q-icon name="search" />
|
|
</template>
|
|
</q-input>
|
|
</div>
|
|
<div class="history-list-container">
|
|
<q-list style="min-width: 200px; width: 300px; max-width: 300px; border: 1px solid var(--q-separator-color);" padding>
|
|
<q-item
|
|
v-for="chat in filteredChatHistory"
|
|
:key="chat.id"
|
|
clickable
|
|
v-ripple
|
|
v-close-popup
|
|
@click="loadChat(chat.id)"
|
|
dense
|
|
>
|
|
<q-item-section>
|
|
<div class="row items-center justify-between">
|
|
<div class="col-8 ellipsis">{{ chat.title }}</div>
|
|
<div class="col-4 text-right text-grey-7 text-caption">{{ formatTime(chat.timestamp) }}</div>
|
|
</div>
|
|
</q-item-section>
|
|
</q-item>
|
|
<q-item v-if="filteredChatHistory.length === 0">
|
|
<q-item-section class="text-center text-grey">
|
|
No matching chats found
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</div>
|
|
</div>
|
|
</q-menu>
|
|
</q-btn>
|
|
<q-btn flat round dense icon="close" @click="$emit('close')" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<q-separator class="tw:bg-[#DBDBDB]" />
|
|
|
|
<!-- History Panel -->
|
|
<q-dialog v-model="showHistory" position="right">
|
|
<q-card style="width: 350px; max-width: 100vw; height: 100vh;">
|
|
<q-card-section class="row items-center q-pb-none">
|
|
<div class="text-h6">Chat History</div>
|
|
<q-space />
|
|
<q-btn icon="close" flat round dense v-close-popup />
|
|
</q-card-section>
|
|
|
|
<q-card-section class="q-pa-md" style="max-height: calc(100vh - 70px); overflow: auto;">
|
|
<q-list separator>
|
|
<q-item
|
|
v-for="chat in chatHistory"
|
|
:key="chat.id"
|
|
clickable
|
|
v-ripple
|
|
@click="loadChat(chat.id)"
|
|
>
|
|
<q-item-section>
|
|
<q-item-label>{{ chat.title }}</q-item-label>
|
|
<q-item-label caption>
|
|
{{ new Date(chat.timestamp).toLocaleString() }}
|
|
</q-item-label>
|
|
<q-item-label caption>
|
|
Model: {{ chat.model }}
|
|
</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</q-card-section>
|
|
</q-card>
|
|
</q-dialog>
|
|
|
|
<div class="chat-content " :class="store.state.theme == 'dark' ? 'dark-mode' : 'light-mode'">
|
|
<div class="messages-container " ref="messagesContainer" @scroll="checkIfShouldAutoScroll">
|
|
<div v-if="chatMessages.length === 0" class="welcome-section ">
|
|
<div class="tw:flex tw:flex-col tw:items-center tw:justify-center tw:h-full ">
|
|
<img :src="o2AiTitleLogo" />
|
|
<span class="tw:text-[14px] tw:font-[600] tw:text-center">AI native observability</span>
|
|
</div>
|
|
</div>
|
|
<div v-for="(message, index) in processedMessages"
|
|
:key="index"
|
|
class="message"
|
|
:class="[
|
|
message.role,
|
|
{ 'error-message': message.content.startsWith('Error:') }
|
|
]">
|
|
<div class="message-content" >
|
|
<q-avatar v-if="message.role === 'user'" size="24px" :class="store.state.theme == 'dark' ? 'dark-user-avatar' : 'light-user-avatar'">
|
|
<q-icon size="16px" name="person" :color="store.state.theme == 'dark' ? 'white' : '#4a5568'" />
|
|
</q-avatar>
|
|
<div class="message-blocks" style="background-color: transparent;" :class="store.state.theme == 'dark' ? 'dark-mode' : 'light-mode'">
|
|
<!-- Loading indicator inside message box for empty assistant messages -->
|
|
<div v-if="message.role === 'assistant' && (!message.contentBlocks || message.contentBlocks.length === 0) && (!message.content || message.content.trim() === '') && isLoading" class="inline-loading">
|
|
<q-spinner-dots color="primary" size="1.5em" />
|
|
<span>{{ currentAnalyzingMessage }}</span>
|
|
</div>
|
|
<!-- Render contentBlocks in sequence (interleaved tool calls + text) -->
|
|
<template v-for="(block, blockIndex) in message.contentBlocks" :key="'cb-' + blockIndex">
|
|
<!-- Tool call block - expandable -->
|
|
<div
|
|
v-if="block.type === 'tool_call'"
|
|
class="tool-call-item"
|
|
:class="[store.state.theme == 'dark' ? 'dark-mode' : 'light-mode', { 'has-details': hasToolCallDetails(block) }]"
|
|
@click="hasToolCallDetails(block) && toggleToolCallExpanded(index, blockIndex)"
|
|
>
|
|
<div class="tool-call-header">
|
|
<q-icon name="check_circle" size="14px" color="positive" />
|
|
<span class="tool-call-name">
|
|
{{ formatToolCallMessage(block).text }}<strong v-if="formatToolCallMessage(block).highlight">{{ formatToolCallMessage(block).highlight }}</strong>{{ formatToolCallMessage(block).suffix }}
|
|
</span>
|
|
<q-icon
|
|
v-if="hasToolCallDetails(block)"
|
|
:name="isToolCallExpanded(index, blockIndex) ? 'expand_less' : 'expand_more'"
|
|
size="16px"
|
|
class="expand-icon"
|
|
/>
|
|
</div>
|
|
<!-- Expandable details -->
|
|
<div v-if="isToolCallExpanded(index, blockIndex)" class="tool-call-details" @click.stop>
|
|
<div v-if="getToolCallDisplayData(block.context)?.query" class="detail-item">
|
|
<div class="detail-header">
|
|
<span class="detail-label">Query</span>
|
|
<q-btn
|
|
flat
|
|
dense
|
|
size="xs"
|
|
icon="content_copy"
|
|
class="copy-btn"
|
|
@click.stop="copyToClipboard(getToolCallDisplayData(block.context)?.query)"
|
|
>
|
|
<q-tooltip>Copy query</q-tooltip>
|
|
</q-btn>
|
|
</div>
|
|
<code class="detail-value query-value">{{ getToolCallDisplayData(block.context)?.query }}</code>
|
|
</div>
|
|
<div v-if="getToolCallDisplayData(block.context)?.stream" class="detail-item">
|
|
<span class="detail-label">Stream</span>
|
|
<code class="detail-value">{{ getToolCallDisplayData(block.context)?.stream }}</code>
|
|
</div>
|
|
<div v-if="getToolCallDisplayData(block.context)?.type" class="detail-item">
|
|
<span class="detail-label">Type</span>
|
|
<code class="detail-value">{{ getToolCallDisplayData(block.context)?.type }}</code>
|
|
</div>
|
|
<div v-if="getToolCallDisplayData(block.context)?.start_time" class="detail-item">
|
|
<span class="detail-label">Start</span>
|
|
<span class="detail-value">{{ formatTimestamp(getToolCallDisplayData(block.context)?.start_time) }}</span>
|
|
</div>
|
|
<div v-if="getToolCallDisplayData(block.context)?.end_time" class="detail-item">
|
|
<span class="detail-label">End</span>
|
|
<span class="detail-value">{{ formatTimestamp(getToolCallDisplayData(block.context)?.end_time) }}</span>
|
|
</div>
|
|
<div v-if="getToolCallDisplayData(block.context)?.from !== undefined" class="detail-item">
|
|
<span class="detail-label">From</span>
|
|
<span class="detail-value">{{ getToolCallDisplayData(block.context)?.from }}</span>
|
|
</div>
|
|
<div v-if="getToolCallDisplayData(block.context)?.size !== undefined" class="detail-item">
|
|
<span class="detail-label">Size</span>
|
|
<span class="detail-value">{{ getToolCallDisplayData(block.context)?.size }}</span>
|
|
</div>
|
|
<div v-if="getToolCallDisplayData(block.context)?.query_type" class="detail-item">
|
|
<span class="detail-label">Query Type</span>
|
|
<code class="detail-value">{{ getToolCallDisplayData(block.context)?.query_type }}</code>
|
|
</div>
|
|
<div v-if="getToolCallDisplayData(block.context)?.vrl" class="detail-item">
|
|
<div class="detail-header">
|
|
<span class="detail-label">VRL</span>
|
|
<q-btn
|
|
flat
|
|
dense
|
|
size="xs"
|
|
icon="content_copy"
|
|
class="copy-btn"
|
|
@click.stop="copyToClipboard(getToolCallDisplayData(block.context)?.vrl)"
|
|
>
|
|
<q-tooltip>Copy VRL</q-tooltip>
|
|
</q-btn>
|
|
</div>
|
|
<code class="detail-value query-value">{{ getToolCallDisplayData(block.context)?.vrl }}</code>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Text block - render with markdown processing -->
|
|
<template v-else-if="block.type === 'text' && block.text">
|
|
<template v-for="(textBlock, tbIndex) in processTextBlock(block.text)" :key="'tb-' + blockIndex + '-' + tbIndex">
|
|
<div v-if="textBlock.type === 'code'" class="code-block">
|
|
<div class="code-block-header code-block-theme">
|
|
<span v-if="textBlock.language" class="code-type-label">
|
|
{{ getLanguageDisplay(textBlock.language) }}
|
|
</span>
|
|
<q-btn
|
|
flat
|
|
dense
|
|
class="copy-button"
|
|
no-caps
|
|
color="primary"
|
|
@click="copyToClipboard(textBlock.content)"
|
|
>
|
|
<div class="tw:flex tw:items-center">
|
|
<q-icon size="16px" name="content_copy" />
|
|
<span class="tw:ml-1">Copy</span>
|
|
</div>
|
|
</q-btn>
|
|
</div>
|
|
<span class="generated-code-block">
|
|
<code :class="['hljs', textBlock.language]" v-html="textBlock.highlightedContent"></code>
|
|
</span>
|
|
<div class="code-block-footer code-block-theme tw:flex tw:items-center tw:justify-between tw:w-full">
|
|
<q-btn
|
|
flat
|
|
dense
|
|
class="retry-button"
|
|
no-caps
|
|
color="primary"
|
|
@click="retryGeneration(message)"
|
|
>
|
|
<div class="tw:flex tw:items-center">
|
|
<q-icon size="16px" name="refresh" />
|
|
<span class="tw:ml-1">Retry</span>
|
|
</div>
|
|
</q-btn>
|
|
</div>
|
|
</div>
|
|
<div v-else class="text-block" v-html="processHtmlBlock(textBlock.content)"></div>
|
|
</template>
|
|
</template>
|
|
</template>
|
|
<!-- Fallback for messages without contentBlocks (user messages or old assistant messages) -->
|
|
<template v-if="!message.contentBlocks || message.contentBlocks.length === 0">
|
|
<template v-for="(block, blockIndex) in message.blocks" :key="'fb-' + blockIndex">
|
|
<div v-if="block.type === 'code'" class="code-block">
|
|
<div class="code-block-header code-block-theme">
|
|
<span v-if="block.language" class="code-type-label">
|
|
{{ getLanguageDisplay(block.language) }}
|
|
</span>
|
|
<q-btn
|
|
flat
|
|
dense
|
|
class="copy-button"
|
|
no-caps
|
|
color="primary"
|
|
@click="copyToClipboard(block.content)"
|
|
>
|
|
<div class="tw:flex tw:items-center">
|
|
<q-icon size="16px" name="content_copy" />
|
|
<span class="tw:ml-1">Copy</span>
|
|
</div>
|
|
</q-btn>
|
|
</div>
|
|
<span class="generated-code-block">
|
|
<code :class="['hljs', block.language]" v-html="block.highlightedContent"></code>
|
|
</span>
|
|
</div>
|
|
<div v-else class="text-block" v-html="processHtmlBlock(block.content)"></div>
|
|
</template>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Tool call indicator - shows outside message box -->
|
|
<div v-if="activeToolCall" class="tool-call-indicator" :class="store.state.theme == 'dark' ? 'dark-mode' : 'light-mode'">
|
|
<div class="tool-call-content">
|
|
<q-spinner-dots color="primary" size="1.5em" />
|
|
<div class="tool-call-info">
|
|
<span class="tool-call-message">{{ activeToolCall.message }}</span>
|
|
<div v-if="getToolCallDisplayData(activeToolCall.context)" class="tool-call-context">
|
|
<div v-if="getToolCallDisplayData(activeToolCall.context)?.query" class="context-item">
|
|
<code class="context-query">{{ truncateQuery(getToolCallDisplayData(activeToolCall.context)?.query) }}</code>
|
|
</div>
|
|
<span v-if="getToolCallDisplayData(activeToolCall.context)?.stream" class="context-tag">
|
|
Stream: {{ getToolCallDisplayData(activeToolCall.context)?.stream }}
|
|
</span>
|
|
<span v-if="getToolCallDisplayData(activeToolCall.context)?.query_type" class="context-tag">
|
|
Type: {{ getToolCallDisplayData(activeToolCall.context)?.query_type }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Standalone loading indicator - only shown when loading with no tool calls -->
|
|
<div v-if="isLoading && !activeToolCall" id="loading-indicator" class="tw:flex tw:items-center tw:gap-2 tw:p-4">
|
|
<q-spinner-dots color="primary" size="2em" />
|
|
<span>{{ currentAnalyzingMessage }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Scroll to bottom button -->
|
|
<div
|
|
v-show="showScrollToBottom"
|
|
class="scroll-to-bottom-container"
|
|
>
|
|
<q-btn
|
|
round
|
|
flat
|
|
icon="arrow_downward"
|
|
class="scroll-to-bottom-btn"
|
|
@click="scrollToBottomSmooth"
|
|
size="sm"
|
|
>
|
|
<q-tooltip anchor="top middle" self="bottom middle">
|
|
Scroll to bottom
|
|
</q-tooltip>
|
|
</q-btn>
|
|
</div>
|
|
</div>
|
|
<div class="chat-input-wrapper tw:flex tw:flex-col q-ma-md" @click="focusInput">
|
|
<q-input
|
|
ref="chatInput"
|
|
v-model="inputMessage"
|
|
placeholder="Write your prompt"
|
|
dense
|
|
:disable="isLoading"
|
|
rows="10"
|
|
@keydown="handleKeyDown"
|
|
type="textarea"
|
|
autogrow
|
|
:borderless="true"
|
|
style="max-height: 250px; overflow-y: auto; font-size: 16px;"
|
|
class="chat-input"
|
|
flat
|
|
>
|
|
</q-input>
|
|
<div class="tw:flex tw:items-center tw:justify-end tw:mt-2 tw:gap-2" :class="store.state.theme == 'dark' ? 'dark-mode-bottom-bar' : 'light-mode-bottom-bar'">
|
|
<!-- Debug info - remove this later -->
|
|
<!-- <div class="tw:text-xs tw:text-gray-500">Loading: {{ isLoading }}, Input: {{ inputMessage.length }}</div> -->
|
|
|
|
<!-- Send button - shown when not loading -->
|
|
<q-btn
|
|
v-if="!isLoading"
|
|
:disable="!inputMessage.trim()"
|
|
@click="sendMessage"
|
|
round
|
|
dense
|
|
flat
|
|
class="tw:ml-1 send-button"
|
|
>
|
|
<q-icon name="send" size="16px" color="white" />
|
|
</q-btn>
|
|
|
|
<!-- Stop button - shown when loading/streaming -->
|
|
<q-btn
|
|
v-if="isLoading"
|
|
@click="cancelCurrentRequest"
|
|
round
|
|
dense
|
|
flat
|
|
class="tw:ml-1 stop-button"
|
|
>
|
|
<q-icon name="stop" size="16px" color="white" />
|
|
</q-btn>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { defineComponent, ref, onMounted, nextTick, watch, computed, onUnmounted } from 'vue';
|
|
import hljs from 'highlight.js';
|
|
import 'highlight.js/styles/github.css';
|
|
import 'highlight.js/styles/github-dark.css';
|
|
import { marked } from 'marked';
|
|
import { MarkedOptions } from 'marked';
|
|
import DOMPurify from 'dompurify';
|
|
import { useQuasar } from 'quasar';
|
|
import { useStore } from 'vuex';
|
|
import useAiChat from '@/composables/useAiChat';
|
|
import { outlinedThumbUpOffAlt, outlinedThumbDownOffAlt } from '@quasar/extras/material-icons-outlined';
|
|
import { getImageURL } from '@/utils/zincutils';
|
|
import { ChatMessage, ChatHistoryEntry, ToolCall, ContentBlock } from '@/types/chat';
|
|
|
|
// Add IndexedDB setup
|
|
const DB_NAME = 'o2ChatDB';
|
|
const DB_VERSION = 1;
|
|
const STORE_NAME = 'chatHistory';
|
|
|
|
const { fetchAiChat } = useAiChat();
|
|
|
|
const initDB = () => {
|
|
return new Promise<IDBDatabase>((resolve, reject) => {
|
|
//this opens / creates(if not exists) the database with the name o2ChatDB and version 1
|
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
|
|
request.onerror = () => reject(request.error);
|
|
//this is called when the database is successfully opened and returns the database object
|
|
request.onsuccess = () => resolve(request.result);
|
|
//this is called when the database is created for the first time / when the version is changed
|
|
request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
|
|
const db = (event.target as IDBOpenDBRequest).result;
|
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
//this creates the object store with the name chatHistory and the key is id , autoIncrement is true
|
|
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
|
|
//this creates the index with the name timestamp
|
|
store.createIndex('timestamp', 'timestamp', { unique: false });
|
|
//this creates the index with the name title
|
|
store.createIndex('title', 'title', { unique: false });
|
|
}
|
|
};
|
|
});
|
|
};
|
|
|
|
// Register VRL as a JavaScript alias (type assertion)
|
|
hljs.registerLanguage('vrl', () => hljs.getLanguage('javascript') as any);
|
|
|
|
// Configure marked options with custom language support
|
|
const markedOptions = {
|
|
breaks: true,
|
|
gfm: true,
|
|
langPrefix: 'hljs language-',
|
|
headerIds: false,
|
|
mangle: false,
|
|
sanitize: false, // Allow HTML in markdown
|
|
highlight: (code: string, lang: string) => {
|
|
if (lang === 'vrl') {
|
|
return hljs.highlight(code, { language: 'javascript' }).value;
|
|
}
|
|
if (lang && hljs.getLanguage(lang)) {
|
|
return hljs.highlight(code, { language: lang }).value;
|
|
}
|
|
return hljs.highlightAuto(code).value;
|
|
}
|
|
} as MarkedOptions;
|
|
|
|
marked.setOptions(markedOptions);
|
|
|
|
// Function to render markdown content
|
|
function renderMarkdown(content: any) {
|
|
return marked.parse(content);
|
|
}
|
|
|
|
export default defineComponent({
|
|
name: 'O2AIChat',
|
|
props: {
|
|
isOpen: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
headerHeight: {
|
|
type: Number,
|
|
default: 0,
|
|
},
|
|
//this will be used to set the input message if the user sends the data from any page by clicking on the ai chat button
|
|
aiChatInputContext: {
|
|
type: String,
|
|
default: ''
|
|
}
|
|
},
|
|
setup(props) {
|
|
const $q = useQuasar();
|
|
const inputMessage = ref(props.aiChatInputContext ? props.aiChatInputContext : '');
|
|
const chatMessages = ref<ChatMessage[]>([]);
|
|
const isLoading = ref(false);
|
|
const messagesContainer = ref<HTMLElement | null>(null);
|
|
const chatInput = ref<HTMLElement | null>(null);
|
|
const currentStreamingMessage = ref('');
|
|
const currentTextSegment = ref(''); // Track current text segment (resets after each tool call)
|
|
const selectedProvider = ref<string>('openai');
|
|
const showHistory = ref(false);
|
|
const chatHistory = ref<ChatHistoryEntry[]>([]);
|
|
const currentChatId = ref<number | null>(null);
|
|
const store = useStore ();
|
|
const chatUpdated = computed(() => store.state.chatUpdated);
|
|
|
|
const currentChatTimestamp = ref<string | null>(null);
|
|
const saveHistoryLoading = ref(false);
|
|
const historySearchTerm = ref('');
|
|
const shouldAutoScroll = ref(true);
|
|
const showScrollToBottom = ref(false);
|
|
|
|
// Track expanded tool calls by message index and block index
|
|
const expandedToolCalls = ref<Set<string>>(new Set());
|
|
|
|
// Active tool call state - for showing tool progress outside message box
|
|
const activeToolCall = ref<{ tool: string; message: string; context: Record<string, any> } | null>(null);
|
|
|
|
// Pending tool calls - stores tool calls that arrive before text content to avoid empty message boxes
|
|
const pendingToolCalls = ref<ContentBlock[]>([]);
|
|
|
|
// AbortController for managing request cancellation - allows users to stop ongoing AI requests
|
|
const currentAbortController = ref<AbortController | null>(null);
|
|
|
|
// Analyzing messages for loading indicator
|
|
const ANALYZING_MESSAGES = [
|
|
"Analyzing...",
|
|
"Thinking...",
|
|
"Processing...",
|
|
"Examining data...",
|
|
"Reviewing context...",
|
|
"Formulating response...",
|
|
"Checking details...",
|
|
"Gathering insights...",
|
|
"Evaluating options...",
|
|
"Synthesizing information...",
|
|
"Working on it...",
|
|
"Almost there...",
|
|
"Diving deeper...",
|
|
"Connecting the dots...",
|
|
"Crunching numbers...",
|
|
"Exploring possibilities...",
|
|
"Refining answer...",
|
|
"Still thinking...",
|
|
"Making progress...",
|
|
"Piecing together..."
|
|
];
|
|
const currentAnalyzingMessage = ref(ANALYZING_MESSAGES[0]);
|
|
const analyzingRotationInterval = ref<NodeJS.Timeout | null>(null);
|
|
|
|
/**
|
|
* Start rotating the analyzing message every 5 seconds
|
|
*/
|
|
const startAnalyzingRotation = () => {
|
|
currentAnalyzingMessage.value = ANALYZING_MESSAGES[Math.floor(Math.random() * ANALYZING_MESSAGES.length)];
|
|
analyzingRotationInterval.value = setInterval(() => {
|
|
currentAnalyzingMessage.value = ANALYZING_MESSAGES[Math.floor(Math.random() * ANALYZING_MESSAGES.length)];
|
|
}, 5000);
|
|
};
|
|
|
|
/**
|
|
* Stop rotating the analyzing message
|
|
*/
|
|
const stopAnalyzingRotation = () => {
|
|
if (analyzingRotationInterval.value) {
|
|
clearInterval(analyzingRotationInterval.value);
|
|
analyzingRotationInterval.value = null;
|
|
}
|
|
};
|
|
|
|
// Query history functionality
|
|
const queryHistory = ref<string[]>([]);
|
|
const historyIndex = ref(-1);
|
|
const HISTORY_KEY = 'ai-chat-query-history';
|
|
const MAX_HISTORY_SIZE = 10;
|
|
|
|
const modelConfig: any = {
|
|
openai: [
|
|
'gpt-4.1'
|
|
],
|
|
groq: [
|
|
'llama-3.3-70b-versatile',
|
|
'meta-llama/llama-4-scout-17b-16e-instruct',
|
|
'meta-llama/llama-4-maverick-17b-128e-instruct'
|
|
],
|
|
xai: [
|
|
'xai/grok-3-mini-beta',
|
|
'xai/grok-3-latest'
|
|
]
|
|
};
|
|
|
|
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 getScrollThreshold = () => {
|
|
return 50; // Fixed 50px threshold for all screens
|
|
};
|
|
|
|
const checkIfShouldAutoScroll = () => {
|
|
if (!messagesContainer.value) return;
|
|
|
|
const { scrollTop, scrollHeight, clientHeight } = messagesContainer.value;
|
|
const threshold = getScrollThreshold();
|
|
const isAtBottom = scrollTop + clientHeight >= scrollHeight - threshold;
|
|
|
|
shouldAutoScroll.value = isAtBottom;
|
|
|
|
// Show scroll to bottom button when user scrolls up significantly
|
|
// Only show if there's enough content to scroll and user is not at bottom
|
|
const hasScrollableContent = scrollHeight > clientHeight + 100; // At least 100px more content
|
|
const isScrolledUp = scrollTop + clientHeight < scrollHeight - 100; // 100px from bottom
|
|
|
|
showScrollToBottom.value = hasScrollableContent && isScrolledUp;
|
|
};
|
|
|
|
const scrollToBottom = async () => {
|
|
await nextTick();
|
|
if (messagesContainer.value && shouldAutoScroll.value) {
|
|
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
|
|
}
|
|
};
|
|
|
|
const scrollToBottomSmooth = async () => {
|
|
await nextTick();
|
|
if (messagesContainer.value) {
|
|
messagesContainer.value.scrollTo({
|
|
top: messagesContainer.value.scrollHeight,
|
|
behavior: 'smooth'
|
|
});
|
|
// Hide the button immediately when user clicks it
|
|
showScrollToBottom.value = false;
|
|
// Reset auto-scroll when user manually scrolls to bottom
|
|
shouldAutoScroll.value = true;
|
|
}
|
|
};
|
|
|
|
|
|
const scrollToLoadingIndicator = async () => {
|
|
await nextTick();
|
|
const loadingElement = document.getElementById('loading-indicator');
|
|
if (loadingElement) {
|
|
loadingElement.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Cancels the currently ongoing AI chat request if one exists
|
|
* This will stop the streaming response and clean up the request state
|
|
* Shows a user-friendly notification about the cancellation
|
|
*
|
|
* Called when user clicks the "Stop" button during message generation
|
|
*/
|
|
const cancelCurrentRequest = async () => {
|
|
if (currentAbortController.value) {
|
|
currentAbortController.value.abort();
|
|
currentAbortController.value = null;
|
|
|
|
// Show user notification about successful cancellation
|
|
$q.notify({
|
|
message: 'Response generation stopped',
|
|
color: 'secondary',
|
|
position: 'bottom',
|
|
timeout: 2000,
|
|
icon: 'stop'
|
|
});
|
|
|
|
// Update UI state to reflect cancellation
|
|
isLoading.value = false;
|
|
activeToolCall.value = null;
|
|
stopAnalyzingRotation();
|
|
|
|
// Handle partial message cleanup
|
|
if (chatMessages.value.length > 0) {
|
|
const lastMessage = chatMessages.value[chatMessages.value.length - 1];
|
|
if (lastMessage.role === 'assistant') {
|
|
if (!lastMessage.content) {
|
|
// Remove empty assistant message that was added for streaming
|
|
chatMessages.value.pop();
|
|
} else if (currentStreamingMessage.value) {
|
|
// Keep partial content but indicate it was cancelled
|
|
lastMessage.content += '\n\n_[Response stopped by user]_';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reset streaming state
|
|
currentStreamingMessage.value = '';
|
|
currentTextSegment.value = '';
|
|
|
|
// Save the current state including cancellation
|
|
await saveToHistory();
|
|
|
|
// Scroll to show the final state
|
|
await scrollToBottom();
|
|
}
|
|
};
|
|
|
|
watch(() => props.aiChatInputContext, (newAiChatInputContext: string) => {
|
|
if(newAiChatInputContext) {
|
|
inputMessage.value = newAiChatInputContext;
|
|
}
|
|
});
|
|
|
|
|
|
//fetchInitialMessage is called when the component is mounted and the isOpen prop is true
|
|
|
|
const fetchInitialMessage = async () => {
|
|
isLoading.value = true;
|
|
try {
|
|
chatMessages.value = [];
|
|
} catch (error) {
|
|
chatMessages.value = [{
|
|
role: 'assistant',
|
|
content: 'Error: Unable to connect to backend'
|
|
}];
|
|
console.error('Error fetching initial message:', error);
|
|
}
|
|
isLoading.value = false;
|
|
stopAnalyzingRotation();
|
|
scrollToBottom();
|
|
};
|
|
|
|
const processStream = async (reader: ReadableStreamDefaultReader<Uint8Array>) => {
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
let messageComplete = false;
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
// Append new chunk to existing buffer
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
// Process each line that starts with 'data: '
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop() || ''; // Keep last potentially incomplete line
|
|
|
|
for (const line of lines) {
|
|
if (line.trim().startsWith('data: ')) {
|
|
try {
|
|
// Extract everything after 'data: ' and before any line break
|
|
const jsonStr = line.substring(line.indexOf('{'));
|
|
|
|
// Skip empty or invalid JSON strings
|
|
if (!jsonStr || !jsonStr.trim()) continue;
|
|
|
|
// Try to parse the JSON, handling potential errors
|
|
try {
|
|
const data = JSON.parse(jsonStr);
|
|
|
|
// Handle tool_call events - show spinner indicator, don't add to chat yet
|
|
if (data && data.type === 'tool_call') {
|
|
// If there's already an active tool call, complete it first
|
|
if (activeToolCall.value) {
|
|
const completedToolBlock: ContentBlock = {
|
|
type: 'tool_call',
|
|
tool: activeToolCall.value.tool,
|
|
message: activeToolCall.value.message,
|
|
context: activeToolCall.value.context
|
|
};
|
|
let lastMessage = chatMessages.value[chatMessages.value.length - 1];
|
|
if (lastMessage && lastMessage.role === 'assistant') {
|
|
if (!lastMessage.contentBlocks) lastMessage.contentBlocks = [];
|
|
lastMessage.contentBlocks.push(completedToolBlock);
|
|
} else {
|
|
pendingToolCalls.value.push(completedToolBlock);
|
|
}
|
|
}
|
|
|
|
// Show active indicator (blue spinner box) - don't add to chat yet
|
|
activeToolCall.value = {
|
|
tool: data.tool,
|
|
message: data.message,
|
|
context: data.context || {}
|
|
};
|
|
|
|
// Reset text segment - next text will start a new block
|
|
currentTextSegment.value = '';
|
|
await scrollToBottom();
|
|
continue;
|
|
}
|
|
|
|
// Handle complete events - complete any active tool call
|
|
if (data && data.type === 'complete') {
|
|
if (activeToolCall.value) {
|
|
const completedToolBlock: ContentBlock = {
|
|
type: 'tool_call',
|
|
tool: activeToolCall.value.tool,
|
|
message: activeToolCall.value.message,
|
|
context: activeToolCall.value.context
|
|
};
|
|
let lastMessage = chatMessages.value[chatMessages.value.length - 1];
|
|
if (lastMessage && lastMessage.role === 'assistant') {
|
|
if (!lastMessage.contentBlocks) lastMessage.contentBlocks = [];
|
|
lastMessage.contentBlocks.push(completedToolBlock);
|
|
} else {
|
|
pendingToolCalls.value.push(completedToolBlock);
|
|
}
|
|
activeToolCall.value = null;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Handle message content (type === 'message' or legacy format with just content)
|
|
if (data && typeof data.content === 'string') {
|
|
// Complete any active tool call first (add green checkmark to chat)
|
|
if (activeToolCall.value) {
|
|
const completedToolBlock: ContentBlock = {
|
|
type: 'tool_call',
|
|
tool: activeToolCall.value.tool,
|
|
message: activeToolCall.value.message,
|
|
context: activeToolCall.value.context
|
|
};
|
|
let lastMessage = chatMessages.value[chatMessages.value.length - 1];
|
|
if (lastMessage && lastMessage.role === 'assistant') {
|
|
if (!lastMessage.contentBlocks) lastMessage.contentBlocks = [];
|
|
lastMessage.contentBlocks.push(completedToolBlock);
|
|
} else {
|
|
pendingToolCalls.value.push(completedToolBlock);
|
|
}
|
|
activeToolCall.value = null;
|
|
}
|
|
|
|
// Format code blocks with proper line breaks
|
|
let content = data.content;
|
|
content = content.replace(/```(\w*)\s*([^`])/g, '```$1\n$2');
|
|
content = content.replace(/([^`])\s*```/g, '$1\n```');
|
|
|
|
// Add newline separator if starting a new text segment after tool call
|
|
if (currentStreamingMessage.value && currentTextSegment.value === '') {
|
|
currentStreamingMessage.value += '\n\n';
|
|
}
|
|
// Add newline between consecutive message events if needed
|
|
// (when current content doesn't end with newline and new content doesn't start with newline)
|
|
else if (currentStreamingMessage.value &&
|
|
!currentStreamingMessage.value.endsWith('\n') &&
|
|
!content.startsWith('\n')) {
|
|
currentStreamingMessage.value += '\n\n';
|
|
currentTextSegment.value += '\n\n';
|
|
}
|
|
|
|
// Accumulate to both total content and current segment
|
|
currentStreamingMessage.value += content;
|
|
currentTextSegment.value += content;
|
|
|
|
// Get or create assistant message
|
|
let lastMessage = chatMessages.value[chatMessages.value.length - 1];
|
|
if (!lastMessage || lastMessage.role !== 'assistant') {
|
|
// Create new assistant message with pending tool calls + text
|
|
chatMessages.value.push({
|
|
role: 'assistant',
|
|
content: currentStreamingMessage.value,
|
|
contentBlocks: [...pendingToolCalls.value, { type: 'text', text: currentTextSegment.value }]
|
|
});
|
|
pendingToolCalls.value = []; // Clear pending
|
|
} else {
|
|
// Update existing assistant message's total content
|
|
lastMessage.content = currentStreamingMessage.value;
|
|
|
|
// Update or add text block in contentBlocks
|
|
if (!lastMessage.contentBlocks) {
|
|
lastMessage.contentBlocks = [];
|
|
}
|
|
|
|
// Find the last text block and update it, or create new one
|
|
const lastBlock = lastMessage.contentBlocks[lastMessage.contentBlocks.length - 1];
|
|
if (lastBlock && lastBlock.type === 'text') {
|
|
// Append to existing text block (same segment)
|
|
lastBlock.text = currentTextSegment.value;
|
|
} else {
|
|
// Add new text block (after tool call - new segment)
|
|
lastMessage.contentBlocks.push({ type: 'text', text: currentTextSegment.value });
|
|
}
|
|
}
|
|
messageComplete = true;
|
|
await scrollToBottom();
|
|
}
|
|
} catch (jsonError) {
|
|
console.debug('JSON parse error:', jsonError, 'for line:', jsonStr);
|
|
continue;
|
|
}
|
|
} catch (e) {
|
|
console.debug('Error processing line:', e, 'Line:', line);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process any remaining complete data in buffer
|
|
if (buffer.trim()) {
|
|
const lines = buffer.split('\n');
|
|
for (const line of lines) {
|
|
if (line.trim().startsWith('data: ')) {
|
|
try {
|
|
const jsonStr = line.substring(line.indexOf('{'));
|
|
if (!jsonStr || !jsonStr.trim()) continue;
|
|
|
|
const data = JSON.parse(jsonStr);
|
|
|
|
// Handle tool_call events - show spinner, don't add to chat yet
|
|
if (data && data.type === 'tool_call') {
|
|
// If there's already an active tool call, complete it first
|
|
if (activeToolCall.value) {
|
|
const completedToolBlock: ContentBlock = {
|
|
type: 'tool_call',
|
|
tool: activeToolCall.value.tool,
|
|
message: activeToolCall.value.message,
|
|
context: activeToolCall.value.context
|
|
};
|
|
let lastMessage = chatMessages.value[chatMessages.value.length - 1];
|
|
if (lastMessage && lastMessage.role === 'assistant') {
|
|
if (!lastMessage.contentBlocks) lastMessage.contentBlocks = [];
|
|
lastMessage.contentBlocks.push(completedToolBlock);
|
|
} else {
|
|
pendingToolCalls.value.push(completedToolBlock);
|
|
}
|
|
}
|
|
|
|
// Set new active tool call (shows spinner indicator)
|
|
activeToolCall.value = {
|
|
tool: data.tool,
|
|
message: data.message,
|
|
context: data.context || {}
|
|
};
|
|
|
|
// Reset text segment for next text block
|
|
currentTextSegment.value = '';
|
|
continue;
|
|
}
|
|
|
|
// Handle complete events - complete any active tool call
|
|
if (data && data.type === 'complete') {
|
|
if (activeToolCall.value) {
|
|
const completedToolBlock: ContentBlock = {
|
|
type: 'tool_call',
|
|
tool: activeToolCall.value.tool,
|
|
message: activeToolCall.value.message,
|
|
context: activeToolCall.value.context
|
|
};
|
|
let lastMessage = chatMessages.value[chatMessages.value.length - 1];
|
|
if (lastMessage && lastMessage.role === 'assistant') {
|
|
if (!lastMessage.contentBlocks) lastMessage.contentBlocks = [];
|
|
lastMessage.contentBlocks.push(completedToolBlock);
|
|
} else {
|
|
pendingToolCalls.value.push(completedToolBlock);
|
|
}
|
|
activeToolCall.value = null;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Handle message content
|
|
if (data && typeof data.content === 'string') {
|
|
// Complete any active tool call first (add green checkmark to chat)
|
|
if (activeToolCall.value) {
|
|
const completedToolBlock: ContentBlock = {
|
|
type: 'tool_call',
|
|
tool: activeToolCall.value.tool,
|
|
message: activeToolCall.value.message,
|
|
context: activeToolCall.value.context
|
|
};
|
|
let lastMessage = chatMessages.value[chatMessages.value.length - 1];
|
|
if (lastMessage && lastMessage.role === 'assistant') {
|
|
if (!lastMessage.contentBlocks) lastMessage.contentBlocks = [];
|
|
lastMessage.contentBlocks.push(completedToolBlock);
|
|
} else {
|
|
pendingToolCalls.value.push(completedToolBlock);
|
|
}
|
|
activeToolCall.value = null;
|
|
}
|
|
|
|
let content = data.content;
|
|
content = content.replace(/```(\w*)\s*([^`])/g, '```$1\n$2');
|
|
content = content.replace(/([^`])\s*```/g, '$1\n```');
|
|
|
|
// Add newline separator if starting a new text segment after tool call
|
|
if (currentStreamingMessage.value && currentTextSegment.value === '') {
|
|
currentStreamingMessage.value += '\n\n';
|
|
}
|
|
// Add newline between consecutive message events if needed
|
|
else if (currentStreamingMessage.value &&
|
|
!currentStreamingMessage.value.endsWith('\n') &&
|
|
!content.startsWith('\n')) {
|
|
currentStreamingMessage.value += '\n\n';
|
|
currentTextSegment.value += '\n\n';
|
|
}
|
|
|
|
currentStreamingMessage.value += content;
|
|
currentTextSegment.value += content;
|
|
|
|
let lastMessage = chatMessages.value[chatMessages.value.length - 1];
|
|
if (!lastMessage || lastMessage.role !== 'assistant') {
|
|
// Create new assistant message with pending tool calls + text
|
|
chatMessages.value.push({
|
|
role: 'assistant',
|
|
content: currentStreamingMessage.value,
|
|
contentBlocks: [...pendingToolCalls.value, { type: 'text', text: currentTextSegment.value }]
|
|
});
|
|
pendingToolCalls.value = []; // Clear pending
|
|
} else {
|
|
lastMessage.content = currentStreamingMessage.value;
|
|
|
|
if (!lastMessage.contentBlocks) {
|
|
lastMessage.contentBlocks = [];
|
|
}
|
|
const lastBlock = lastMessage.contentBlocks[lastMessage.contentBlocks.length - 1];
|
|
if (lastBlock && lastBlock.type === 'text') {
|
|
lastBlock.text = currentTextSegment.value;
|
|
} else {
|
|
lastMessage.contentBlocks.push({ type: 'text', text: currentTextSegment.value });
|
|
}
|
|
}
|
|
messageComplete = true;
|
|
await scrollToBottom();
|
|
}
|
|
} catch (e) {
|
|
console.debug('Error processing remaining buffer:', e);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we completed a message, save to history
|
|
if (messageComplete) {
|
|
await saveToHistory();
|
|
}
|
|
} catch (error) {
|
|
// Handle different types of errors appropriately
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
// Request was cancelled by user - this is expected behavior, not an error
|
|
return; // Exit gracefully without logging as error
|
|
} else {
|
|
// Genuine error occurred during stream processing
|
|
console.error('Error reading stream:', error);
|
|
}
|
|
}
|
|
};
|
|
|
|
const saveToHistory = async () => {
|
|
saveHistoryLoading.value = true;
|
|
if (chatMessages.value.length === 0) return;
|
|
|
|
try {
|
|
const db = await initDB();
|
|
const transaction = db.transaction(STORE_NAME, 'readwrite');
|
|
const DbIndexStore = transaction.objectStore(STORE_NAME);
|
|
|
|
// Generate a title from the first user message
|
|
const firstUserMessage = chatMessages.value.find(msg => msg.role === 'user');
|
|
const title = firstUserMessage ?
|
|
(firstUserMessage.content.length > 40 ?
|
|
firstUserMessage.content.substring(0, 40) + '...' :
|
|
firstUserMessage.content) :
|
|
'New Chat';
|
|
|
|
// Create a serializable version of the messages (including contentBlocks)
|
|
// Use JSON parse/stringify to strip Vue reactive proxies
|
|
const serializableMessages = chatMessages.value.map(msg => {
|
|
const serialized: any = {
|
|
role: msg.role,
|
|
content: msg.content
|
|
};
|
|
if (msg.contentBlocks && msg.contentBlocks.length > 0) {
|
|
// Deep clone to remove Vue reactivity
|
|
serialized.contentBlocks = JSON.parse(JSON.stringify(msg.contentBlocks));
|
|
}
|
|
return serialized;
|
|
});
|
|
|
|
const chatData = {
|
|
timestamp: new Date().toISOString(),
|
|
title,
|
|
messages: serializableMessages,
|
|
provider: selectedProvider.value
|
|
};
|
|
|
|
// Always use put with the current chat ID to update existing chat
|
|
// instead of creating a new one
|
|
let chatId = currentChatId.value || Date.now();
|
|
const request = DbIndexStore.put({
|
|
...chatData,
|
|
id: chatId // Use timestamp as ID if no current ID
|
|
});
|
|
|
|
|
|
|
|
|
|
request.onsuccess = (event: Event) => {
|
|
if (!currentChatId.value) {
|
|
currentChatId.value = (event.target as IDBRequest).result as number;
|
|
}
|
|
};
|
|
} catch (error) {
|
|
console.error('Error saving chat history:', error);
|
|
}
|
|
finally {
|
|
saveHistoryLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const MAX_HISTORY_ITEMS = 100;
|
|
|
|
const loadHistory = async () => {
|
|
try {
|
|
const db = await initDB();
|
|
const transaction = db.transaction(STORE_NAME, 'readonly');
|
|
const store = transaction.objectStore(STORE_NAME);
|
|
const request = store.index('timestamp').openCursor(null, 'prev');
|
|
//one the promise is resolved we get the history here
|
|
//this history length might be more than 100 so after resolving / finishing the indexDB call
|
|
// we do the filtering
|
|
const history: any[] = [];
|
|
|
|
const loadResult = await new Promise((resolve, reject) => {
|
|
request.onsuccess = (event: Event) => {
|
|
const cursor = (event.target as IDBRequest).result as IDBCursorWithValue;
|
|
if (cursor) {
|
|
history.push(cursor.value);
|
|
cursor.continue();
|
|
} else {
|
|
resolve(history);
|
|
}
|
|
};
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
|
|
// If we have more than MAX_HISTORY_ITEMS, delete the oldest ones
|
|
//this will allows the user to see only top 100 chat histories and also delete the non required ones
|
|
//as we are not giving option to delete history manually we are doing this
|
|
//some times it takes time to delete the history from the indexDB so we only delete the history if user access the history menu
|
|
if (history.length > MAX_HISTORY_ITEMS) {
|
|
const itemsToDelete = history.slice(MAX_HISTORY_ITEMS);
|
|
const deleteTransaction = db.transaction(STORE_NAME, 'readwrite');
|
|
const deleteStore = deleteTransaction.objectStore(STORE_NAME);
|
|
|
|
for (const item of itemsToDelete) {
|
|
deleteStore.delete(item.id);
|
|
}
|
|
|
|
// Wait for deletion transaction to complete
|
|
await new Promise((resolve, reject) => {
|
|
deleteTransaction.oncomplete = () => resolve(true);
|
|
deleteTransaction.onerror = () => reject(deleteTransaction.error);
|
|
});
|
|
}
|
|
|
|
//here we do assign the history to the actual chat history
|
|
// Keep only the latest MAX_HISTORY_ITEMS
|
|
chatHistory.value = history.slice(0, MAX_HISTORY_ITEMS);
|
|
return chatHistory.value;
|
|
|
|
} catch (error) {
|
|
console.error('Error loading chat history:', error);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const addNewChat = () => {
|
|
chatMessages.value = [];
|
|
currentChatId.value = null;
|
|
selectedProvider.value = 'openai';
|
|
showHistory.value = false;
|
|
currentChatTimestamp.value = null;
|
|
shouldAutoScroll.value = true; // Reset auto-scroll for new chat
|
|
store.dispatch('setCurrentChatTimestamp', null);
|
|
store.dispatch('setChatUpdated', true);
|
|
};
|
|
|
|
const openHistory = async () => {
|
|
showHistory.value = true;
|
|
await loadHistory();
|
|
};
|
|
|
|
const loadChat = async (chatId: number) => {
|
|
try {
|
|
|
|
const db = await initDB();
|
|
const transaction = db.transaction(STORE_NAME, 'readonly');
|
|
const indexDbStore = transaction.objectStore(STORE_NAME);
|
|
if(chatId == null) {
|
|
addNewChat();
|
|
return;
|
|
}
|
|
const request = indexDbStore.get(chatId);
|
|
|
|
request.onsuccess = async () => {
|
|
const chat = request.result;
|
|
if (chat) {
|
|
// Ensure messages are properly formatted (including contentBlocks)
|
|
const formattedMessages = chat.messages.map((msg: any) => ({
|
|
role: msg.role,
|
|
content: msg.content,
|
|
...(msg.contentBlocks ? { contentBlocks: msg.contentBlocks } : {})
|
|
}));
|
|
|
|
// Check if the last message is a user message without an assistant response
|
|
const lastMessage = formattedMessages[formattedMessages.length - 1];
|
|
|
|
chatMessages.value = formattedMessages;
|
|
selectedProvider.value = chat.provider || 'openai';
|
|
currentChatId.value = chatId;
|
|
showHistory.value = false;
|
|
shouldAutoScroll.value = true; // Reset auto-scroll when loading chat
|
|
|
|
if(chatId !== store.state.currentChatTimestamp) {
|
|
store.dispatch('setCurrentChatTimestamp', chatId);
|
|
store.dispatch('setChatUpdated', true);
|
|
}
|
|
|
|
|
|
// Scroll to bottom after loading chat
|
|
await nextTick(() => {
|
|
scrollToBottom();
|
|
});
|
|
}
|
|
};
|
|
} catch (error) {
|
|
console.error('Error loading chat:', error);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Sends a message to the AI chat service with streaming response handling
|
|
* Creates a new AbortController for each request to enable cancellation
|
|
* Manages the complete request lifecycle from user input to streaming response
|
|
*/
|
|
const sendMessage = async () => {
|
|
if (!inputMessage.value.trim() || isLoading.value) return;
|
|
|
|
const userMessage = inputMessage.value;
|
|
|
|
// Add to query history before clearing input
|
|
addToHistory(userMessage);
|
|
|
|
chatMessages.value.push({
|
|
role: 'user',
|
|
content: userMessage
|
|
});
|
|
inputMessage.value = '';
|
|
shouldAutoScroll.value = true; // Reset auto-scroll for new message
|
|
await scrollToBottom(); // Scroll after user message
|
|
await saveToHistory(); // Save after user message
|
|
|
|
isLoading.value = true;
|
|
currentStreamingMessage.value = '';
|
|
currentTextSegment.value = '';
|
|
startAnalyzingRotation(); // Start rotating analyzing messages
|
|
|
|
// Create new AbortController for this request - enables cancellation via Stop button
|
|
currentAbortController.value = new AbortController();
|
|
|
|
try {
|
|
// Don't add empty assistant message here - wait for actual content
|
|
await scrollToLoadingIndicator(); // Scroll directly to loading indicator
|
|
|
|
let response: any;
|
|
try {
|
|
// Pass abort signal to enable request cancellation
|
|
response = await fetchAiChat(
|
|
chatMessages.value,
|
|
"",
|
|
store.state.selectedOrganization.identifier,
|
|
currentAbortController.value.signal
|
|
);
|
|
} catch (error) {
|
|
console.error('Error fetching AI chat:', error);
|
|
return;
|
|
}
|
|
|
|
// Check if request was cancelled before processing response
|
|
if (response && response.cancelled) {
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw response;
|
|
}
|
|
|
|
if (!response.body) {
|
|
throw new Error('No response body');
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
await processStream(reader);
|
|
|
|
store.dispatch('setCurrentChatTimestamp', currentChatId.value);
|
|
store.dispatch('setChatUpdated', true);
|
|
|
|
} catch (error: any) {
|
|
// Remove the empty assistant message that was added before the error
|
|
//this will impact in the case of error showing empty message above the error message in the chat
|
|
if (chatMessages.value.length > 0 && chatMessages.value[chatMessages.value.length - 1].role === 'assistant' && !chatMessages.value[chatMessages.value.length - 1].content) {
|
|
chatMessages.value.pop();
|
|
}
|
|
let errorMessage = 'Error: Unable to get response from the server. Please try again later.';
|
|
//we need to handle the 403 error seperately and show the error message to the user
|
|
if (error.status === 403) {
|
|
chatMessages.value.push({
|
|
role: 'assistant',
|
|
content: 'Unauthorized Access: You are not authorized to perform this operation, please contact your administrator.'
|
|
});
|
|
} else {
|
|
// Always create a new assistant message with error since we don't pre-create empty ones
|
|
chatMessages.value.push({
|
|
role: 'assistant',
|
|
content: errorMessage
|
|
});
|
|
}
|
|
await saveToHistory(); // Save after error
|
|
}
|
|
|
|
isLoading.value = false;
|
|
activeToolCall.value = null;
|
|
stopAnalyzingRotation();
|
|
|
|
// Clean up AbortController after request completion (success or error)
|
|
currentAbortController.value = null;
|
|
|
|
await scrollToBottom();
|
|
};
|
|
|
|
const selectCapability = (capability: string) => {
|
|
// Remove the number prefix and set as input
|
|
inputMessage.value = capability.replace(/^\d+\.\s/, '');
|
|
};
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault(); // Prevent the default enter behavior
|
|
sendMessage();
|
|
} else if (e.key === 'ArrowUp' && isOnFirstLine(e.target as HTMLTextAreaElement)) {
|
|
e.preventDefault();
|
|
navigateHistory('up');
|
|
} else if (e.key === 'ArrowDown' && historyIndex.value > -1) {
|
|
e.preventDefault();
|
|
navigateHistory('down');
|
|
}
|
|
};
|
|
|
|
// Check if cursor is on the first line of textarea
|
|
const isOnFirstLine = (textarea: HTMLTextAreaElement) => {
|
|
if (!textarea) return false;
|
|
|
|
const cursorPosition = textarea.selectionStart;
|
|
const textBeforeCursor = textarea.value.substring(0, cursorPosition);
|
|
|
|
// Check if there are any newlines before cursor position
|
|
return !textBeforeCursor.includes('\n');
|
|
};
|
|
|
|
// Navigate through query history
|
|
const navigateHistory = (direction: 'up' | 'down') => {
|
|
if (queryHistory.value.length === 0) return;
|
|
|
|
if (direction === 'up') {
|
|
if (historyIndex.value < queryHistory.value.length - 1) {
|
|
historyIndex.value++;
|
|
inputMessage.value = queryHistory.value[historyIndex.value];
|
|
}
|
|
} else if (direction === 'down') {
|
|
if (historyIndex.value > 0) {
|
|
historyIndex.value--;
|
|
inputMessage.value = queryHistory.value[historyIndex.value];
|
|
} else if (historyIndex.value === 0) {
|
|
historyIndex.value = -1;
|
|
inputMessage.value = '';
|
|
}
|
|
}
|
|
};
|
|
|
|
const focusInput = () => {
|
|
if (chatInput.value) {
|
|
// For Quasar components, we need to call the focus method on the component
|
|
chatInput.value.focus();
|
|
// Alternative: directly focus the native textarea element
|
|
const textarea = chatInput.value.$el?.querySelector('textarea');
|
|
if (textarea) {
|
|
textarea.focus();
|
|
}
|
|
}
|
|
};
|
|
|
|
// Load query history from localStorage
|
|
const loadQueryHistory = () => {
|
|
try {
|
|
const stored = localStorage.getItem(HISTORY_KEY);
|
|
if (stored) {
|
|
queryHistory.value = JSON.parse(stored);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading query history:', error);
|
|
queryHistory.value = [];
|
|
}
|
|
};
|
|
|
|
// Save query history to localStorage
|
|
const saveQueryHistory = () => {
|
|
try {
|
|
localStorage.setItem(HISTORY_KEY, JSON.stringify(queryHistory.value));
|
|
} catch (error) {
|
|
console.error('Error saving query history:', error);
|
|
}
|
|
};
|
|
|
|
// Add query to history
|
|
const addToHistory = (query: string) => {
|
|
const trimmedQuery = query.trim();
|
|
if (!trimmedQuery) return;
|
|
|
|
// Remove if already exists to avoid duplicates
|
|
const existingIndex = queryHistory.value.indexOf(trimmedQuery);
|
|
if (existingIndex > -1) {
|
|
queryHistory.value.splice(existingIndex, 1);
|
|
}
|
|
|
|
// Add to beginning of array
|
|
queryHistory.value.unshift(trimmedQuery);
|
|
|
|
// Keep only last MAX_HISTORY_SIZE entries
|
|
if (queryHistory.value.length > MAX_HISTORY_SIZE) {
|
|
queryHistory.value = queryHistory.value.slice(0, MAX_HISTORY_SIZE);
|
|
}
|
|
|
|
saveQueryHistory();
|
|
historyIndex.value = -1; // Reset index
|
|
};
|
|
|
|
// Watch for isOpen changes to fetch initial message when opened
|
|
watch(() => props.isOpen, (newValue) => {
|
|
if (newValue) {
|
|
if (chatMessages.value.length === 0) {
|
|
fetchInitialMessage();
|
|
}
|
|
loadHistory(); // Load history when chat is opened
|
|
}
|
|
});
|
|
|
|
// Only fetch initial message if component starts as open
|
|
onMounted(() => {
|
|
if (props.isOpen) {
|
|
fetchInitialMessage();
|
|
loadHistory(); // Load history on mount if chat is open
|
|
loadChat(store.state.currentChatTimestamp);
|
|
}
|
|
|
|
// Load query history from localStorage
|
|
loadQueryHistory();
|
|
});
|
|
|
|
onUnmounted(()=>{
|
|
// Cancel any ongoing requests when component is unmounted to prevent memory leaks
|
|
if (currentAbortController.value) {
|
|
currentAbortController.value.abort();
|
|
currentAbortController.value = null;
|
|
}
|
|
|
|
//this step is added because we are using seperate instances of o2 ai chat component to make sync between them
|
|
//whenever a new chat is created or a new message is sent, the currentChatTimestamp is set to the chatId
|
|
//so we need to make sure that the currentChatTimestamp is set to the correct chatId
|
|
//and the chat gets updated when the component is unmounted so that the main layout component can load the correct chat
|
|
store.dispatch('setCurrentChatTimestamp', currentChatId.value);
|
|
store.dispatch('setChatUpdated', true); if ( store.state.currentChatTimestamp) {
|
|
loadChat(store.state.currentChatTimestamp);
|
|
}
|
|
if(!store.state.currentChatTimestamp) {
|
|
addNewChat();
|
|
}
|
|
})
|
|
//this watch is added to make sure that the chat gets updated
|
|
// when the component is unmounted so that the main layout component can load the correct chat
|
|
watch(chatUpdated, (newChatUpdated: boolean) => {
|
|
if (newChatUpdated && store.state.currentChatTimestamp) {
|
|
loadChat(store.state.currentChatTimestamp);
|
|
}
|
|
if(newChatUpdated && !store.state.currentChatTimestamp) {
|
|
addNewChat();
|
|
}
|
|
store.dispatch('setChatUpdated', false);
|
|
});
|
|
|
|
const copyToClipboard = async (text: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
$q.notify({
|
|
message: 'Code copied to clipboard',
|
|
color: 'positive',
|
|
position: 'top',
|
|
timeout: 1000
|
|
});
|
|
} catch (err) {
|
|
console.error('Failed to copy text: ', err);
|
|
$q.notify({
|
|
message: 'Failed to copy code',
|
|
color: 'negative',
|
|
position: 'top'
|
|
});
|
|
}
|
|
};
|
|
|
|
// Filter markdown headers - convert # and ## to smaller formatting
|
|
const filterMarkdownHeaders = (content: string): string => {
|
|
// Convert # and ## at start of lines to bold format for cleaner display
|
|
let filtered = content;
|
|
|
|
// Convert ## headers to bold with colon
|
|
filtered = filtered.replace(/^## (.+)$/gm, '**$1:**');
|
|
|
|
// Convert # headers to bold with colon
|
|
filtered = filtered.replace(/^# (.+)$/gm, '**$1:**');
|
|
|
|
return filtered;
|
|
};
|
|
|
|
// Process text block and return array of code/text blocks for rendering
|
|
const processTextBlock = (text: string) => {
|
|
// Filter headers before processing
|
|
const filteredContent = filterMarkdownHeaders(text);
|
|
const tokens = marked.lexer(filteredContent);
|
|
const blocks = [];
|
|
|
|
for (const token of tokens) {
|
|
if (token.type === 'code') {
|
|
let codeText = token.text.trim();
|
|
while (codeText.startsWith('--') || codeText.startsWith('//') || codeText.startsWith('#')) {
|
|
codeText = codeText.split('\n').slice(1).join('\n').trim();
|
|
}
|
|
|
|
const highlightedContent = token.lang && hljs.getLanguage(token.lang)
|
|
? DOMPurify.sanitize(hljs.highlight(codeText, { language: token.lang }).value)
|
|
: DOMPurify.sanitize(hljs.highlightAuto(codeText).value);
|
|
|
|
blocks.push({
|
|
type: 'code',
|
|
language: token.lang || '',
|
|
content: codeText,
|
|
highlightedContent
|
|
});
|
|
} else {
|
|
blocks.push({
|
|
type: 'text',
|
|
content: marked.parser([token])
|
|
});
|
|
}
|
|
}
|
|
|
|
return blocks;
|
|
};
|
|
|
|
const processMessageContent = (content: string) => {
|
|
// Filter headers before processing
|
|
const filteredContent = filterMarkdownHeaders(content);
|
|
const tokens = marked.lexer(filteredContent);
|
|
const blocks = [];
|
|
|
|
for (const token of tokens) {
|
|
if (token.type === 'code') {
|
|
// Remove comments at the beginning of code blocks
|
|
let codeText = token.text.trim();
|
|
while (codeText.startsWith('--') || codeText.startsWith('//') || codeText.startsWith('#')) {
|
|
codeText = codeText.split('\n').slice(1).join('\n').trim();
|
|
}
|
|
|
|
const highlightedContent = token.lang && hljs.getLanguage(token.lang)
|
|
? DOMPurify.sanitize(hljs.highlight(codeText, { language: token.lang }).value)
|
|
: DOMPurify.sanitize(hljs.highlightAuto(codeText).value);
|
|
|
|
blocks.push({
|
|
type: 'code',
|
|
language: token.lang || '',
|
|
content: codeText,
|
|
highlightedContent
|
|
});
|
|
} else {
|
|
blocks.push({
|
|
type: 'text',
|
|
content: marked.parser([token])
|
|
});
|
|
}
|
|
}
|
|
|
|
return blocks;
|
|
};
|
|
|
|
const processedMessages = computed(() => {
|
|
return chatMessages.value.map(message => ({
|
|
...message,
|
|
blocks: processMessageContent(message.content),
|
|
contentBlocks: message.contentBlocks || []
|
|
}));
|
|
});
|
|
|
|
// Check if there's an assistant message in progress (for loading indicator positioning)
|
|
const hasAssistantMessage = computed(() => {
|
|
const lastMessage = chatMessages.value[chatMessages.value.length - 1];
|
|
return lastMessage?.role === 'assistant';
|
|
});
|
|
|
|
const retryGeneration = async (message: any) => {
|
|
if (!message || message.role !== 'assistant') return;
|
|
|
|
// Find the index of this assistant message
|
|
const messageIndex = chatMessages.value.findIndex(m => m.content === message.content);
|
|
if (messageIndex === -1) return;
|
|
|
|
// Find the corresponding user message that came before this assistant message
|
|
let userMessageIndex = messageIndex - 1;
|
|
while (userMessageIndex >= 0) {
|
|
if (chatMessages.value[userMessageIndex].role === 'user') {
|
|
// Set the user message and trigger send without removing previous messages
|
|
inputMessage.value = chatMessages.value[userMessageIndex].content;
|
|
await sendMessage();
|
|
break;
|
|
}
|
|
userMessageIndex--;
|
|
}
|
|
};
|
|
|
|
const getLanguageDisplay = (lang: string) => {
|
|
const languageMap: { [key: string]: string } = {
|
|
'js': 'JavaScript',
|
|
'javascript': 'JavaScript',
|
|
'ts': 'TypeScript',
|
|
'typescript': 'TypeScript',
|
|
'python': 'Python',
|
|
'py': 'Python',
|
|
'sql': 'SQL',
|
|
'vrl': 'VRL',
|
|
'json': 'JSON',
|
|
'html': 'HTML',
|
|
'css': 'CSS',
|
|
'scss': 'SCSS',
|
|
'bash': 'Bash',
|
|
'shell': 'Shell',
|
|
'yaml': 'YAML',
|
|
'yml': 'YAML',
|
|
'markdown': 'Markdown',
|
|
'md': 'Markdown'
|
|
};
|
|
|
|
const normalizedLang = lang.toLowerCase();
|
|
return languageMap[normalizedLang] || lang.toUpperCase();
|
|
};
|
|
|
|
const processHtmlBlock = (content: string) => {
|
|
// Sanitize HTML to prevent XSS attacks
|
|
const sanitized = DOMPurify.sanitize(content);
|
|
// Replace pre tags with span and add our custom class
|
|
return sanitized.replace(/<pre([^>]*)>/g, '<span class="generated-code-block"$1>')
|
|
.replace(/<\/pre>/g, '</span>');
|
|
};
|
|
|
|
const formatTime = (timestamp: string) => {
|
|
const date = new Date(timestamp);
|
|
return date.toLocaleString();
|
|
};
|
|
|
|
// Tool call context formatting helpers
|
|
const truncateQuery = (query: string) => {
|
|
if (!query) return '';
|
|
const maxLength = 100;
|
|
if (query.length <= maxLength) return query;
|
|
return query.substring(0, maxLength) + '...';
|
|
};
|
|
|
|
const formatContextKey = (key: string) => {
|
|
// Convert snake_case to Title Case
|
|
return key.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
|
|
};
|
|
|
|
const formatContextValue = (value: any) => {
|
|
if (typeof value === 'string') {
|
|
// Truncate long strings
|
|
if (value.length > 30) return value.substring(0, 30) + '...';
|
|
return value;
|
|
}
|
|
return String(value);
|
|
};
|
|
|
|
// Tool call expansion helpers
|
|
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}`);
|
|
};
|
|
|
|
// Extract display fields from tool call context (handles different tool schemas)
|
|
const getToolCallDisplayData = (context: any) => {
|
|
if (!context) return null;
|
|
|
|
const data: Record<string, any> = {};
|
|
|
|
// Handle nested request_body.query structure (SearchSQL, ExtractPatterns)
|
|
if (context.request_body?.query) {
|
|
const q = context.request_body.query;
|
|
if (q.sql) data.query = q.sql;
|
|
if (q.start_time) data.start_time = q.start_time;
|
|
if (q.end_time) data.end_time = q.end_time;
|
|
if (q.from !== undefined) data.from = q.from;
|
|
if (q.size !== undefined) data.size = q.size;
|
|
if (q.query_type) data.query_type = q.query_type;
|
|
if (q.vrl) data.vrl = q.vrl;
|
|
}
|
|
|
|
// Handle flat structure (StreamSchema, etc.)
|
|
if (context.stream_name) data.stream = context.stream_name;
|
|
if (context.type) data.type = context.type;
|
|
|
|
return Object.keys(data).length > 0 ? data : null;
|
|
};
|
|
|
|
const hasToolCallDetails = (block: ContentBlock) => {
|
|
return getToolCallDisplayData(block.context) !== null;
|
|
};
|
|
|
|
const formatToolCallMessage = (block: ContentBlock) => {
|
|
// Interpolate context into message for certain tools
|
|
// Returns object with text and optional highlight for bold rendering
|
|
if (block.tool === 'StreamSchema' && block.context?.stream_name) {
|
|
return { text: 'Fetched ', highlight: block.context.stream_name, suffix: ' stream schema' };
|
|
}
|
|
if (block.tool === 'GetIncident' && block.context?.incident_id) {
|
|
return { text: 'Retrieved incident ', highlight: block.context.incident_id, suffix: '' };
|
|
}
|
|
if (block.tool === 'GetAlert' && block.context?.alert_id) {
|
|
return { text: 'Fetched alert ', highlight: block.context.alert_id, suffix: '' };
|
|
}
|
|
if (block.tool === 'GetDashboard' && block.context?.dashboard_id) {
|
|
return { text: 'Fetched dashboard ', highlight: block.context.dashboard_id, suffix: '' };
|
|
}
|
|
return { text: block.message, highlight: null, suffix: '' };
|
|
};
|
|
|
|
const formatTimestamp = (timestamp: number) => {
|
|
if (!timestamp || timestamp === 0) return 'Not specified';
|
|
// Timestamp is in microseconds, convert to milliseconds
|
|
const ms = timestamp > 1e15 ? timestamp / 1000 : timestamp;
|
|
const date = new Date(ms);
|
|
return date.toLocaleString();
|
|
};
|
|
|
|
const likeCodeBlock = (message: any) => {
|
|
// console.log('likeCodeBlock', message);
|
|
};
|
|
|
|
const dislikeCodeBlock = (message: any) => {
|
|
// console.log('dislikeCodeBlock', message);
|
|
};
|
|
const o2AiTitleLogo = computed(() => {
|
|
return store.state.theme == 'dark' ? getImageURL('images/common/o2_ai_logo_dark.svg') : getImageURL('images/common/o2_ai_logo.svg')
|
|
});
|
|
const getGenerateAiIcon = computed(()=> {
|
|
return getImageURL('images/common/ai_icon_dark.svg')
|
|
})
|
|
|
|
const filteredChatHistory = computed(() => {
|
|
if (!historySearchTerm.value) {
|
|
return chatHistory.value;
|
|
}
|
|
const searchTerm = historySearchTerm.value.toLowerCase();
|
|
return chatHistory.value.filter(chat =>
|
|
chat.title.toLowerCase().includes(searchTerm)
|
|
);
|
|
});
|
|
|
|
return {
|
|
inputMessage,
|
|
chatMessages,
|
|
isLoading,
|
|
currentAnalyzingMessage,
|
|
sendMessage,
|
|
handleKeyDown,
|
|
focusInput,
|
|
messagesContainer,
|
|
chatInput,
|
|
formatMessage,
|
|
capabilities,
|
|
selectCapability,
|
|
selectedProvider,
|
|
showHistory,
|
|
chatHistory,
|
|
addNewChat,
|
|
openHistory,
|
|
loadChat,
|
|
processedMessages,
|
|
pendingToolCalls,
|
|
processTextBlock,
|
|
copyToClipboard,
|
|
retryGeneration,
|
|
getLanguageDisplay,
|
|
processHtmlBlock,
|
|
formatTime,
|
|
loadHistory,
|
|
store,
|
|
outlinedThumbUpOffAlt,
|
|
outlinedThumbDownOffAlt,
|
|
likeCodeBlock,
|
|
dislikeCodeBlock,
|
|
currentChatTimestamp,
|
|
o2AiTitleLogo,
|
|
getGenerateAiIcon,
|
|
saveHistoryLoading,
|
|
historySearchTerm,
|
|
filteredChatHistory,
|
|
shouldAutoScroll,
|
|
checkIfShouldAutoScroll,
|
|
getScrollThreshold,
|
|
scrollToLoadingIndicator,
|
|
scrollToBottomSmooth,
|
|
showScrollToBottom,
|
|
cancelCurrentRequest,
|
|
currentAbortController,
|
|
activeToolCall,
|
|
truncateQuery,
|
|
formatContextKey,
|
|
expandedToolCalls,
|
|
toggleToolCallExpanded,
|
|
isToolCallExpanded,
|
|
hasToolCallDetails,
|
|
getToolCallDisplayData,
|
|
formatToolCallMessage,
|
|
formatTimestamp,
|
|
formatContextValue,
|
|
}
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.chat-container {
|
|
width: 100%;
|
|
height: 100vh;
|
|
color: var(--q-primary-text);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
|
|
// Light mode gradient - more sophisticated
|
|
&.light-mode {
|
|
background: rgba(255,255,255,0.7);
|
|
background-size: 400% 400%;
|
|
animation: subtleShift 20s ease-in-out infinite;
|
|
}
|
|
|
|
// Dark mode gradient - more sophisticated
|
|
&.dark-mode {
|
|
background: rgba(0,0,0,0.7);
|
|
background-size: 400% 400%;
|
|
animation: subtleShift 25s ease-in-out infinite;
|
|
}
|
|
|
|
.chat-content-wrapper {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
|
|
// Light mode gradient - more sophisticated
|
|
&.light-mode {
|
|
background: transparent;
|
|
background-size: 400% 400%;
|
|
animation: subtleShift 20s ease-in-out infinite;
|
|
}
|
|
|
|
// Dark mode gradient - more sophisticated
|
|
&.dark-mode {
|
|
background: transparent;
|
|
background-size: 400% 400%;
|
|
animation: subtleShift 25s ease-in-out infinite;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
.chat-header {
|
|
padding: 0px 12px 4px 12px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: end;
|
|
border-bottom: 1px solid var(--q-separator-color);
|
|
flex-shrink: 0;
|
|
background: var(--q-page-background);
|
|
z-index: 2;
|
|
|
|
.chat-title {
|
|
font-weight: bold;
|
|
}
|
|
}
|
|
|
|
.chat-content {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: transparent;
|
|
position: relative;
|
|
}
|
|
|
|
.messages-container {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 8px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
background: transparent;
|
|
max-width: 900px;
|
|
margin: 0 auto;
|
|
width: 100%;
|
|
}
|
|
|
|
.welcome-section {
|
|
padding: 24px;
|
|
background: linear-gradient(to right, rgba(var(--q-primary-rgb), 0.05), rgba(var(--q-primary-rgb), 0.1));
|
|
border-radius: 8px;
|
|
margin-bottom: 24px;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.chat-input-wrapper {
|
|
padding: 4px 8px 8px 8px;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
justify-content: center;
|
|
transition: all 0.2s ease;
|
|
|
|
:deep(.q-field) {
|
|
max-width: 900px;
|
|
width: 100%;
|
|
}
|
|
|
|
}
|
|
.light-mode .chat-input-wrapper{
|
|
background:#ffffff;
|
|
border: 1px solid #e4e7ec;
|
|
border-radius: 12px;
|
|
&:focus-within {
|
|
border: 1px solid transparent;
|
|
box-shadow: 0 0 0 2px #667eea
|
|
}
|
|
}
|
|
.dark-mode .chat-input-wrapper{
|
|
background:#191919;
|
|
border: 1px solid #323232;
|
|
border-radius: 12px;
|
|
&:focus-within {
|
|
border: 1px solid transparent;
|
|
box-shadow: 0 0 0 2px #5a6ec3
|
|
}
|
|
}
|
|
|
|
|
|
.light-mode .message {
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
|
}
|
|
.dark-mode .message {
|
|
box-shadow: 0 1px 2px rgba(255, 255, 255, 0.1);
|
|
}
|
|
.message {
|
|
width: 100%;
|
|
padding: 12px;
|
|
border-radius: 8px;
|
|
|
|
|
|
|
|
.message-content {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 6px;
|
|
width: 100%;
|
|
}
|
|
|
|
.message-blocks {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0;
|
|
min-width: 0;
|
|
max-width: 100%;
|
|
overflow-x: auto;
|
|
word-wrap: break-word;
|
|
overflow-wrap: break-word;
|
|
}
|
|
|
|
.text-block {
|
|
width: 100%;
|
|
overflow-wrap: break-word;
|
|
max-width: 100%;
|
|
&:not(:last-child) {
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
:deep(pre), :deep(.generated-code-block) {
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
overflow-wrap: break-word;
|
|
margin: 0;
|
|
padding: 0;
|
|
line-height: 1.4;
|
|
display: block;
|
|
max-width: 100%;
|
|
overflow-x: auto;
|
|
|
|
code {
|
|
padding: 8px;
|
|
margin: 0;
|
|
display: block;
|
|
max-width: 100%;
|
|
}
|
|
}
|
|
|
|
// Heading styles - appropriately sized for chat interface
|
|
// Only applies within message-blocks, not globally
|
|
:deep(h1) {
|
|
font-size: 1.5rem !important;
|
|
font-weight: 600 !important;
|
|
margin: 16px 0 8px 0 !important;
|
|
line-height: 1.3 !important;
|
|
}
|
|
:deep(h2) {
|
|
font-size: 1.25rem !important;
|
|
font-weight: 600 !important;
|
|
margin: 14px 0 7px 0 !important;
|
|
line-height: 1.3 !important;
|
|
}
|
|
:deep(h3) {
|
|
font-size: 1.125rem !important;
|
|
font-weight: 600 !important;
|
|
margin: 12px 0 6px 0 !important;
|
|
line-height: 1.3 !important;
|
|
}
|
|
:deep(h4) {
|
|
font-size: 1rem !important;
|
|
font-weight: 600 !important;
|
|
margin: 10px 0 5px 0 !important;
|
|
line-height: 1.3 !important;
|
|
}
|
|
:deep(h5) {
|
|
font-size: 0.875rem !important;
|
|
font-weight: 600 !important;
|
|
margin: 8px 0 4px 0 !important;
|
|
line-height: 1.3 !important;
|
|
}
|
|
:deep(h6) {
|
|
font-size: 0.75rem !important;
|
|
font-weight: 600 !important;
|
|
margin: 8px 0 4px 0 !important;
|
|
line-height: 1.3 !important;
|
|
}
|
|
|
|
// Table styling to prevent horizontal overflow
|
|
:deep(table) {
|
|
max-width: 100%;
|
|
width: 100%;
|
|
table-layout: fixed;
|
|
border-collapse: collapse;
|
|
overflow-x: auto;
|
|
display: block;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
:deep(table th), :deep(table td) {
|
|
padding: 8px 12px;
|
|
border: 1px solid #e2e8f0;
|
|
word-wrap: break-word;
|
|
overflow-wrap: break-word;
|
|
text-overflow: ellipsis;
|
|
overflow: hidden;
|
|
}
|
|
|
|
// Force break long words and URLs
|
|
:deep(p), :deep(div), :deep(span) {
|
|
word-wrap: break-word;
|
|
overflow-wrap: break-word;
|
|
word-break: break-word;
|
|
max-width: 100%;
|
|
}
|
|
}
|
|
|
|
.code-block {
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
margin: 0;
|
|
}
|
|
// .light-mode .code-block{
|
|
// border: 1px solid #eee;
|
|
// }
|
|
// .dark-mode .code-block-header{
|
|
// border: 1px solid rgba(225, 225, 225, 0.14);
|
|
// }
|
|
|
|
.code-block-header {
|
|
padding: 4px 8px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.code-type-label {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
background: rgba(var(--q-primary-rgb), 0.1);
|
|
}
|
|
.light-mode .code-type-label{
|
|
color: var(--q-primary);
|
|
}
|
|
.dark-mode .code-type-label{
|
|
color: #e2e2e2;
|
|
}
|
|
|
|
.generated-code-block {
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
overflow-wrap: break-word;
|
|
margin: 0;
|
|
padding: 0;
|
|
line-height: 1.4;
|
|
code {
|
|
padding: 8px;
|
|
margin: 0;
|
|
display: block;
|
|
|
|
}
|
|
}
|
|
|
|
.dark-mode .generated-code-block{
|
|
code {
|
|
background-color: #181a1b;
|
|
border: 0.5px solid #E1E1E124;
|
|
border-top: none;
|
|
}
|
|
}
|
|
.light-mode .generated-code-block{
|
|
code {
|
|
background-color: #ffffff;
|
|
border: 0.5px solid #00000024 ;
|
|
border-top: none;
|
|
color: black;
|
|
}
|
|
}
|
|
|
|
.code-block-footer {
|
|
padding: 4px 8px;
|
|
display: flex;
|
|
}
|
|
}
|
|
.light-mode .message{
|
|
&.user {
|
|
background: linear-gradient(135deg, #f8f9ff 0%, #e8edff 100%);
|
|
border: 1px solid #e0e6ff;
|
|
border-radius: 12px;
|
|
color: #2c3e50;
|
|
margin-left: 40px;
|
|
width: calc(100% - 40px);
|
|
}
|
|
|
|
&.assistant {
|
|
background: #ffffff;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 12px;
|
|
color: var(--q-primary-text);
|
|
margin-left: 0;
|
|
width: 100%;
|
|
}
|
|
}
|
|
.dark-mode .message{
|
|
&.user {
|
|
background: linear-gradient(135deg, #2a2d47 0%, #1e213a 100%);
|
|
border: 1px solid #3a3d5c;
|
|
border-radius: 12px;
|
|
color: #e2e8f0;
|
|
margin-left: 40px;
|
|
width: calc(100% - 40px);
|
|
}
|
|
|
|
&.assistant {
|
|
background: #1a1a1a;
|
|
border: 1px solid #333333;
|
|
border-radius: 12px;
|
|
color: #e2e2e2;
|
|
margin-left: 0;
|
|
width: 100%;
|
|
}
|
|
}
|
|
|
|
ul, ol {
|
|
pre {
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
overflow-wrap: break-word;
|
|
margin: 0;
|
|
padding: 0;
|
|
code {
|
|
background-color: white;
|
|
color: black;
|
|
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Avatar styling for user messages
|
|
.light-user-avatar {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
|
|
color: white;
|
|
}
|
|
|
|
.dark-user-avatar {
|
|
background: linear-gradient(135deg, #4c63d2 0%, #5a67d8 100%) !important;
|
|
color: white;
|
|
}
|
|
|
|
// Send button gradient styling
|
|
.send-button {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
|
|
transition: all 0.3s ease !important;
|
|
box-shadow: 0 4px 15px 0 rgba(102, 126, 234, 0.3) !important;
|
|
|
|
&:hover:not(.disabled):not([disabled]):not(:disabled) {
|
|
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%) !important;
|
|
box-shadow: 0 6px 20px 0 rgba(102, 126, 234, 0.4) !important;
|
|
transform: translateY(-1px) !important;
|
|
}
|
|
|
|
&:active:not(.disabled):not([disabled]):not(:disabled) {
|
|
transform: translateY(0) !important;
|
|
box-shadow: 0 2px 10px 0 rgba(102, 126, 234, 0.3) !important;
|
|
}
|
|
|
|
}
|
|
|
|
// Stop button gradient styling
|
|
.stop-button {
|
|
background: linear-gradient(135deg, #f56565 0%, #e53e3e 100%) !important;
|
|
transition: all 0.3s ease !important;
|
|
box-shadow: 0 4px 15px 0 rgba(245, 101, 101, 0.3) !important;
|
|
|
|
&:hover {
|
|
background: linear-gradient(135deg, #e53e3e 0%, #c53030 100%) !important;
|
|
box-shadow: 0 6px 20px 0 rgba(245, 101, 101, 0.4) !important;
|
|
transform: translateY(-1px) !important;
|
|
}
|
|
|
|
&:active {
|
|
transform: translateY(0) !important;
|
|
box-shadow: 0 2px 10px 0 rgba(245, 101, 101, 0.3) !important;
|
|
}
|
|
}
|
|
|
|
.dark-mode .code-block-header{
|
|
background-color: #3b3b3b;
|
|
border: 1px 1px 0px 1px solid #e1e1e1;
|
|
|
|
}
|
|
.light-mode .code-block-header{
|
|
background-color: #ecf0f5;
|
|
|
|
}
|
|
|
|
.model-selector{
|
|
text-overflow: ellipsis;
|
|
}
|
|
.dark-mode-bottom-bar{
|
|
.model-selector{
|
|
background-color: #262626;
|
|
border: 1px solid #3b3b3b;
|
|
padding: 0px 4px;
|
|
border-radius: 4px;
|
|
padding-left: 4px;
|
|
}
|
|
|
|
}
|
|
|
|
.light-mode-bottom-bar{
|
|
.model-selector{
|
|
background-color: #ffffff;
|
|
border: 1px solid #bdbbbb;
|
|
padding: 0px 4px;
|
|
border-radius: 4px;
|
|
padding-left: 4px;
|
|
}
|
|
}
|
|
.o2-ai-beta-text {
|
|
position: relative;
|
|
color: var(--q-primary);
|
|
font-size: 8px;
|
|
padding: 0px 4px;
|
|
border-radius: 10px;
|
|
text-align: center;
|
|
border: 1px solid var(--q-primary);
|
|
text-transform: uppercase;
|
|
font-weight: 600;
|
|
letter-spacing: 0.5px;
|
|
width: 34px;
|
|
}
|
|
|
|
.history-menu-container {
|
|
position: relative;
|
|
max-height: 400px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.search-history-bar-sticky {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 2;
|
|
background: var(--q-page-background);
|
|
padding: 8px;
|
|
border-bottom: 1px solid var(--q-separator-color);
|
|
}
|
|
|
|
.history-list-container {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
// Scroll to bottom button styling
|
|
.scroll-to-bottom-container {
|
|
position: absolute;
|
|
bottom: 10px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
z-index: 1000;
|
|
transition: all 0.3s ease;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.scroll-to-bottom-btn {
|
|
transition: all 0.3s ease;
|
|
animation: fadeInUp 0.3s ease;
|
|
pointer-events: auto;
|
|
backdrop-filter: blur(8px);
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
|
|
|
body.body--light & {
|
|
border: 2px solid #2563eb !important;
|
|
color: #2563eb !important;
|
|
background: rgba(255, 255, 255, 0.95) !important;
|
|
}
|
|
|
|
body.body--dark & {
|
|
border: 2px solid #667eea !important;
|
|
color: #667eea !important;
|
|
background: rgba(30, 30, 30, 0.9) !important;
|
|
}
|
|
|
|
&:hover {
|
|
transform: scale(1.1);
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
|
|
body.body--light & {
|
|
border: 2px solid #1d4ed8 !important;
|
|
color: #1d4ed8 !important;
|
|
background: rgba(255, 255, 255, 1) !important;
|
|
}
|
|
|
|
body.body--dark & {
|
|
border: 2px solid #5a6fd8 !important;
|
|
color: #5a6fd8 !important;
|
|
background: rgba(40, 40, 40, 0.95) !important;
|
|
}
|
|
}
|
|
|
|
&:active {
|
|
transform: scale(1);
|
|
}
|
|
|
|
.q-icon {
|
|
font-size: 18px;
|
|
animation: bounce 2s infinite;
|
|
font-weight: bold;
|
|
}
|
|
}
|
|
|
|
// Bounce animation for the arrow icon
|
|
@keyframes bounce {
|
|
0%, 20%, 50%, 80%, 100% {
|
|
transform: translateY(0);
|
|
}
|
|
40% {
|
|
transform: translateY(-3px);
|
|
}
|
|
60% {
|
|
transform: translateY(-2px);
|
|
}
|
|
}
|
|
|
|
// Fade in up animation for button appearance
|
|
@keyframes fadeInUp {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(10px) scale(0.9);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0) scale(1);
|
|
}
|
|
}
|
|
|
|
// Subtle gradient animation
|
|
@keyframes subtleShift {
|
|
0% {
|
|
background-position: 0% 50%;
|
|
}
|
|
25% {
|
|
background-position: 100% 50%;
|
|
}
|
|
50% {
|
|
background-position: 100% 100%;
|
|
}
|
|
75% {
|
|
background-position: 0% 100%;
|
|
}
|
|
100% {
|
|
background-position: 0% 50%;
|
|
}
|
|
}
|
|
|
|
// Tool call indicator styling
|
|
.tool-call-indicator {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 12px 16px;
|
|
border-radius: 12px;
|
|
margin: 8px 0;
|
|
animation: fadeIn 0.3s ease;
|
|
|
|
&.light-mode {
|
|
background: linear-gradient(135deg, #f0f4ff 0%, #e8f0fe 100%);
|
|
border: 1px solid #d0d8e8;
|
|
}
|
|
|
|
&.dark-mode {
|
|
background: linear-gradient(135deg, #1e2235 0%, #252a3d 100%);
|
|
border: 1px solid #3a3f55;
|
|
}
|
|
|
|
.tool-call-content {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 12px;
|
|
width: 100%;
|
|
}
|
|
|
|
.tool-call-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.tool-call-status {
|
|
font-size: 12px;
|
|
font-style: italic;
|
|
opacity: 0.7;
|
|
margin-bottom: 2px;
|
|
|
|
.light-mode & {
|
|
color: #6b7280;
|
|
}
|
|
|
|
.dark-mode & {
|
|
color: #9ca3af;
|
|
}
|
|
}
|
|
|
|
.tool-call-message {
|
|
font-weight: 600;
|
|
font-size: 14px;
|
|
|
|
.light-mode & {
|
|
color: #4a5568;
|
|
}
|
|
|
|
.dark-mode & {
|
|
color: #e2e8f0;
|
|
}
|
|
}
|
|
|
|
.tool-call-context {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
align-items: center;
|
|
}
|
|
|
|
.context-item {
|
|
width: 100%;
|
|
}
|
|
|
|
.context-query {
|
|
display: block;
|
|
font-family: 'Fira Code', 'Consolas', monospace;
|
|
font-size: 12px;
|
|
padding: 8px 12px;
|
|
border-radius: 6px;
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
max-width: 100%;
|
|
overflow: hidden;
|
|
|
|
.light-mode & {
|
|
background: #ffffff;
|
|
color: #2d3748;
|
|
border: 1px solid #e2e8f0;
|
|
}
|
|
|
|
.dark-mode & {
|
|
background: #1a1a1a;
|
|
color: #a0aec0;
|
|
border: 1px solid #333;
|
|
}
|
|
}
|
|
|
|
.context-tag {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
font-size: 11px;
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
font-weight: 500;
|
|
|
|
.light-mode & {
|
|
background: rgba(102, 126, 234, 0.1);
|
|
color: #667eea;
|
|
}
|
|
|
|
.dark-mode & {
|
|
background: rgba(102, 126, 234, 0.2);
|
|
color: #a0aec0;
|
|
}
|
|
}
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(-10px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
// Inline loading indicator (inside message box)
|
|
.inline-loading {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 8px 0;
|
|
color: #6b7280;
|
|
font-size: 14px;
|
|
}
|
|
|
|
// Tool call item - inline in chat flow (interleaved with text)
|
|
.tool-call-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 8px 12px;
|
|
border-radius: 6px;
|
|
font-size: 13px;
|
|
margin-bottom: 8px;
|
|
|
|
&.has-details {
|
|
cursor: pointer;
|
|
|
|
&:hover {
|
|
&.light-mode {
|
|
background: rgba(76, 175, 80, 0.12);
|
|
}
|
|
&.dark-mode {
|
|
background: rgba(76, 175, 80, 0.18);
|
|
}
|
|
}
|
|
}
|
|
|
|
&.light-mode {
|
|
background: rgba(76, 175, 80, 0.08);
|
|
color: #4a5568;
|
|
}
|
|
|
|
&.dark-mode {
|
|
background: rgba(76, 175, 80, 0.12);
|
|
color: #a0aec0;
|
|
}
|
|
|
|
// Error state styling
|
|
&.error {
|
|
&.light-mode {
|
|
background: rgba(244, 67, 54, 0.08);
|
|
}
|
|
&.dark-mode {
|
|
background: rgba(244, 67, 54, 0.12);
|
|
}
|
|
}
|
|
|
|
// Timeout state styling
|
|
&.timeout {
|
|
&.light-mode {
|
|
background: rgba(255, 152, 0, 0.08);
|
|
}
|
|
&.dark-mode {
|
|
background: rgba(255, 152, 0, 0.12);
|
|
}
|
|
}
|
|
|
|
.tool-call-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.tool-call-name {
|
|
font-weight: 500;
|
|
flex: 1;
|
|
|
|
code {
|
|
font-family: 'Fira Code', 'Consolas', monospace;
|
|
font-size: 12px;
|
|
padding: 1px 4px;
|
|
border-radius: 3px;
|
|
|
|
.light-mode & {
|
|
background: rgba(0, 0, 0, 0.06);
|
|
}
|
|
.dark-mode & {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
}
|
|
}
|
|
}
|
|
|
|
.expand-icon {
|
|
opacity: 0.6;
|
|
transition: transform 0.2s;
|
|
}
|
|
|
|
.tool-call-details {
|
|
margin-top: 10px;
|
|
padding-top: 10px;
|
|
border-top: 1px solid rgba(128, 128, 128, 0.2);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
|
|
.detail-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.detail-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.detail-label {
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.copy-btn {
|
|
opacity: 0.6;
|
|
&:hover {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
.detail-value {
|
|
font-size: 12px;
|
|
user-select: text;
|
|
|
|
&.query-value {
|
|
font-family: 'Fira Code', 'Consolas', monospace;
|
|
padding: 8px;
|
|
border-radius: 4px;
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
user-select: text;
|
|
cursor: text;
|
|
|
|
.light-mode & {
|
|
background: rgba(0, 0, 0, 0.04);
|
|
}
|
|
.dark-mode & {
|
|
background: rgba(255, 255, 255, 0.06);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.tool-call-error {
|
|
font-size: 11px;
|
|
color: #f44336;
|
|
font-style: italic;
|
|
max-width: 250px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.tool-call-error {
|
|
font-size: 11px;
|
|
color: #f44336;
|
|
font-style: italic;
|
|
max-width: 250px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.tool-call-query {
|
|
font-family: 'Fira Code', 'Consolas', monospace;
|
|
font-size: 11px;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
max-width: 250px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
|
|
.light-mode & {
|
|
background: rgba(0, 0, 0, 0.05);
|
|
color: #666;
|
|
}
|
|
|
|
.dark-mode & {
|
|
background: rgba(255, 255, 255, 0.08);
|
|
color: #888;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// Theme-based syntax highlighting - both CSS files are loaded, we scope them by theme
|
|
// Hide dark theme in light mode
|
|
.light-mode {
|
|
// Force light theme colors to take precedence
|
|
:deep(.hljs) {
|
|
display: block;
|
|
overflow-x: auto;
|
|
padding: 0.5em;
|
|
color: #24292e;
|
|
background: #ffffff;
|
|
}
|
|
:deep(.hljs-doctag),
|
|
:deep(.hljs-keyword),
|
|
:deep(.hljs-meta .hljs-keyword),
|
|
:deep(.hljs-template-tag),
|
|
:deep(.hljs-template-variable),
|
|
:deep(.hljs-type),
|
|
:deep(.hljs-variable.language_) {
|
|
color: #d73a49;
|
|
}
|
|
:deep(.hljs-title),
|
|
:deep(.hljs-title.class_),
|
|
:deep(.hljs-title.class_.inherited__),
|
|
:deep(.hljs-title.function_) {
|
|
color: #6f42c1;
|
|
}
|
|
:deep(.hljs-attr),
|
|
:deep(.hljs-attribute),
|
|
:deep(.hljs-literal),
|
|
:deep(.hljs-meta),
|
|
:deep(.hljs-number),
|
|
:deep(.hljs-operator),
|
|
:deep(.hljs-variable),
|
|
:deep(.hljs-selector-attr),
|
|
:deep(.hljs-selector-class),
|
|
:deep(.hljs-selector-id) {
|
|
color: #005cc5;
|
|
}
|
|
:deep(.hljs-regexp),
|
|
:deep(.hljs-string),
|
|
:deep(.hljs-meta .hljs-string) {
|
|
color: #032f62;
|
|
}
|
|
:deep(.hljs-built_in),
|
|
:deep(.hljs-symbol) {
|
|
color: #e36209;
|
|
}
|
|
:deep(.hljs-comment),
|
|
:deep(.hljs-code),
|
|
:deep(.hljs-formula) {
|
|
color: #6a737d;
|
|
}
|
|
:deep(.hljs-name),
|
|
:deep(.hljs-quote),
|
|
:deep(.hljs-selector-tag),
|
|
:deep(.hljs-selector-pseudo) {
|
|
color: #22863a;
|
|
}
|
|
:deep(.hljs-subst) {
|
|
color: #24292e;
|
|
}
|
|
:deep(.hljs-section) {
|
|
color: #005cc5;
|
|
font-weight: bold;
|
|
}
|
|
:deep(.hljs-bullet) {
|
|
color: #735c0f;
|
|
}
|
|
:deep(.hljs-emphasis) {
|
|
color: #24292e;
|
|
font-style: italic;
|
|
}
|
|
:deep(.hljs-strong) {
|
|
color: #24292e;
|
|
font-weight: bold;
|
|
}
|
|
:deep(.hljs-addition) {
|
|
color: #22863a;
|
|
background-color: #f0fff4;
|
|
}
|
|
:deep(.hljs-deletion) {
|
|
color: #b31d28;
|
|
background-color: #ffeef0;
|
|
}
|
|
}
|
|
// Force dark theme colors in dark mode
|
|
.dark-mode {
|
|
:deep(.hljs) {
|
|
display: block;
|
|
overflow-x: auto;
|
|
padding: 0.5em;
|
|
color: #c9d1d9;
|
|
background: #0d1117;
|
|
}
|
|
:deep(.hljs-doctag),
|
|
:deep(.hljs-keyword),
|
|
:deep(.hljs-meta .hljs-keyword),
|
|
:deep(.hljs-template-tag),
|
|
:deep(.hljs-template-variable),
|
|
:deep(.hljs-type),
|
|
:deep(.hljs-variable.language_) {
|
|
color: #ff7b72;
|
|
}
|
|
:deep(.hljs-title),
|
|
:deep(.hljs-title.class_),
|
|
:deep(.hljs-title.class_.inherited__),
|
|
:deep(.hljs-title.function_) {
|
|
color: #d2a8ff;
|
|
}
|
|
:deep(.hljs-attr),
|
|
:deep(.hljs-attribute),
|
|
:deep(.hljs-literal),
|
|
:deep(.hljs-meta),
|
|
:deep(.hljs-number),
|
|
:deep(.hljs-operator),
|
|
:deep(.hljs-variable),
|
|
:deep(.hljs-selector-attr),
|
|
:deep(.hljs-selector-class),
|
|
:deep(.hljs-selector-id) {
|
|
color: #79c0ff;
|
|
}
|
|
:deep(.hljs-regexp),
|
|
:deep(.hljs-string),
|
|
:deep(.hljs-meta .hljs-string) {
|
|
color: #a5d6ff;
|
|
}
|
|
:deep(.hljs-built_in),
|
|
:deep(.hljs-symbol) {
|
|
color: #ffa657;
|
|
}
|
|
:deep(.hljs-comment),
|
|
:deep(.hljs-code),
|
|
:deep(.hljs-formula) {
|
|
color: #8b949e;
|
|
}
|
|
:deep(.hljs-name),
|
|
:deep(.hljs-quote),
|
|
:deep(.hljs-selector-tag),
|
|
:deep(.hljs-selector-pseudo) {
|
|
color: #7ee787;
|
|
}
|
|
:deep(.hljs-subst) {
|
|
color: #c9d1d9;
|
|
}
|
|
:deep(.hljs-section) {
|
|
color: #1f6feb;
|
|
font-weight: bold;
|
|
}
|
|
:deep(.hljs-bullet) {
|
|
color: #f2cc60;
|
|
}
|
|
:deep(.hljs-emphasis) {
|
|
color: #c9d1d9;
|
|
font-style: italic;
|
|
}
|
|
:deep(.hljs-strong) {
|
|
color: #c9d1d9;
|
|
font-weight: bold;
|
|
}
|
|
:deep(.hljs-addition) {
|
|
color: #aff5b4;
|
|
background-color: #033a16;
|
|
}
|
|
:deep(.hljs-deletion) {
|
|
color: #ffdcd7;
|
|
background-color: #67060c;
|
|
}
|
|
}
|
|
</style>
|