Files
openobserve/web/src/components/O2AIChat.vue
T
5122275d05 feat: agent level filters (#12691)
- **add agent filters, agent name mapping and normalization**
- **chore: update translations from en.json**
- **agent filter, rebased**

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: sai nikhil kethe <nikhil@openobserve.ai>
Co-authored-by: ktx-abhay <abhay.padamani@kiara.tech>
2026-07-01 18:08:46 +00:00

7615 lines
251 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div
class="chat-container tw:w-full tw:h-full tw:flex tw:flex-col tw:overflow-hidden tw:rounded-md"
:class="[
{ 'chat-open': isOpen },
store.state.theme == 'dark' ? 'dark-mode' : 'light-mode',
]"
>
<div
v-if="isOpen"
class="chat-content-wrapper tw:flex tw:flex-col tw:h-full tw:bg-transparent"
: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">
<div class="tw:inline-flex tw:w-6 tw:h-6 tw:rounded-full tw:overflow-hidden">
<img :src="o2AiTitleLogo" class="tw:w-full tw:h-full tw:object-cover" />
</div>
<ODropdown @update:open="(v) => v && loadHistory()">
<template #trigger>
<OButton
variant="ghost"
size="sm"
class="chat-title-dropdown"
>
<div class="tw:flex tw:items-center tw:gap-2 tw:max-w-[220px]">
<span
class="chat-title-text tw:text-[14px] tw:font-medium tw:truncate tw:block"
>
{{ displayedTitle || "New Chat" }}
<OTooltip
v-if="displayedTitle && displayedTitle.length > 25"
:sideOffset="8"
side="bottom"
align="center"
:content="displayedTitle"
/>
</span>
<OIcon
name="arrow-drop-down"
size="md"
class="tw:flex-shrink-0"
/>
</div>
</OButton>
</template>
<!-- History menu with search -->
<div class="history-menu-container">
<div class="search-history-bar-sticky">
<OSearchInput
v-model="historySearchTerm"
placeholder="Search chat history"
class="tw:mt-1"
/>
</div>
<div
class="history-list-container"
style="
min-width: 200px;
width: 300px;
max-width: 300px;
border: 1px solid var(--o2-border);
"
>
<ODropdownItem
v-for="chat in filteredChatHistory"
:key="chat.id"
class="history-item"
@select="loadChat(chat.id)"
>
<div
class="tw:flex tw:items-center tw:justify-between tw:w-full"
>
<div class="tw:flex-1 tw:overflow-hidden">
<div class="tw:text-[13px] tw:truncate">
{{ chat.title }}
</div>
<div class="tw:text-[11px] tw:text-gray-500">
{{ formatTime(chat.timestamp) }}
</div>
</div>
<OButton
variant="ghost"
size="icon-xs-circle"
class="delete-history-btn"
@click.stop="deleteChat(chat.id)"
>
<OIcon name="delete" size="sm" />
<OTooltip content="Delete chat" />
</OButton>
</div>
</ODropdownItem>
<div
v-if="filteredChatHistory.length === 0"
class="tw:text-center tw:text-gray-500 tw:p-2"
>
No matching chats found
</div>
</div>
<!-- Clear all conversations button -->
<div
v-if="filteredChatHistory.length > 0"
class="clear-all-container"
>
<ODropdownSeparator />
<OButton
variant="ghost"
class="clear-all-btn"
@click.stop="clearAllConversations"
>
<template #icon-left>
<OIcon name="delete-sweep" size="sm" />
</template>
Clear all conversations
</OButton>
</div>
</div>
</ODropdown>
</div>
<div class="tw:flex tw:items-center tw:gap-1 chat-header-actions">
<!-- Edit title button -->
<OButton
v-if="currentChatId"
variant="ghost"
size="icon-sm"
@click.stop="openEditTitleDialog"
>
<OIcon name="edit" size="sm" />
<OTooltip content="Edit title" />
</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="`${store.state.isAiChatExpanded ? 'Collapse' : 'Expand'} (${isMac ? '⌘' : 'Ctrl+'}B)`"
/>
</OButton>
<OButton variant="ghost" size="icon-sm" @click="$emit('close')">
<OIcon name="close" size="sm" />
</OButton>
</div>
</div>
</div>
<OSeparator class="tw:bg-[#DBDBDB]" />
<!-- History Panel -->
<ODrawer data-test="o2-ai-chat-history-drawer" v-model:open="showHistory" size="sm" title="Chat History">
<ul class="tw:flex tw:flex-col tw:divide-y tw:divide-border">
<li
v-for="chat in chatHistory"
:key="chat.id"
:data-test="`o2-ai-chat-history-item-${chat.id}`"
class="tw:flex tw:flex-col tw:px-3 tw:py-2 tw:cursor-pointer hover:tw:bg-muted/50"
@click="loadChat(chat.id)"
>
<span class="tw:text-sm">{{ chat.title }}</span>
<span class="tw:block tw:text-xs tw:text-muted-foreground">
{{ new Date(chat.timestamp).toLocaleString() }}
</span>
<span class="tw:block tw:text-xs tw:text-muted-foreground">
Model: {{ 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="Edit Chat Title"
secondary-button-label="Cancel"
primary-button-label="Save"
@click:secondary="showEditTitleDialog = false"
@click:primary="saveEditedTitle"
>
<OInput
v-model="editingTitle"
autofocus
@keyup.enter="saveEditedTitle"
placeholder="Enter chat title"
/>
</ODialog>
<!-- Delete Chat Confirmation Dialog -->
<ConfirmDialog
v-model="showDeleteChatConfirmDialog"
title="Delete Chat"
message="Are you sure you want to delete this chat? This action cannot be undone."
@update:ok="confirmDeleteChat"
@update:cancel="showDeleteChatConfirmDialog = false"
/>
<!-- Clear All Conversations Confirmation Dialog -->
<ConfirmDialog
v-model="showClearAllConfirmDialog"
title="Clear All Conversations"
message="Are you sure you want to clear all conversations? This action cannot be undone."
@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="previewImage?.filename"
>
<div class="tw:flex tw:justify-center">
<img
v-if="previewImage"
:src="
'data:' + previewImage.mimeType + ';base64,' + previewImage.data
"
:alt="previewImage.filename"
style="max-width: 100%; max-height: 80vh; object-fit: contain"
/>
</div>
</ODialog>
<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"
:class="{ 'welcome-section--centered': centeredStart }"
>
<!-- Home tab: rich V2 welcome -->
<O2AIHomeWelcome
v-if="centeredStart"
@select-prompt="selectWelcomePrompt"
/>
<!-- Sidepanel: minimal logo + title (unchanged) -->
<div
v-else
class="tw:flex tw:flex-col tw:items-center tw:justify-center tw:h-full tw:w-full"
>
<div class="tw:flex tw:flex-col tw:items-center">
<img :src="o2AiTitleLogo" />
<div class="tw:relative tw:inline-block">
<span
class="tw:text-[14px] tw:font-[600] tw:ml-[30px] tw:text-center"
>O2 Assistant</span
>
<span class="o2-ai-beta-text tw:ml-[8px]">BETA</span>
</div>
</div>
</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">
<div
v-if="message.role === 'user'"
class="tw:inline-flex tw:items-center tw:justify-center tw:w-6 tw:h-6 tw:rounded-full"
:class="
store.state.theme == 'dark'
? 'dark-user-avatar'
: 'light-user-avatar'
"
>
<OIcon
size="sm"
name="person"
class='tw:text-white'
/>
</div>
<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">
<OSpinner variant="dots" size="sm" />
<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) },
{
error:
block.success === false && !block.pendingConfirmation,
},
{
'pending-confirmation':
block.pendingConfirmation &&
block.tool !== 'navigation_action',
},
{
'pending-navigation':
block.pendingConfirmation &&
block.tool === 'navigation_action',
},
]"
@click="
hasToolCallDetails(block) &&
!block.pendingConfirmation &&
toggleToolCallExpanded(index, blockIndex)
"
>
<div class="tool-call-header">
<OIcon
:name="
block.pendingConfirmation
? block.tool === 'navigation_action'
? 'open-in-new'
: 'help-outline'
: block.success === false
? 'error'
: 'check-circle'
"
size="sm"
:class="
block.pendingConfirmation
? block.tool === 'navigation_action'
? 'tw:text-[var(--o2-primary)]'
: 'tw:text-[var(--o2-warning)]'
: block.success === false
? 'tw:text-[var(--o2-negative)]'
: 'tw:text-[var(--o2-positive)]'
"
/>
<span class="tool-call-name">
{{ formatToolCallMessage(block).text
}}<strong
v-if="formatToolCallMessage(block).highlight"
>{{ formatToolCallMessage(block).highlight }}</strong
>{{ formatToolCallMessage(block).suffix }}
</span>
<!-- Navigation icon -->
<OIcon
v-if="
block.navigationAction && !block.pendingConfirmation
"
name="open-in-new"
size="xs"
class="navigation-icon"
@click.stop="
handleNavigationAction(block.navigationAction)
"
>
<OTooltip :content="block.navigationAction.label" />
</OIcon>
<OIcon
v-if="
hasToolCallDetails(block) &&
!block.pendingConfirmation
"
:name="
isToolCallExpanded(index, blockIndex)
? 'expand-less'
: 'expand-more'
"
size="sm"
class="expand-icon"
/>
</div>
<!-- Expandable details -->
<div
v-if="isToolCallExpanded(index, blockIndex)"
class="tool-call-details"
@click.stop
>
<!-- Error details for failed tool calls -->
<template v-if="block.success === false">
<div v-if="block.resultMessage" class="detail-item">
<span class="detail-label">Error</span>
<span class="detail-value tool-error-message">{{
block.resultMessage
}}</span>
</div>
<div v-if="block.errorType" class="detail-item">
<span class="detail-label">Type</span>
<code class="detail-value">{{
block.errorType
}}</code>
</div>
<div v-if="block.suggestion" class="detail-item">
<span class="detail-label">Suggestion</span>
<span class="detail-value tool-suggestion">{{
block.suggestion
}}</span>
</div>
</template>
<!-- Summary details for successful tool calls with summary -->
<template v-if="block.success !== false && block.summary">
<div
v-if="block.summary.count !== undefined"
class="detail-item"
>
<span class="detail-label">Results</span>
<span class="detail-value"
>{{ block.summary.count }} records</span
>
</div>
<div
v-if="block.summary.took !== undefined"
class="detail-item"
>
<span class="detail-label">Duration</span>
<span class="detail-value"
>{{ block.summary.took }}ms</span
>
</div>
<!-- CLI tool summary (return_code / stdout_lines / stderr_lines / truncated) -->
<div
v-if="block.summary.return_code !== undefined"
class="detail-item"
>
<span class="detail-label">Exit code</span>
<code class="detail-value">{{
block.summary.return_code
}}</code>
</div>
<div
v-if="block.summary.stdout_lines !== undefined"
class="detail-item"
>
<span class="detail-label">Stdout</span>
<span class="detail-value"
>{{ block.summary.stdout_lines }} lines</span
>
</div>
<div
v-if="block.summary.stderr_lines"
class="detail-item"
>
<span class="detail-label">Stderr</span>
<span class="detail-value"
>{{ block.summary.stderr_lines }} lines</span
>
</div>
<div v-if="block.summary.truncated" class="detail-item">
<span class="detail-label">Output</span>
<span class="detail-value">truncated</span>
</div>
</template>
<!-- Existing context details -->
<div
v-if="getToolCallDisplayData(block.context)?.query"
class="detail-item"
>
<div class="detail-header">
<span class="detail-label">Query</span>
<OButton
variant="ghost"
size="icon-xs-circle"
class="copy-btn"
@click.stop="
copyToClipboard(
getToolCallDisplayData(block.context)?.query,
)
"
>
<OIcon name="content-copy" size="sm" />
<OTooltip content="Copy query" />
</OButton>
</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>
<OButton
variant="ghost"
size="icon-xs-circle"
class="copy-btn"
@click.stop="
copyToClipboard(
getToolCallDisplayData(block.context)?.vrl,
)
"
>
<OIcon name="content-copy" size="sm" />
<OTooltip content="Copy VRL" />
</OButton>
</div>
<code class="detail-value query-value">{{
getToolCallDisplayData(block.context)?.vrl
}}</code>
</div>
<div
v-if="getToolCallDisplayData(block.context)?.command"
class="detail-item"
>
<div class="detail-header">
<span class="detail-label">Command</span>
<OButton
variant="ghost"
size="icon-xs-circle"
class="copy-btn"
@click.stop="
copyToClipboard(
getToolCallDisplayData(block.context)?.command,
)
"
>
<OIcon name="content-copy" size="sm" />
<OTooltip content="Copy command" />
</OButton>
</div>
<code class="detail-value query-value">{{
getToolCallDisplayData(block.context)?.command
}}</code>
</div>
<!-- Tool response: SearchSQL hits -->
<template v-if="block.response && block.response.hits">
<div class="detail-item">
<div class="detail-header">
<span class="detail-label">Results</span>
<OButton
variant="ghost"
size="icon-xs-circle"
class="copy-btn"
@click.stop="
copyToClipboard(
JSON.stringify(block.response.hits, null, 2),
)
"
>
<OIcon name="content-copy" size="sm" />
<OTooltip content="Copy results" />
</OButton>
</div>
<div class="tool-response-hits">
<div
v-for="(hit, hIdx) in block.response.hits"
:key="hIdx"
class="tool-response-hit"
>
<span
v-for="(val, key) in hit"
:key="key"
class="hit-field"
>
<span class="hit-key">{{ key }}:</span>
{{ val }}
</span>
</div>
</div>
</div>
<div class="tool-response-meta">
<span
v-if="block.response.total !== undefined"
class="context-tag"
>Total: {{ block.response.total }}</span
>
<span
v-if="block.response.took !== undefined"
class="context-tag"
>Took: {{ block.response.took }}ms</span
>
<span
v-if="block.response.hits_truncated"
class="context-tag"
>Showing first
{{ block.response.hits.length }}</span
>
</div>
</template>
<!-- Tool response: testFunction input/output -->
<template
v-else-if="
block.response &&
(block.response.input || block.response.output)
"
>
<div v-if="block.response.input" class="detail-item">
<span class="detail-label">Input Events</span>
<div class="tool-response-hits">
<div
v-for="(evt, eIdx) in block.response.input"
:key="eIdx"
class="tool-response-hit"
>
<span
v-for="(val, key) in evt"
:key="key"
class="hit-field"
>
<span class="hit-key">{{ key }}:</span>
{{
typeof val === "string" && val.length > 120
? val.substring(0, 120) + "..."
: val
}}
</span>
</div>
</div>
</div>
<div v-if="block.response.output" class="detail-item">
<span class="detail-label">Output</span>
<div class="tool-response-hits">
<div
v-for="(res, rIdx) in block.response.output"
:key="rIdx"
class="tool-response-hit"
>
<template v-if="res.event">
<span
v-for="(val, key) in res.event"
:key="key"
class="hit-field"
>
<span class="hit-key">{{ key }}:</span>
{{
typeof val === "string" && val.length > 120
? val.substring(0, 120) + "..."
: val
}}
</span>
</template>
<span
v-if="res.message"
class="hit-field hit-error"
>
<span class="hit-key">error:</span>
{{ res.message }}
</span>
</div>
</div>
</div>
</template>
<!-- Tool response: list items from normalized { total, items } -->
<template
v-else-if="
block.response &&
block.response.items &&
Array.isArray(block.response.items)
"
>
<div
v-if="block.response.items.length > 0"
class="detail-item"
>
<div class="detail-header">
<span class="detail-label">Items</span>
<OButton
variant="ghost"
size="icon-xs-circle"
class="copy-btn"
@click.stop="
copyToClipboard(
JSON.stringify(block.response.items, null, 2),
)
"
>
<OIcon name="content-copy" size="sm" />
<OTooltip content="Copy items" />
</OButton>
</div>
<div class="tool-response-hits">
<div
v-for="(item, iIdx) in block.response.items"
:key="iIdx"
class="tool-response-list-item"
>
<div
v-for="(val, key) in item"
:key="key"
class="hit-field"
>
<span class="hit-key">{{ key }}:</span>
{{
typeof val === "object"
? JSON.stringify(val)
: val
}}
</div>
</div>
</div>
</div>
</template>
<!-- Tool response: generic fallback (string or other) -->
<div v-else-if="block.response" class="detail-item">
<div class="detail-header">
<span class="detail-label">Response</span>
<OButton
variant="ghost"
size="icon-xs-circle"
class="copy-btn"
@click.stop="
copyToClipboard(
typeof block.response === 'string'
? block.response
: JSON.stringify(block.response, null, 2),
)
"
>
<OIcon name="content-copy" size="sm" />
<OTooltip content="Copy response" />
</OButton>
</div>
<code class="detail-value query-value">{{
typeof block.response === "string"
? block.response
: JSON.stringify(block.response, null, 2)
}}</code>
</div>
</div>
</div>
<!-- Log Entry block - expandable -->
<div
v-else-if="block.type === 'log_entry'"
class="log-entry-item"
:class="[
store.state.theme == 'dark' ? 'dark-mode' : 'light-mode',
]"
@click="toggleLogEntryExpanded(index, blockIndex)"
>
<div class="log-entry-header">
<OIcon name="description" size="xs" />
<span class="log-entry-info">
{{ block.preview }}
</span>
<OIcon
:name="
isLogEntryExpanded(index, blockIndex)
? 'expand-less'
: 'expand-more'
"
size="sm"
class="expand-icon"
/>
</div>
<!-- Expandable details -->
<div
v-if="isLogEntryExpanded(index, blockIndex)"
class="log-entry-details"
@click.stop
>
<div class="log-entry-content">
<OButton
variant="ghost"
size="icon-xs-circle"
class="copy-btn"
@click.stop="copyToClipboard(block.content)"
>
<OIcon name="content-copy" size="sm" />
<OTooltip content="Copy content" />
</OButton>
<code
class="log-entry-code"
v-html="formatLogEntryContent(block.content)"
></code>
</div>
</div>
</div>
<!-- Stream-level error block -->
<div
v-else-if="block.type === 'error'"
class="stream-error-block"
:class="
store.state.theme == 'dark' ? 'dark-mode' : 'light-mode'
"
>
<div class="stream-error-header">
<OIcon name="warning" size="sm" />
<span class="stream-error-message">{{
block.message
}}</span>
</div>
<div
v-if="block.suggestion"
class="stream-error-suggestion"
>
{{ block.suggestion }}
</div>
<div
v-if="block.recoverable"
class="stream-error-recoverable"
>
This error may be temporary. You can try again.
</div>
</div>
<!-- Navigation block - standalone navigation button -->
<div
v-else-if="
block.type === 'navigation' && block.navigationAction
"
class="navigation-block"
:class="
store.state.theme == 'dark' ? 'dark-mode' : 'light-mode'
"
>
<OButton
variant="primary"
size="xs"
class="navigation-block-btn"
@click="handleNavigationAction(block.navigationAction)"
>
<template #icon-left
><OIcon :name="'open-in-new'" size="sm"
/></template>
{{ block.navigationAction.label }}
</OButton>
</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>
<OButton
variant="ghost"
size="xs"
class="copy-button"
@click="copyToClipboard(textBlock.content)"
>
<OIcon size="sm" name="content-copy" />
<span class="tw:ml-1">Copy</span>
</OButton>
</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"
>
<OButton
variant="ghost"
size="xs"
class="retry-button"
@click="retryGeneration(message)"
>
<OIcon size="sm" name="refresh" />
<span class="tw:ml-1">Retry</span>
</OButton>
</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
"
>
<!-- Display images for user messages -->
<div
v-if="
message.role === 'user' &&
message.images &&
message.images.length > 0
"
class="message-images tw:flex tw:flex-wrap tw:gap-2 tw:mb-2"
>
<div
v-for="(img, imgIndex) in message.images"
:key="'img-' + imgIndex"
class="message-image-item"
>
<img
:src="'data:' + img.mimeType + ';base64,' + img.data"
:alt="img.filename"
class="tw:max-w-[200px] tw:max-h-[150px] tw:object-contain tw:rounded tw:border tw:border-gray-300 tw:cursor-pointer"
@click="openImagePreview(img)"
/>
<OTooltip :content="img.filename" />
</div>
</div>
<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>
<OButton
variant="ghost"
size="xs"
class="copy-button"
@click="copyToClipboard(block.content)"
>
<OIcon size="sm" name="content-copy" />
<span class="tw:ml-1">Copy</span>
</OButton>
</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>
<!-- Feedback buttons for assistant messages -->
<div
v-if="
message.role === 'assistant' &&
message.content &&
message.content.trim() !== ''
"
class="feedback-buttons"
:class="{ 'feedback-active': message.feedback }"
>
<OButton
variant="ghost"
size="icon-xs-circle"
:disabled="message.feedback === 'thumbs_up'"
:class="{
'feedback-selected': message.feedback === 'thumbs_up',
}"
data-test="o2-ai-chat-thumbs-up-btn"
@click="likeCodeBlock(index)"
>
<OIcon name="thumb-up-off-alt" size="xs" />
<OTooltip content="Helpful" />
</OButton>
<OButton
variant="ghost"
size="icon-xs-circle"
:disabled="message.feedback === 'thumbs_down'"
:class="{
'feedback-selected': message.feedback === 'thumbs_down',
}"
data-test="o2-ai-chat-thumbs-down-btn"
@click="dislikeCodeBlock(index)"
>
<OIcon name="thumb-down-off-alt" size="xs" />
<OTooltip content="Not helpful" />
</OButton>
</div>
</div>
</div>
</div>
<!-- Completed tool calls during streaming - keep progress visible
so each step stays on screen instead of flashing away while it
sits in pendingToolCalls waiting for the assistant's text. -->
<div
v-for="(block, pIdx) in pendingToolCalls"
v-show="block.type === 'tool_call'"
:key="'pending-tc-' + pIdx"
class="tool-call-indicator completed"
:class="store.state.theme == 'dark' ? 'dark-mode' : 'light-mode'"
>
<div class="tool-call-content">
<OIcon
:name="block.success === false ? 'error' : 'check-circle'"
size="sm"
/>
<div class="tool-call-info">
<span class="tool-call-message">{{ block.message }}</span>
</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">
<OSpinner variant="dots" size="xs" />
<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>
<div
v-if="
getToolCallDisplayData(activeToolCall.context)?.vrl &&
!getToolCallDisplayData(activeToolCall.context)?.query
"
class="context-item"
>
<code class="context-query">{{
truncateQuery(
getToolCallDisplayData(activeToolCall.context)?.vrl,
)
}}</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"
class="tool-call-indicator"
:class="store.state.theme == 'dark' ? 'dark-mode' : 'light-mode'"
>
<div class="tool-call-content">
<OSpinner variant="dots" size="xs" />
<span class="tool-call-message">{{
currentAnalyzingMessage
}}</span>
</div>
</div>
</div>
<!-- Scroll to bottom button -->
<div v-show="showScrollToBottom" class="scroll-to-bottom-container">
<OButton
variant="ghost"
size="icon-sm"
class="scroll-to-bottom-btn"
@click="scrollToBottomSmooth"
>
<OIcon name="arrow-downward" size="sm" />
<OTooltip side="top" align="center" content="Scroll to bottom" />
</OButton>
</div>
</div>
<!-- Fixed loading indicator above input - only shown when scrolled up -->
<div
v-if="(isLoading || activeToolCall) && showScrollToBottom"
class="fixed-analyzing-indicator"
:class="store.state.theme == 'dark' ? 'dark-mode' : 'light-mode'"
>
<!-- Show tool call if active -->
<div v-if="activeToolCall" class="analyzing-content">
<OSpinner variant="dots" size="xs" />
<span class="analyzing-message">{{ activeToolCall.message }}</span>
</div>
<!-- Show analyzing message if loading but no active tool call -->
<div v-else-if="isLoading" class="analyzing-content">
<OSpinner variant="dots" size="xs" />
<span class="analyzing-message">{{ currentAnalyzingMessage }}</span>
</div>
</div>
<div class="chat-input-container tw:m-3">
<!-- Confirmation dialog -->
<O2AIConfirmDialog
:visible="pendingConfirmation !== null"
:confirmation="pendingConfirmation"
@confirm="handleToolConfirm"
@cancel="handleToolCancel"
@always-confirm="handleToolAlwaysConfirm"
/>
<!-- Hidden file input for image upload -->
<input
ref="imageInputRef"
type="file"
accept="image/png,image/jpeg"
multiple
style="display: none"
@change="handleImageSelect"
/>
<div
v-if="!pendingConfirmation"
class="unified-input-box"
:class="store.state.theme == 'dark' ? 'dark-mode' : 'light-mode'"
@dragover="handleDragOver"
@drop="handleDrop"
@paste="handlePaste"
>
<!-- Image preview strip -->
<div v-if="pendingImages.length > 0" class="image-preview-strip">
<div
v-for="(img, index) in pendingImages"
:key="index"
class="image-preview-item"
>
<img
:src="'data:' + img.mimeType + ';base64,' + img.data"
:alt="img.filename"
class="preview-image"
/>
<OButton
variant="ghost"
size="icon-xs-circle"
class="image-remove-btn"
@click.stop="removeImage(index)"
>
<OIcon name="close" size="xs" />
</OButton>
<OTooltip
:content="`${img.filename} (${(img.size / 1024).toFixed(0)}KB)`"
/>
</div>
</div>
<RichTextInput
ref="chatInput"
v-model="inputMessage"
:placeholder="inputPlaceholder"
:disabled="isLoading"
:theme="store.state.theme"
:references="contextReferences"
:borderless="true"
@keydown="handleKeyDown"
@submit="sendMessage"
@update:references="handleReferencesUpdate"
/>
<!-- Bottom bar with buttons -->
<div class="input-bottom-bar">
<div class="tw:flex tw:items-center tw:gap-2">
<!-- Image upload button -->
<OButton
v-if="!isLoading"
@click.stop="triggerImageUpload"
variant="ghost"
size="icon-sm"
class="image-upload-btn"
>
<OIcon
name="image"
size="sm"
:class="store.state.theme == 'dark' ? 'tw:text-white' : 'tw:text-gray-600'"
/>
<OTooltip :content="t('aiAssistant.attachImageTooltip')" />
</OButton>
<div v-else class="tw:w-8"></div>
<!-- Auto navigation toggle button -->
<OButton
v-if="!isLoading"
@click.stop="isAutoNavigationEnabled = !isAutoNavigationEnabled"
variant="ghost"
size="sm"
class="auto-nav-toggle-btn"
:class="{ 'auto-nav-enabled': isAutoNavigationEnabled }"
>
<OIcon
:name="
isAutoNavigationEnabled
? 'check-circle'
: 'radio-button-unchecked'
"
size="sm"
:class="[
'auto-nav-icon',
!isAutoNavigationEnabled
? store.state.theme == 'dark' ? 'tw:text-gray-400' : 'tw:text-gray-600'
: ''
]"
/>
<span class="auto-nav-label tw:ml-1">{{ t('aiAssistant.autoNavigation.label') }}</span>
<OTooltip
:content="
isAutoNavigationEnabled
? t('aiAssistant.autoNavigation.enabledTooltip')
: t('aiAssistant.autoNavigation.disabledTooltip')
"
/>
</OButton>
</div>
<div class="tw:flex tw:items-center tw:gap-2">
<!-- Send button - shown when not loading -->
<OButton
v-if="!isLoading"
:disabled="!inputMessage.trim() && pendingImages.length === 0"
@click="sendMessage"
variant="ai-gradient"
size="icon-xs-circle"
class="send-button"
>
<OIcon name="send" size="sm" />
</OButton>
<!-- Stop button - shown when loading/streaming -->
<OButton
v-if="isLoading"
@click="cancelCurrentRequest"
variant="ghost"
size="icon-xs-circle"
class="stop-button"
>
<OIcon name="stop" size="sm" />
</OButton>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import {
defineComponent,
ref,
onMounted,
nextTick,
watch,
computed,
onUnmounted,
} from "vue";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import { useTypewriterPlaceholder } from "@/components/ai-assistant/welcome/useTypewriterPlaceholder";
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 { useStore } from "vuex";
import useAiChat from "@/composables/useAiChat";
import { getImageURL, getUUIDv7 } from "@/utils/zincutils";
import {
ChatMessage,
ChatHistoryEntry,
ToolCall,
ContentBlock,
NavigationAction,
ImageAttachment,
MAX_IMAGE_SIZE_BYTES,
ALLOWED_IMAGE_TYPES,
} from "@/ts/interfaces/chat";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import RichTextInput, { ReferenceChip } from "@/components/RichTextInput.vue";
import O2AIConfirmDialog from "@/components/O2AIConfirmDialog.vue";
import O2AIHomeWelcome from "@/components/ai-assistant/welcome/O2AIHomeWelcome.vue";
import { useChatHistory } from "@/composables/useChatHistory";
import { useShortcuts } from "@/lib/vue-shortcut-manager";
import {
useAiDashboardEvents,
getDashboardEventType,
} from "@/composables/useAiDashboardEvents";
import OButton from "@/lib/core/Button/OButton.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 ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import ODropdownSeparator from "@/lib/overlay/Dropdown/ODropdownSeparator.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 OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
import { copyToClipboard } from "@/utils/clipboard";
import OSeparator from '@/lib/core/Separator/OSeparator.vue';
import { UNAUTHORIZED_MESSAGE, isAuthError } from "@/utils/authErrors";
const { fetchAiChat, submitFeedback } = useAiChat();
const { emit: emitDashboardEvent } = useAiDashboardEvents();
// 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",
components: {
OSeparator,
OButton,
ConfirmDialog,
RichTextInput,
O2AIConfirmDialog,
O2AIHomeWelcome,
ODropdown,
ODropdownItem,
ODropdownSeparator,
ODrawer,
ODialog,
OSpinner,
OIcon,
OTooltip,
OInput,
OSearchInput,
},
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: "",
},
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 inputMessage = ref(
props.aiChatInputContext ? props.aiChatInputContext : "",
);
const chatMessages = ref<ChatMessage[]>([]);
const isLoading = ref(false);
const messagesContainer = ref<HTMLElement | null>(null);
const chatInput = ref<any>(null); // RichTextInput component instance
const scrollTimeoutId = ref<ReturnType<typeof setTimeout> | null>(null);
const currentStreamingMessage = ref("");
const currentTextSegment = ref(""); // Track current text segment (resets after each tool call)
const showHistory = ref(false);
const chatHistory = ref<ChatHistoryEntry[]>([]);
const currentChatId = ref<number | null>(null);
const currentSessionId = ref<string | null>(null); // UUID v7 for tracking all API calls in this chat session
const lastTraceId = ref<string | null>(null); // OTEL trace_id from last workflow for feedback correlation
const store = useStore();
const { t } = useI18n();
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
const chatUpdated = computed(() => store.state.chatUpdated);
// Typewriter placeholder — only animates on the home tab (centeredStart) when no chat is open.
// On the sidepanel the placeholder stays static ("Write your prompt").
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 || "Write your prompt"
: "Write your prompt",
);
// Chat history composable
const {
saveToHistory: dbSaveToHistory,
loadHistory: dbLoadHistory,
loadChat: dbLoadChat,
deleteChatById: dbDeleteChatById,
clearAllHistory: dbClearAllHistory,
updateChatTitle: dbUpdateChatTitle,
} = useChatHistory(
() => store.state.userInfo.email ?? "",
() => store.state.selectedOrganization.identifier ?? "",
);
const currentChatTimestamp = ref<string | null>(null);
const saveHistoryLoading = ref(false);
const historySearchTerm = ref("");
const shouldAutoScroll = ref(true);
const showScrollToBottom = ref(false);
// Edit title state
const showEditTitleDialog = ref(false);
const editingTitle = ref("");
// Clear all confirmation state
const showClearAllConfirmDialog = ref(false);
// Delete individual chat confirmation state
const showDeleteChatConfirmDialog = ref(false);
const chatToDelete = ref<number | null>(null);
// Tool confirmation state (from AI agent — confirmation-required actions, inline in chat)
const pendingConfirmation = ref<{
tool: string;
args: Record<string, any>;
message: string;
navAction?: NavigationAction;
} | null>(null);
// Auto navigation state - per chat ID
// Stores chat ID -> boolean mapping for auto navigation preference
const autoNavigationPreferences = ref<Map<number, boolean>>(new Map());
// Pending auto navigation preference for new chats (before chat ID is created)
const pendingAutoNavigation = ref(true);
// Current chat's auto navigation state (defaults to true)
const isAutoNavigationEnabled = computed({
get: () => {
if (!currentChatId.value) return pendingAutoNavigation.value;
return autoNavigationPreferences.value.get(currentChatId.value) ?? true;
},
set: (value: boolean) => {
if (currentChatId.value) {
autoNavigationPreferences.value.set(currentChatId.value, value);
saveAutoNavigationPreferences();
} else {
// Store temporarily for new chats
pendingAutoNavigation.value = value;
}
},
});
// AI-generated chat title state
const aiGeneratedTitle = ref<string | null>(null);
const displayedTitle = ref<string>("");
const isTypingTitle = ref(false);
const titleAnimationId = ref<number>(0); // Used to cancel stale animations
// Track expanded tool calls by message index and block index
const expandedToolCalls = ref<Set<string>>(new Set());
// Track expanded log entries by message index and block index
const expandedLogEntries = 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);
// Background streams: when the user switches sessions while streaming,
// the detached stream continues in background and saves to IndexedDB on completion.
const backgroundStreams = new Set<AbortController>();
const MAX_BACKGROUND_STREAMS = 3;
// Map sessionId → live stream context for re-attachment when user navigates back.
// This allows loadChat to swap chatMessages.value back to the live array so that
// processStream's isActive() becomes true again and the UI updates in real-time.
const backgroundStreamMap = new Map<
string,
{
msgs: ChatMessage[];
controller: AbortController;
chatId: number | null;
}
>();
// Typewriter animation state for LLM responses
const displayedStreamingContent = ref("");
const typewriterAnimationId = ref<number | null>(null);
const TYPEWRITER_SPEED = 8; // ms per character - fast like ChatGPT (5-10ms range)
// Throttle save during streaming to prevent data loss on page reload
const lastStreamingSaveTime = ref<number>(0);
const STREAMING_SAVE_INTERVAL = 3000; // Save at most every 3 seconds during streaming
// Pending images for current message
const pendingImages = ref<ImageAttachment[]>([]);
const imageInputRef = ref<HTMLInputElement | null>(null);
// Image preview dialog state
const showImagePreview = ref(false);
const previewImage = ref<ImageAttachment | null>(null);
// Context references for rich text input chips
const contextReferences = ref<ReferenceChip[]>([]);
// Component readiness tracking
const componentReady = ref(false);
const pendingChips = ref<ReferenceChip[]>([]);
// 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;
}
};
// Flush any in-progress streaming text into the last assistant text block
// and reset streaming state. Called before inserting non-text blocks
// (confirmation_required, navigation_action) so the text preceding them is
// not lost.
const finalizeTextBlock = () => {
if (currentTextSegment.value) {
const lm = chatMessages.value[chatMessages.value.length - 1];
if (lm && lm.role === "assistant" && lm.contentBlocks) {
const lb = lm.contentBlocks[lm.contentBlocks.length - 1];
if (lb && lb.type === "text") {
lb.text = currentTextSegment.value;
}
}
}
if (typewriterAnimationId.value) {
cancelAnimationFrame(typewriterAnimationId.value);
typewriterAnimationId.value = null;
}
currentTextSegment.value = "";
displayedStreamingContent.value = "";
};
// Interval ID for title animation
let titleIntervalId: ReturnType<typeof setInterval> | null = null;
/**
* Animate title with typewriter effect
* Characters appear one by one from left to right
* Uses setInterval for reliable timing with Vue reactivity
*/
const animateTitle = (title: string) => {
// Clear any existing animation
if (titleIntervalId) {
clearInterval(titleIntervalId);
titleIntervalId = null;
}
// Increment animation ID to track this animation
const currentAnimationId = ++titleAnimationId.value;
isTypingTitle.value = true;
displayedTitle.value = "";
let charIndex = 0;
titleIntervalId = setInterval(() => {
// Check if this animation was superseded
if (titleAnimationId.value !== currentAnimationId) {
if (titleIntervalId) {
clearInterval(titleIntervalId);
titleIntervalId = null;
}
return;
}
if (charIndex < title.length) {
displayedTitle.value = title.slice(0, charIndex + 1);
charIndex++;
} else {
// Animation complete
if (titleIntervalId) {
clearInterval(titleIntervalId);
titleIntervalId = null;
}
isTypingTitle.value = false;
}
}, 30); // 30ms per character
};
/**
* Reset title state for new chat
*/
const resetTitleState = () => {
// Cancel any ongoing animation
titleAnimationId.value++;
if (titleIntervalId) {
clearInterval(titleIntervalId);
titleIntervalId = null;
}
aiGeneratedTitle.value = null;
displayedTitle.value = "";
isTypingTitle.value = false;
};
/**
* Reset typewriter animation state
*/
const resetTypewriterState = () => {
displayedStreamingContent.value = "";
if (typewriterAnimationId.value) {
cancelAnimationFrame(typewriterAnimationId.value);
typewriterAnimationId.value = null;
}
};
/**
* Animate text reveal with typewriter effect
* Skips animation for code blocks (reveals them instantly)
*/
const animateStreamingText = () => {
const target = currentTextSegment.value;
const current = displayedStreamingContent.value;
if (current.length >= target.length) {
// Caught up, stop animation
if (typewriterAnimationId.value) {
cancelAnimationFrame(typewriterAnimationId.value);
typewriterAnimationId.value = null;
}
return;
}
// Check if we're at the start of a code block - if so, skip to end of code block
const remaining = target.slice(current.length);
const codeBlockStart = remaining.match(/^```[\w]*/);
if (codeBlockStart) {
// Find the closing ``` and reveal entire code block instantly
const codeBlockEnd = remaining.indexOf("```", codeBlockStart[0].length);
if (codeBlockEnd !== -1) {
const endPos = codeBlockEnd + 3;
displayedStreamingContent.value = target.slice(
0,
current.length + endPos,
);
} else {
// Code block not complete yet, reveal opening and wait
displayedStreamingContent.value = target.slice(
0,
current.length + codeBlockStart[0].length,
);
}
} else {
// Regular text - reveal one character
displayedStreamingContent.value = target.slice(0, current.length + 1);
}
// Schedule next frame
typewriterAnimationId.value = requestAnimationFrame(() => {
setTimeout(animateStreamingText, TYPEWRITER_SPEED);
});
};
// 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
toast({
message: "Response generation stopped",
variant: "info",
});
// Update UI state to reflect cancellation
isLoading.value = false;
activeToolCall.value = null;
stopAnalyzingRotation();
// Immediately show all buffered text (like ChatGPT's "Stop generating")
displayedStreamingContent.value = currentTextSegment.value;
if (typewriterAnimationId.value) {
cancelAnimationFrame(typewriterAnimationId.value);
typewriterAnimationId.value = null;
}
// 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) {
// Update final text in contentBlocks to show all buffered content
if (lastMessage.contentBlocks) {
const lastBlock =
lastMessage.contentBlocks[
lastMessage.contentBlocks.length - 1
];
if (lastBlock && lastBlock.type === "text") {
lastBlock.text = currentTextSegment.value;
}
}
// Keep partial content but indicate it was cancelled
lastMessage.content += "\n\n_[Response stopped by user]_";
}
}
}
// Persist any tool calls that completed before the user hit Stop.
// During the tool phase (before the assistant streams text) completed
// steps sit in pendingToolCalls and aren't attached to a message yet,
// so without this they'd vanish on cancel. Runs after the partial-
// message cleanup above so the empty-assistant-message pop can't drop
// the message we attach them to.
if (pendingToolCalls.value.length) {
const lastMessage =
chatMessages.value[chatMessages.value.length - 1];
if (lastMessage && lastMessage.role === "assistant") {
if (!lastMessage.contentBlocks) lastMessage.contentBlocks = [];
// Tools ran before any text, so place them ahead of it.
lastMessage.contentBlocks.unshift(...pendingToolCalls.value);
} else {
const stoppedNote = "_[Response stopped by user]_";
chatMessages.value.push({
role: "assistant",
content: stoppedNote,
contentBlocks: [
...pendingToolCalls.value,
{ type: "text", text: stoppedNote },
],
});
}
pendingToolCalls.value = [];
}
// Reset streaming state
currentStreamingMessage.value = "";
currentTextSegment.value = "";
displayedStreamingContent.value = "";
// Save the current state including cancellation
await saveToHistory();
// Scroll to show the final state
await scrollToBottom();
}
};
// Process any pending chips that were queued before component was ready
const processPendingChips = () => {
if (pendingChips.value.length > 0) {
nextTick(() => {
if (
chatInput.value &&
typeof chatInput.value.insertChip === "function"
) {
// Focus input first to ensure cursor is positioned correctly
focusInput();
// Only clear if appendMode is false and there are no existing chips
// 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;
// Only clear if:
// 1. appendMode is false (user wants to replace content)
// 2. AND there are no existing chips
// 3. AND there is no existing text
if (!props.appendMode && !hasExistingChips && !hasExistingText) {
if (
chatInput.value &&
typeof chatInput.value.clear === "function"
) {
chatInput.value.clear();
}
inputMessage.value = "";
}
// Insert all pending chips at the cursor position
pendingChips.value.forEach((chip) => {
chatInput.value.insertChip(chip);
});
pendingChips.value = [];
}
});
}
};
// Helper to create a better preview of content
const createPreview = (content: string, maxLength: number = 40): string => {
// Clean up content
let preview = content.trim();
// Try to detect JSON and create a meaningful preview
try {
const parsed = JSON.parse(content);
if (typeof parsed === "object" && parsed !== null) {
// For objects, show first few keys
const keys = Object.keys(parsed);
if (keys.length > 0) {
const firstKeys = keys
.slice(0, 3)
.map((k) => {
const val = parsed[k];
if (typeof val === "string") {
const truncatedVal =
val.length > 8 ? val.substring(0, 8) + "..." : val;
return k + ': "' + truncatedVal + '"';
}
return k + ": " + String(val).substring(0, 8);
})
.join(", ");
const moreKeys = keys.length > 3 ? ", ..." : "";
preview = "{" + firstKeys + moreKeys + "}";
}
}
} catch {
// Not JSON, use plain text preview
// Replace newlines and multiple spaces with single space
preview = preview.replace(/\s+/g, " ");
}
// Truncate if still too long
if (preview.length > maxLength) {
preview = preview.substring(0, maxLength) + "...";
}
return preview;
};
watch(
() => props.aiChatInputContext,
(newAiChatInputContext: string) => {
if (newAiChatInputContext) {
// Create a reference chip from the context
const contextChip: ReferenceChip = {
id: `context-${Date.now()}`,
filename: "Log Entry",
preview: createPreview(newAiChatInputContext, 10),
fullContent: newAiChatInputContext,
charCount: newAiChatInputContext.length,
type: "context",
};
// Always queue the chip first for consistent behavior
pendingChips.value.push(contextChip);
// If component is ready, process immediately with proper timing
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);
});
}
// If component not ready, chips will be processed when componentReady becomes true
// No fallback text needed - avoids flickering when chat opens
}
},
);
// 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);
});
}
},
);
//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;
// --- Stream context: captured at call time ---
// When the user switches sessions mid-stream, chatMessages.value gets
// replaced with a new array. This captured reference keeps the stream
// writing to the ORIGINAL array so data isn't lost.
const msgs = chatMessages.value;
let ctxSessionId = currentSessionId.value;
let ctxChatId = currentChatId.value;
let ctxTitle: string | undefined = aiGeneratedTitle.value || undefined;
// Local streaming accumulators (synced to refs only when active)
let streamingMsg = currentStreamingMessage.value;
let textSegment = currentTextSegment.value;
const isActive = () => chatMessages.value === msgs;
const syncStreamingRefs = () => {
if (isActive()) {
currentStreamingMessage.value = streamingMsg;
currentTextSegment.value = textSegment;
}
};
// Context-aware save: uses captured metadata when detached
const saveCtx = async () => {
if (msgs.length === 0) return;
if (!ctxSessionId) {
ctxSessionId = getUUIDv7();
if (isActive()) currentSessionId.value = ctxSessionId;
}
const title = isActive()
? aiGeneratedTitle.value || undefined
: ctxTitle;
const chatId = isActive() ? currentChatId.value : ctxChatId;
const resultId = await dbSaveToHistory(
msgs,
ctxSessionId,
title,
chatId,
);
if (!chatId && resultId) {
if (isActive()) {
currentChatId.value = resultId;
// Carry the new-chat preference onto the chat id. Persist the actual
// value (ON by default) so an explicit user disable is honored.
autoNavigationPreferences.value.set(
resultId,
pendingAutoNavigation.value,
);
saveAutoNavigationPreferences();
} else {
ctxChatId = resultId;
}
}
};
let lastSaveTime = lastStreamingSaveTime.value;
const throttledSaveCtx = async (force = false) => {
const now = Date.now();
if (force || now - lastSaveTime >= STREAMING_SAVE_INTERVAL) {
lastSaveTime = now;
if (isActive()) lastStreamingSaveTime.value = now;
await saveCtx();
}
};
// Local finalizeTextBlock: operates on captured msgs, not chatMessages.value
const localFinalizeTextBlock = () => {
if (textSegment) {
const lm = msgs[msgs.length - 1];
if (lm && lm.role === "assistant" && lm.contentBlocks) {
const lb = lm.contentBlocks[lm.contentBlocks.length - 1];
if (lb && lb.type === "text") {
lb.text = textSegment;
}
}
}
if (isActive()) {
if (typewriterAnimationId.value) {
cancelAnimationFrame(typewriterAnimationId.value);
typewriterAnimationId.value = null;
}
displayedStreamingContent.value = "";
}
textSegment = "";
syncStreamingRefs();
};
// --- End stream context ---
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 title events - AI-generated chat title from first message
if (data && data.type === "title") {
ctxTitle = data.title;
if (isActive()) {
aiGeneratedTitle.value = data.title;
animateTitle(data.title);
}
continue;
}
// Handle confirmation_required events - add inline confirmation block in chat
if (data && data.type === "confirmation_required") {
localFinalizeTextBlock();
// When detached, auto-deny confirmations to unblock the stream
if (!isActive()) {
try {
const orgId =
store.state.selectedOrganization.identifier;
await fetch(
`${store.state.API_ENDPOINT}/api/${orgId}/ai/confirm/${ctxSessionId}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ approved: false }),
},
);
} catch (error) {
console.error(
"Error auto-denying confirmation for background stream:",
error,
);
}
continue;
}
// Check if this is a navigation action and auto navigation is enabled
if (
data.tool === "navigation_action" &&
isAutoNavigationEnabled.value
) {
// Auto-approve navigation without showing confirmation
try {
const orgId =
store.state.selectedOrganization.identifier;
await fetch(
`${store.state.API_ENDPOINT}/api/${orgId}/ai/confirm/${ctxSessionId}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ approved: true }),
},
);
} catch (error) {
console.error(
"Error auto-confirming navigation:",
error,
);
}
continue;
}
// data.message is always set by the backend:
// - Navigation: validated label (e.g. "View in Logs")
// - Other tools: "Confirm execution of {tool}?"
const confirmBlock: ContentBlock = {
type: "tool_call",
tool: data.tool,
message: activeToolCall.value?.message || data.message,
context: activeToolCall.value?.context || {},
call_id:
data.call_id ||
activeToolCall.value?.call_id ||
undefined,
pendingConfirmation: true,
confirmationMessage: data.message,
confirmationArgs: data.args || {},
};
activeToolCall.value = null;
let lastMessage = msgs[msgs.length - 1];
if (lastMessage && lastMessage.role === "assistant") {
if (!lastMessage.contentBlocks)
lastMessage.contentBlocks = [];
lastMessage.contentBlocks.push(confirmBlock);
} else {
msgs.push({
role: "assistant",
content: "",
contentBlocks: [
...pendingToolCalls.value,
confirmBlock,
],
});
pendingToolCalls.value = [];
}
pendingConfirmation.value = {
tool: data.tool,
args: data.args || {},
message:
data.message || `Confirm execution of ${data.tool}?`,
};
await scrollToBottom();
continue;
}
// 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,
call_id: activeToolCall.value.call_id,
};
let lastMessage = msgs[msgs.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
if (isActive()) {
activeToolCall.value = {
tool: data.tool,
message: data.message,
context: data.context || {},
call_id: data.call_id || undefined,
};
}
localFinalizeTextBlock();
if (isActive()) await scrollToBottom();
continue;
}
// Handle error events - display error message to user
if (data && data.type === "error") {
// Complete any active tool call first
let lastMessage = msgs[msgs.length - 1];
if (activeToolCall.value) {
const completedToolBlock: ContentBlock = {
type: "tool_call",
tool: activeToolCall.value.tool,
message: activeToolCall.value.message,
context: activeToolCall.value.context,
call_id: activeToolCall.value.call_id,
};
if (lastMessage && lastMessage.role === "assistant") {
if (!lastMessage.contentBlocks)
lastMessage.contentBlocks = [];
lastMessage.contentBlocks.push(completedToolBlock);
} else {
pendingToolCalls.value.push(completedToolBlock);
}
if (isActive()) activeToolCall.value = null;
}
// Format error message with suggestion if available
// Handle case where error/message might be an object instead of string
const rawError =
data.error ??
data.message ??
"An unexpected error occurred";
const errorText =
typeof rawError === "string"
? rawError
: JSON.stringify(rawError, null, 2);
// Check if this is an authorization/access error
const authErr = isAuthError(errorText, data.error_type);
let errorMessage = authErr
? UNAUTHORIZED_MESSAGE
: `Error: ${errorText}`;
if (data.suggestion && !authErr) {
errorMessage += `\n\n${data.suggestion}`;
}
// Get or create assistant message for error (reuse lastMessage)
lastMessage = msgs[msgs.length - 1];
if (!lastMessage || lastMessage.role !== "assistant") {
msgs.push({
role: "assistant",
content: errorMessage,
contentBlocks: [
...pendingToolCalls.value,
{ type: "text", text: errorMessage },
],
});
pendingToolCalls.value = [];
} else {
// Append error to existing message
if (lastMessage.content) {
lastMessage.content += "\n\n" + errorMessage;
} else {
lastMessage.content = errorMessage;
}
if (!lastMessage.contentBlocks) {
lastMessage.contentBlocks = [];
}
lastMessage.contentBlocks.push({
type: "text",
text: errorMessage,
});
// Clear pending tool calls to avoid leaking into later messages
pendingToolCalls.value = [];
}
// Reset streaming state
textSegment = "";
syncStreamingRefs();
// Save error message to history
await saveCtx();
if (isActive()) await scrollToBottom();
// Stop processing further as error occurred
return;
}
// Handle complete events - complete any active tool call
if (data && data.type === "complete") {
// Capture trace_id for feedback correlation
if (data.trace_id && isActive()) {
lastTraceId.value = data.trace_id;
}
if (activeToolCall.value) {
const completedToolBlock: ContentBlock = {
type: "tool_call",
tool: activeToolCall.value.tool,
message: activeToolCall.value.message,
context: activeToolCall.value.context,
call_id: activeToolCall.value.call_id,
};
let lastMessage = msgs[msgs.length - 1];
if (lastMessage && lastMessage.role === "assistant") {
if (!lastMessage.contentBlocks)
lastMessage.contentBlocks = [];
lastMessage.contentBlocks.push(completedToolBlock);
} else {
pendingToolCalls.value.push(completedToolBlock);
}
if (isActive()) activeToolCall.value = null;
}
// Flush any tool calls that completed before the assistant
// produced text. With opencode, action-only turns (dashboard/
// alert creation, navigation) finish without any `message`
// event, so these blocks would otherwise stay stranded in
// pendingToolCalls and never render — the user sees progress
// "flash and disappear".
if (pendingToolCalls.value.length) {
let lastMessage = msgs[msgs.length - 1];
if (lastMessage && lastMessage.role === "assistant") {
if (!lastMessage.contentBlocks)
lastMessage.contentBlocks = [];
lastMessage.contentBlocks.push(
...pendingToolCalls.value,
);
} else {
msgs.push({
role: "assistant",
content: "",
contentBlocks: [...pendingToolCalls.value],
});
}
pendingToolCalls.value = [];
if (isActive()) await throttledSaveCtx(true);
}
continue;
}
// Handle tool_result events - enrich tool call with result data
if (data && data.type === "tool_result") {
const resultData = {
success: data.success !== false,
resultMessage: data.message || "",
summary: data.summary || undefined,
errorType: data.error_type || undefined,
suggestion: data.suggestion || undefined,
details: data.details || undefined,
response: data.response || undefined,
};
// Generate navigation from tool result if applicable
let navigationAction: NavigationAction | null = null;
if (data.success !== false && data.call_args) {
navigationAction = generateNavigationFromToolResult(
data.tool,
data.call_args,
data,
);
}
// Emit dashboard event only when stream is active (foreground)
if (data.success !== false && isActive()) {
const resolvedToolName =
data.tool && data.tool !== "tools_call"
? data.tool
: "";
const callArgs = data.call_args || {};
const dashboardEventType =
getDashboardEventType(resolvedToolName);
if (dashboardEventType) {
const dashboardId =
callArgs.dashboard_id ||
callArgs.args?.dashboard_id ||
callArgs.request_body?.dashboard_id;
if (dashboardId) {
const folderId =
callArgs.folder ||
callArgs.args?.folder ||
callArgs.request_body?.folder;
emitDashboardEvent({
type: dashboardEventType,
dashboardId,
folderId,
});
} else {
console.warn(
`[O2AIChat] Could not extract dashboardId from call_args for tool "${resolvedToolName}". Skipping dashboard event.`,
callArgs,
);
}
}
}
// Match by call_id if available, fall back to tool name
const matchesActiveToolCall =
activeToolCall.value &&
((data.call_id &&
activeToolCall.value.call_id === data.call_id) ||
(!data.call_id &&
activeToolCall.value.tool === data.tool));
// If active tool call matches, complete it with result data
if (matchesActiveToolCall) {
const completedToolBlock: ContentBlock = {
type: "tool_call",
tool: activeToolCall.value!.tool,
message: activeToolCall.value!.message,
context: activeToolCall.value!.context,
call_id: activeToolCall.value!.call_id,
...resultData,
...(navigationAction && { navigationAction }),
};
let lastMessage = msgs[msgs.length - 1];
if (lastMessage && lastMessage.role === "assistant") {
if (!lastMessage.contentBlocks)
lastMessage.contentBlocks = [];
lastMessage.contentBlocks.push(completedToolBlock);
} else {
pendingToolCalls.value.push(completedToolBlock);
}
if (isActive()) activeToolCall.value = null;
} else {
// Tool was already completed — retroactively enrich the matching block
const lastMessage = msgs[msgs.length - 1];
if (lastMessage && lastMessage.contentBlocks) {
for (
let i = lastMessage.contentBlocks.length - 1;
i >= 0;
i--
) {
const block = lastMessage.contentBlocks[i];
const blockMatches = data.call_id
? block.call_id === data.call_id
: block.type === "tool_call" &&
block.tool === data.tool &&
block.success === undefined;
if (blockMatches) {
Object.assign(block, resultData);
if (navigationAction) {
block.navigationAction = navigationAction;
}
break;
}
}
}
// Also check pending tool calls
for (
let i = pendingToolCalls.value.length - 1;
i >= 0;
i--
) {
const block = pendingToolCalls.value[i];
const blockMatches = data.call_id
? block.call_id === data.call_id
: block.type === "tool_call" &&
block.tool === data.tool &&
block.success === undefined;
if (blockMatches) {
Object.assign(block, resultData);
break;
}
}
}
if (isActive()) await scrollToBottom();
continue;
}
// Handle navigation_action events - check auto navigation setting
// (clickable buttons on tool results are generated by frontend from tool_result data)
if (data && data.type === "navigation_action") {
localFinalizeTextBlock();
// Skip navigation when stream is detached (background)
if (!isActive()) continue;
const navAction: NavigationAction = {
resource_type: data.resource_type,
action: data.action,
label: data.label,
target: data.target,
};
// Check if auto navigation is enabled
if (isAutoNavigationEnabled.value) {
// Auto-navigate without confirmation
await handleNavigationAction(navAction);
} else {
// Show confirmation dialog
// Store the navigation action for later use
const confirmBlock: ContentBlock = {
type: "tool_call",
tool: "navigation_action",
message: data.label || "Navigate",
context: { navAction },
pendingConfirmation: true,
confirmationMessage: data.label,
confirmationArgs: data.target || {},
};
activeToolCall.value = null;
let lastMessage = msgs[msgs.length - 1];
if (lastMessage && lastMessage.role === "assistant") {
if (!lastMessage.contentBlocks)
lastMessage.contentBlocks = [];
lastMessage.contentBlocks.push(confirmBlock);
} else {
msgs.push({
role: "assistant",
content: "",
contentBlocks: [
...pendingToolCalls.value,
confirmBlock,
],
});
pendingToolCalls.value = [];
}
// Set pending confirmation with navigation action data
pendingConfirmation.value = {
tool: "navigation_action",
args: data.target || {},
message: data.label || "Navigate",
};
// Store the navigation action for later execution
pendingConfirmation.value.navAction = navAction;
await scrollToBottom();
}
continue;
}
// Handle error events - stream-level errors
if (data && data.type === "error") {
// Complete any active tool call as failed
if (activeToolCall.value) {
const failedToolBlock: ContentBlock = {
type: "tool_call",
tool: activeToolCall.value.tool,
message: activeToolCall.value.message,
context: activeToolCall.value.context,
call_id: activeToolCall.value.call_id,
success: false,
resultMessage: data.message || "Tool execution failed",
errorType: data.error_type || undefined,
suggestion: data.suggestion || undefined,
};
let lastMessage = msgs[msgs.length - 1];
if (lastMessage && lastMessage.role === "assistant") {
if (!lastMessage.contentBlocks)
lastMessage.contentBlocks = [];
lastMessage.contentBlocks.push(failedToolBlock);
} else {
pendingToolCalls.value.push(failedToolBlock);
}
if (isActive()) activeToolCall.value = null;
}
// Add inline error block
const rawErrorMessage =
data.message || data.error || "An error occurred";
const authErr = isAuthError(
rawErrorMessage,
data.error_type,
);
const errorBlock: ContentBlock = {
type: "error",
message: authErr
? UNAUTHORIZED_MESSAGE
: rawErrorMessage,
errorType: data.error_type || undefined,
suggestion: authErr
? undefined
: data.suggestion || undefined,
recoverable: authErr
? false
: data.recoverable ?? undefined,
};
let lastMessage = msgs[msgs.length - 1];
if (lastMessage && lastMessage.role === "assistant") {
if (!lastMessage.contentBlocks)
lastMessage.contentBlocks = [];
lastMessage.contentBlocks.push(errorBlock);
} else {
msgs.push({
role: "assistant",
content: "",
contentBlocks: [...pendingToolCalls.value, errorBlock],
});
pendingToolCalls.value = [];
}
messageComplete = true;
if (isActive()) await scrollToBottom();
continue;
}
// Handle streamed deltas and full/legacy 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,
call_id: activeToolCall.value.call_id,
};
let lastMessage = msgs[msgs.length - 1];
if (lastMessage && lastMessage.role === "assistant") {
if (!lastMessage.contentBlocks)
lastMessage.contentBlocks = [];
lastMessage.contentBlocks.push(completedToolBlock);
} else {
pendingToolCalls.value.push(completedToolBlock);
}
if (isActive()) activeToolCall.value = null;
}
const isMessageDelta = data.type === "message_delta";
// Format code blocks with proper line breaks for full/legacy
// messages. Deltas must be appended exactly as received.
let content = data.content;
if (!isMessageDelta) {
content = content.replace(
/```(\w*)\s*([^`])/g,
"```$1\n$2",
);
content = content.replace(/([^`])\s*```/g, "$1\n```");
}
if (!isMessageDelta) {
// Add newline separator if starting a new text segment after tool call
if (streamingMsg && textSegment === "") {
streamingMsg += "\n\n";
}
// Add newline between consecutive full/legacy message events if needed
else if (
streamingMsg &&
!streamingMsg.endsWith("\n") &&
!content.startsWith("\n")
) {
streamingMsg += "\n\n";
textSegment += "\n\n";
}
}
// Accumulate to both total content and current segment
streamingMsg += content;
textSegment += content;
syncStreamingRefs();
// Start typewriter animation if not already running
if (isActive() && !typewriterAnimationId.value) {
animateStreamingText();
}
// Get or create assistant message
let lastMessage = msgs[msgs.length - 1];
if (!lastMessage || lastMessage.role !== "assistant") {
// Create new assistant message with pending tool calls + text
msgs.push({
role: "assistant",
content: streamingMsg,
contentBlocks: [
...pendingToolCalls.value,
{ type: "text", text: textSegment },
],
});
pendingToolCalls.value = []; // Clear pending
// Save immediately when assistant message is first created to prevent data loss on reload
await throttledSaveCtx(true);
} else {
// Update existing assistant message's total content
lastMessage.content = streamingMsg;
// 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 = textSegment;
} else {
// Add new text block (after tool call - new segment)
lastMessage.contentBlocks.push({
type: "text",
text: textSegment,
});
}
// Throttled save during streaming to preserve progress
await throttledSaveCtx();
}
messageComplete = true;
if (isActive()) 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 title events
if (data && data.type === "title") {
ctxTitle = data.title;
if (isActive()) {
aiGeneratedTitle.value = data.title;
animateTitle(data.title);
}
continue;
}
// Handle tool_call events
if (data && data.type === "tool_call") {
if (activeToolCall.value) {
const completedToolBlock: ContentBlock = {
type: "tool_call",
tool: activeToolCall.value.tool,
message: activeToolCall.value.message,
context: activeToolCall.value.context,
call_id: activeToolCall.value.call_id,
};
let lastMessage = msgs[msgs.length - 1];
if (lastMessage && lastMessage.role === "assistant") {
if (!lastMessage.contentBlocks)
lastMessage.contentBlocks = [];
lastMessage.contentBlocks.push(completedToolBlock);
} else {
pendingToolCalls.value.push(completedToolBlock);
}
}
if (isActive()) {
activeToolCall.value = {
tool: data.tool,
message: data.message,
context: data.context || {},
call_id: data.call_id || undefined,
};
}
localFinalizeTextBlock();
continue;
}
// Handle error events
if (data && data.type === "error") {
let lastMessage = msgs[msgs.length - 1];
if (activeToolCall.value) {
const completedToolBlock: ContentBlock = {
type: "tool_call",
tool: activeToolCall.value.tool,
message: activeToolCall.value.message,
context: activeToolCall.value.context,
call_id: activeToolCall.value.call_id,
};
if (lastMessage && lastMessage.role === "assistant") {
if (!lastMessage.contentBlocks)
lastMessage.contentBlocks = [];
lastMessage.contentBlocks.push(completedToolBlock);
} else {
pendingToolCalls.value.push(completedToolBlock);
}
if (isActive()) activeToolCall.value = null;
}
const rawError =
data.error ??
data.message ??
"An unexpected error occurred";
const errorText =
typeof rawError === "string"
? rawError
: JSON.stringify(rawError, null, 2);
// Check if this is an authorization/access error
const authErr = isAuthError(errorText, data.error_type);
let errorMessage = authErr
? UNAUTHORIZED_MESSAGE
: `Error: ${errorText}`;
if (data.suggestion && !authErr) {
errorMessage += `\n\n${data.suggestion}`;
}
lastMessage = msgs[msgs.length - 1];
if (!lastMessage || lastMessage.role !== "assistant") {
msgs.push({
role: "assistant",
content: errorMessage,
contentBlocks: [
...pendingToolCalls.value,
{ type: "text", text: errorMessage },
],
});
pendingToolCalls.value = [];
} else {
if (lastMessage.content) {
lastMessage.content += "\n\n" + errorMessage;
} else {
lastMessage.content = errorMessage;
}
if (!lastMessage.contentBlocks) {
lastMessage.contentBlocks = [];
}
lastMessage.contentBlocks.push({
type: "text",
text: errorMessage,
});
pendingToolCalls.value = [];
}
textSegment = "";
syncStreamingRefs();
await saveCtx();
if (isActive()) await scrollToBottom();
return;
}
// Handle complete events
if (data && data.type === "complete") {
if (data.trace_id && isActive()) {
lastTraceId.value = data.trace_id;
}
if (activeToolCall.value) {
const completedToolBlock: ContentBlock = {
type: "tool_call",
tool: activeToolCall.value.tool,
message: activeToolCall.value.message,
context: activeToolCall.value.context,
call_id: activeToolCall.value.call_id,
};
let lastMessage = msgs[msgs.length - 1];
if (lastMessage && lastMessage.role === "assistant") {
if (!lastMessage.contentBlocks)
lastMessage.contentBlocks = [];
lastMessage.contentBlocks.push(completedToolBlock);
} else {
pendingToolCalls.value.push(completedToolBlock);
}
if (isActive()) activeToolCall.value = null;
}
continue;
}
// Handle tool_result events
if (data && data.type === "tool_result") {
const resultData = {
success: data.success !== false,
resultMessage: data.message || "",
summary: data.summary || undefined,
errorType: data.error_type || undefined,
suggestion: data.suggestion || undefined,
details: data.details || undefined,
response: data.response || undefined,
};
const matchesActive =
activeToolCall.value &&
((data.call_id &&
activeToolCall.value.call_id === data.call_id) ||
(!data.call_id &&
activeToolCall.value.tool === data.tool));
if (matchesActive) {
const completedToolBlock: ContentBlock = {
type: "tool_call",
tool: activeToolCall.value!.tool,
message: activeToolCall.value!.message,
context: activeToolCall.value!.context,
call_id: activeToolCall.value!.call_id,
...resultData,
};
let lastMessage = msgs[msgs.length - 1];
if (lastMessage && lastMessage.role === "assistant") {
if (!lastMessage.contentBlocks)
lastMessage.contentBlocks = [];
lastMessage.contentBlocks.push(completedToolBlock);
} else {
pendingToolCalls.value.push(completedToolBlock);
}
if (isActive()) activeToolCall.value = null;
} else {
const lastMessage = msgs[msgs.length - 1];
if (lastMessage && lastMessage.contentBlocks) {
for (
let i = lastMessage.contentBlocks.length - 1;
i >= 0;
i--
) {
const block = lastMessage.contentBlocks[i];
const blockMatches = data.call_id
? block.call_id === data.call_id
: block.type === "tool_call" &&
block.tool === data.tool &&
block.success === undefined;
if (blockMatches) {
Object.assign(block, resultData);
break;
}
}
}
for (
let i = pendingToolCalls.value.length - 1;
i >= 0;
i--
) {
const block = pendingToolCalls.value[i];
const blockMatches = data.call_id
? block.call_id === data.call_id
: block.type === "tool_call" &&
block.tool === data.tool &&
block.success === undefined;
if (blockMatches) {
Object.assign(block, resultData);
break;
}
}
}
continue;
}
// Handle error events - stream-level errors
if (data && data.type === "error") {
if (activeToolCall.value) {
const failedToolBlock: ContentBlock = {
type: "tool_call",
tool: activeToolCall.value.tool,
message: activeToolCall.value.message,
context: activeToolCall.value.context,
call_id: activeToolCall.value.call_id,
success: false,
resultMessage: data.message || "Tool execution failed",
errorType: data.error_type || undefined,
suggestion: data.suggestion || undefined,
};
let lastMessage = msgs[msgs.length - 1];
if (lastMessage && lastMessage.role === "assistant") {
if (!lastMessage.contentBlocks)
lastMessage.contentBlocks = [];
lastMessage.contentBlocks.push(failedToolBlock);
} else {
pendingToolCalls.value.push(failedToolBlock);
}
if (isActive()) activeToolCall.value = null;
}
const rawErrorMessage =
data.message || data.error || "An error occurred";
const authErr = isAuthError(
rawErrorMessage,
data.error_type,
);
const errorBlock: ContentBlock = {
type: "error",
message: authErr
? UNAUTHORIZED_MESSAGE
: rawErrorMessage,
errorType: data.error_type || undefined,
suggestion: authErr
? undefined
: data.suggestion || undefined,
recoverable: authErr
? false
: data.recoverable ?? undefined,
};
let lastMessage = msgs[msgs.length - 1];
if (lastMessage && lastMessage.role === "assistant") {
if (!lastMessage.contentBlocks)
lastMessage.contentBlocks = [];
lastMessage.contentBlocks.push(errorBlock);
} else {
msgs.push({
role: "assistant",
content: "",
contentBlocks: [...pendingToolCalls.value, errorBlock],
});
pendingToolCalls.value = [];
}
messageComplete = true;
continue;
}
// Handle streamed deltas and full/legacy message content.
if (data && typeof data.content === "string") {
if (activeToolCall.value) {
const completedToolBlock: ContentBlock = {
type: "tool_call",
tool: activeToolCall.value.tool,
message: activeToolCall.value.message,
context: activeToolCall.value.context,
call_id: activeToolCall.value.call_id,
};
let lastMessage = msgs[msgs.length - 1];
if (lastMessage && lastMessage.role === "assistant") {
if (!lastMessage.contentBlocks)
lastMessage.contentBlocks = [];
lastMessage.contentBlocks.push(completedToolBlock);
} else {
pendingToolCalls.value.push(completedToolBlock);
}
if (isActive()) activeToolCall.value = null;
}
const isMessageDelta = data.type === "message_delta";
let content = data.content;
if (!isMessageDelta) {
content = content.replace(/```(\w*)\s*([^`])/g, "```$1\n$2");
content = content.replace(/([^`])\s*```/g, "$1\n```");
}
if (!isMessageDelta) {
if (streamingMsg && textSegment === "") {
streamingMsg += "\n\n";
} else if (
streamingMsg &&
!streamingMsg.endsWith("\n") &&
!content.startsWith("\n")
) {
streamingMsg += "\n\n";
textSegment += "\n\n";
}
}
streamingMsg += content;
textSegment += content;
syncStreamingRefs();
if (isActive() && !typewriterAnimationId.value) {
animateStreamingText();
}
let lastMessage = msgs[msgs.length - 1];
if (!lastMessage || lastMessage.role !== "assistant") {
msgs.push({
role: "assistant",
content: streamingMsg,
contentBlocks: [
...pendingToolCalls.value,
{ type: "text", text: textSegment },
],
});
pendingToolCalls.value = [];
await throttledSaveCtx(true);
} else {
lastMessage.content = streamingMsg;
if (!lastMessage.contentBlocks) {
lastMessage.contentBlocks = [];
}
const lastBlock =
lastMessage.contentBlocks[
lastMessage.contentBlocks.length - 1
];
if (lastBlock && lastBlock.type === "text") {
lastBlock.text = textSegment;
} else {
lastMessage.contentBlocks.push({
type: "text",
text: textSegment,
});
}
await throttledSaveCtx();
}
messageComplete = true;
}
} catch (e) {
console.debug("Error processing remaining buffer:", e);
continue;
}
}
}
}
// If we completed a message, save to history
if (messageComplete) {
if (isActive()) {
// Immediately show all remaining text and stop typewriter animation
displayedStreamingContent.value = textSegment;
if (typewriterAnimationId.value) {
cancelAnimationFrame(typewriterAnimationId.value);
typewriterAnimationId.value = null;
}
}
// Update final text in contentBlocks
const lastMessage = msgs[msgs.length - 1];
if (
lastMessage &&
lastMessage.role === "assistant" &&
lastMessage.contentBlocks
) {
const lastBlock =
lastMessage.contentBlocks[lastMessage.contentBlocks.length - 1];
if (lastBlock && lastBlock.type === "text") {
lastBlock.text = textSegment;
}
}
await saveCtx();
}
} 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
// Do a final save for background streams before exiting
if (!isActive() && msgs.length > 0 && ctxSessionId) {
await dbSaveToHistory(msgs, ctxSessionId, ctxTitle, ctxChatId);
}
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) {
saveHistoryLoading.value = false;
return;
}
try {
// Generate session ID if not already set for this chat
if (!currentSessionId.value) {
currentSessionId.value = getUUIDv7();
}
// Prefer AI-generated title, fallback to default
const title = aiGeneratedTitle.value || undefined;
// Save using the composable
const chatId = await dbSaveToHistory(
chatMessages.value,
currentSessionId.value,
title,
currentChatId.value,
);
// Update current chat ID if this is a new chat
if (!currentChatId.value && chatId) {
currentChatId.value = chatId;
// Apply pending auto navigation preference to the new chat. Persist the
// actual value (ON by default) so an explicit user disable is honored.
autoNavigationPreferences.value.set(
chatId,
pendingAutoNavigation.value,
);
saveAutoNavigationPreferences();
}
} catch (error) {
console.error("Error saving chat history:", error);
} finally {
saveHistoryLoading.value = false;
}
};
/**
* Throttled save for streaming - saves at most every STREAMING_SAVE_INTERVAL ms
* This prevents data loss if the user reloads the page during streaming
* @param force - If true, saves immediately regardless of throttle (used for first assistant message)
*/
const throttledStreamingSave = async (force: boolean = false) => {
const now = Date.now();
if (
force ||
now - lastStreamingSaveTime.value >= STREAMING_SAVE_INTERVAL
) {
lastStreamingSaveTime.value = now;
await saveToHistory();
}
};
const loadHistory = async () => {
try {
// Load history using the composable (automatically prunes to 100 items)
const history = await dbLoadHistory();
chatHistory.value = history;
return chatHistory.value;
} catch (error) {
console.error("Error loading chat history:", error);
return [];
}
};
/**
* Detach the current streaming request so it continues in the background.
* processStream's captured context (msgs) keeps writing to the old array
* while we clear the UI for a new session. When the stream completes,
* processStream saves to IndexedDB via saveCtx().
*/
const detachCurrentStream = () => {
if (!currentAbortController.value) return;
// Move controller to background set so onUnmounted can clean it up
// and enforce the max background stream limit.
if (backgroundStreams.size >= MAX_BACKGROUND_STREAMS) {
// Abort the oldest background stream to stay within limits
const oldest = backgroundStreams.values().next().value;
if (oldest) {
oldest.abort();
backgroundStreams.delete(oldest);
}
}
const detachedController = currentAbortController.value;
backgroundStreams.add(detachedController);
currentAbortController.value = null;
// Register for re-attachment: when user navigates back to this session,
// loadChat swaps chatMessages.value to this live array so the UI resumes.
if (currentSessionId.value) {
backgroundStreamMap.set(currentSessionId.value, {
msgs: chatMessages.value,
controller: detachedController,
chatId: currentChatId.value,
});
}
// Clean up UI state — processStream continues silently in background
isLoading.value = false;
activeToolCall.value = null;
stopAnalyzingRotation();
if (typewriterAnimationId.value) {
cancelAnimationFrame(typewriterAnimationId.value);
typewriterAnimationId.value = null;
}
currentStreamingMessage.value = "";
currentTextSegment.value = "";
displayedStreamingContent.value = "";
};
const toggleExpand = () => {
if (!store.state.isAiChatEnabled) {
// Closed → Open inline sidebar
store.dispatch("setIsAiChatEnabled", true);
store.dispatch("setIsAiChatExpanded", false);
} else if (!store.state.isAiChatExpanded) {
// Inline sidebar → Expanded overlay
store.dispatch("setIsAiChatExpanded", true);
} else {
// Expanded overlay → Back to inline sidebar
store.dispatch("setIsAiChatExpanded", false);
}
window.dispatchEvent(new Event("resize"));
};
useShortcuts([
{
key: 'escape',
description: 'Close AI chat',
handler: () => {
if (store.state.isAiChatEnabled) {
store.dispatch('setIsAiChatEnabled', false);
store.dispatch('setIsAiChatExpanded', false);
window.dispatchEvent(new Event('resize'));
}
},
},
{
key: 'ctrl+b',
description: 'Expand/collapse AI chat',
handler: toggleExpand,
},
{
key: 'meta+b',
description: 'Expand/collapse AI chat',
handler: toggleExpand,
},
]);
const addNewChat = () => {
detachCurrentStream();
chatMessages.value = [];
currentChatId.value = null;
currentSessionId.value = null; // Will be generated on first save
lastTraceId.value = null; // Reset trace correlation for new chat
showHistory.value = false;
currentChatTimestamp.value = null;
shouldAutoScroll.value = true; // Reset auto-scroll for new chat
resetTitleState(); // Clear AI-generated title for new chat
resetTypewriterState(); // Clear typewriter animation state for new chat
pendingAutoNavigation.value = true; // Auto navigation is ON by default for new chats
showScrollToBottom.value = false; // Reset scroll-to-bottom button for new chat
store.dispatch("setCurrentChatTimestamp", null);
store.dispatch("setChatUpdated", true);
};
const openHistory = async () => {
showHistory.value = true;
await loadHistory();
};
const openEditTitleDialog = () => {
editingTitle.value = displayedTitle.value || "";
showEditTitleDialog.value = true;
};
const saveEditedTitle = async () => {
if (!currentChatId.value || !editingTitle.value.trim()) {
showEditTitleDialog.value = false;
return;
}
try {
// Update title using the composable
const success = await dbUpdateChatTitle(
currentChatId.value,
editingTitle.value.trim(),
);
if (success) {
// Update the displayed title
displayedTitle.value = editingTitle.value.trim();
aiGeneratedTitle.value = editingTitle.value.trim();
// Reload history to reflect changes
loadHistory();
}
} catch (error) {
console.error("Error updating chat title:", error);
} finally {
showEditTitleDialog.value = false;
}
};
const deleteChat = (chatId: number) => {
chatToDelete.value = chatId;
showDeleteChatConfirmDialog.value = true;
};
const confirmDeleteChat = async () => {
if (!chatToDelete.value) return;
try {
// Delete chat using the composable
const success = await dbDeleteChatById(chatToDelete.value);
if (success) {
// If the deleted chat is the current one, reset to new chat
if (currentChatId.value === chatToDelete.value) {
addNewChat();
}
// Reload history to reflect changes
loadHistory();
}
} catch (error) {
console.error("Error deleting chat:", error);
} finally {
// Reset state
chatToDelete.value = null;
showDeleteChatConfirmDialog.value = false;
}
};
const clearAllConversations = () => {
showClearAllConfirmDialog.value = true;
};
/** Resolve the pendingConfirmation block — mark as success or failure */
const resolveConfirmationBlock = (approved: boolean) => {
for (const msg of chatMessages.value) {
if (msg.contentBlocks) {
for (const block of msg.contentBlocks) {
if (block.pendingConfirmation) {
block.pendingConfirmation = false;
if (!approved) {
block.success = false;
block.resultMessage = "Action cancelled by user";
}
return;
}
}
}
}
};
const handleToolConfirm = async () => {
resolveConfirmationBlock(true);
// Check if this is a navigation action
if (pendingConfirmation.value?.tool === "navigation_action") {
const navAction = pendingConfirmation.value?.navAction;
if (navAction) {
await handleNavigationAction(navAction);
}
pendingConfirmation.value = null;
return;
}
if (!currentSessionId.value) return;
try {
const orgId = store.state.selectedOrganization.identifier;
await fetch(
`${store.state.API_ENDPOINT}/api/${orgId}/ai/confirm/${currentSessionId.value}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ approved: true }),
},
);
} catch (error) {
console.error("Error confirming action:", error);
}
pendingConfirmation.value = null;
};
const handleToolCancel = async () => {
resolveConfirmationBlock(false);
// Check if this is a navigation action
if (pendingConfirmation.value?.tool === "navigation_action") {
// Just clear the confirmation, don't navigate
pendingConfirmation.value = null;
return;
}
if (!currentSessionId.value) return;
try {
const orgId = store.state.selectedOrganization.identifier;
await fetch(
`${store.state.API_ENDPOINT}/api/${orgId}/ai/confirm/${currentSessionId.value}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ approved: false }),
},
);
} catch (error) {
console.error("Error cancelling action:", error);
}
pendingConfirmation.value = null;
};
const handleToolAlwaysConfirm = async () => {
// Enable auto navigation for this chat
isAutoNavigationEnabled.value = true;
// Then proceed with confirmation
resolveConfirmationBlock(true);
// Check if this is a navigation action
if (pendingConfirmation.value?.tool === "navigation_action") {
const navAction = pendingConfirmation.value?.navAction;
if (navAction) {
await handleNavigationAction(navAction);
}
pendingConfirmation.value = null;
return;
}
if (!currentSessionId.value) return;
try {
const orgId = store.state.selectedOrganization.identifier;
await fetch(
`${store.state.API_ENDPOINT}/api/${orgId}/ai/confirm/${currentSessionId.value}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ approved: true }),
},
);
} catch (error) {
console.error("Error confirming action:", error);
}
pendingConfirmation.value = null;
};
// Generate navigation action from tool result data (generic, pattern-based)
//
// Expected backend response format:
// - Search tools: Must have SQL query in callArgs.request_body.query.sql
// - Create/Get/Update/Delete tools: Must return {resource}_id in response
// Examples: alert_id, dashboard_id, pipeline_id, stream_id, etc.
// Optional fields: name, folder (will use 'default' if not provided)
const generateNavigationFromToolResult = (
toolName: string,
callArgs: any,
responseBody: any,
): NavigationAction | null => {
if (!callArgs) {
return null;
}
// Pattern 1: Search tools (has SQL query) → load_query navigation
const requestBody = callArgs.request_body || {};
const query = requestBody.query || {};
const sql = query.sql || "";
if (sql) {
const streamType = callArgs.stream_type || "logs";
let streamName = callArgs.stream_name || "";
// If stream_name is not in callArgs, try to extract it from the SQL query
if (!streamName) {
// Extract stream name from FROM clause (handles quoted and unquoted table names)
const fromMatch = sql.match(/FROM\s+["']?([^"'\s,()]+)["']?/i);
if (fromMatch) {
streamName = fromMatch[1];
}
}
const vrlFunction =
query.functionContent ||
requestBody.function ||
requestBody.functionContent;
// Don't generate navigation if time range is missing
if (query.start_time === undefined || query.end_time === undefined) {
return null;
}
// Don't generate navigation if stream name is still missing
if (!streamName) {
return null;
}
const target: any = {
query: sql,
sql_mode: true,
from: query.start_time,
to: query.end_time,
stream: streamName.split(","),
};
if (vrlFunction) {
target.functionContent = vrlFunction;
}
return {
resource_type: streamType,
action: "load_query",
label: `View in ${streamType.charAt(0).toUpperCase() + streamType.slice(1)}`,
target,
};
}
// Pattern 2: Create/Get tools (has ID) → navigate_direct
// Extract resource type from tool name (CreateAlert → alert, GetDashboard → dashboard, createPipeline → pipeline)
const resourceTypeMatch = toolName.match(
/^(create|get|update|delete)(.+)$/i,
);
if (!resourceTypeMatch) return null;
const resourceType = resourceTypeMatch[2].toLowerCase(); // Alert → alert, Dashboard → dashboard, Pipeline → pipeline
// Parse response data - it might be in different formats
let parsedResponse: any = {};
if (responseBody) {
// If responseBody has a 'response' field (from SRE agent tool_result event)
if (responseBody.response) {
let responseData = responseBody.response;
// If response is a JSON string, parse it
if (typeof responseData === "string") {
try {
responseData = JSON.parse(responseData);
} catch (e) {
console.warn("[Navigation] Failed to parse response string:", e);
}
}
// Extract from versioned response (v8, v7, etc.) for dashboards
if (typeof responseData === "object" && responseData !== null) {
parsedResponse =
responseData.v8 ||
responseData.v7 ||
responseData.v6 ||
responseData.v5 ||
responseData;
} else {
parsedResponse = responseData;
}
}
// If responseBody has 'content' array (MCP format)
else if (
responseBody.content &&
Array.isArray(responseBody.content) &&
responseBody.content[0]?.text
) {
try {
const textContent = responseBody.content[0].text;
const parsed = JSON.parse(textContent);
// Extract from versioned response (v8, v7, etc.)
parsedResponse =
parsed.v8 || parsed.v7 || parsed.v6 || parsed.v5 || parsed;
} catch (e) {
console.warn("[Navigation] Failed to parse content text:", e);
}
}
// Otherwise use responseBody as-is
else {
parsedResponse = responseBody;
}
}
// Merge data from parsed response, call args, and call args request_body for ID/field lookup
const requestBodyFromArgs = (callArgs || {}).request_body || {};
const data = {
...parsedResponse,
...(callArgs || {}),
...requestBodyFromArgs,
};
// Standard pattern: {resource}_id (e.g., alert_id, dashboard_id, pipeline_id)
const resourceIdField = `${resourceType}_id`;
let resourceId = data[resourceIdField] || data.id;
// Also check camelCase variants (dashboardId, alertId)
if (!resourceId) {
const camelCaseField = resourceType + "Id";
resourceId = data[camelCaseField];
}
if (!resourceId) {
return null;
}
// Build target with consistent {resource}_id pattern
const target: any = {
[resourceIdField]: resourceId, // e.g., alert_id, dashboard_id, pipeline_id
};
// Add name if available (required for some resources like alerts)
const name = data.name;
if (name) {
target.name = name;
}
// Add folder if available (default to 'default' for resources that use folders)
const folder = data.folder;
if (folder || resourceType === "alert" || resourceType === "dashboard") {
target.folder = folder || "default";
}
return {
resource_type: resourceType,
action: "navigate_direct",
label: `View ${resourceType.charAt(0).toUpperCase() + resourceType.slice(1)}`,
target,
};
};
const handleNavigationAction = async (action: NavigationAction) => {
// Helper to encode strings for URL (same as search history)
const encodeForUrl = (str: string) =>
btoa(unescape(encodeURIComponent(str)));
// Extract page name for success message
let pageName = action.label || "";
if (!pageName) {
// Fallback: use target name or resource type
pageName =
action.target.name ||
action.resource_type.charAt(0).toUpperCase() +
action.resource_type.slice(1);
}
// Perform navigation FIRST
if (action.action === "load_query") {
const targetPath = `/${action.resource_type}`;
const target = action.target;
// Build query object similar to SearchHistory goToLogs function
const queryParams: Record<string, string> = {
org_identifier: store.state.selectedOrganization.identifier,
stream_type: action.resource_type, // logs, metrics, traces
refresh: "0",
sql_mode: target.sql_mode?.toString() || "false",
quick_mode: "false",
show_histogram: "true",
type: "ai_chat_query",
};
// Add stream (comma-separated if array)
if (target.stream) {
queryParams.stream = Array.isArray(target.stream)
? target.stream.join(",")
: target.stream;
}
// Add time range (prefer absolute from/to over period)
if (target.from !== undefined && target.to !== undefined) {
queryParams.from = target.from.toString();
queryParams.to = target.to.toString();
} else if (target.period) {
queryParams.period = target.period;
}
// Add base64 encoded query
if (target.query) {
queryParams.query = encodeForUrl(target.query);
}
// Add VRL function if present
if (target.functionContent) {
queryParams.functionContent = encodeForUrl(target.functionContent);
queryParams.fn_editor = "true";
} else {
queryParams.fn_editor = "false";
}
// Navigate using same pattern as search history
await router.push({
path: targetPath,
query: queryParams,
});
} else if (action.action === "navigate_direct") {
// Direct navigation - build proper URLs based on resource type
let path = action.target.path || `/${action.resource_type}`;
const queryParams: Record<string, string> = {
org_identifier: store.state.selectedOrganization.identifier,
...action.target.query,
};
// Resource-type-specific URL handling
if (action.resource_type === "alert") {
path = "/alerts";
const alertId =
action.target.alert_id || action.target.query?.alert_id;
if (alertId) {
// Navigate to specific alert with update action
queryParams.action = "update";
queryParams.alert_id = alertId;
queryParams.name = action.target.name || action.target.query?.name;
}
queryParams.folder =
action.target.folder || action.target.query?.folder || "default";
} else if (action.resource_type === "dashboard") {
// Dashboards use /dashboards/view path
path = "/dashboards/view";
queryParams.dashboard =
action.target.dashboard_id ||
action.target.dashboardId ||
action.target.query?.dashboardId;
queryParams.folder =
action.target.folder || action.target.query?.folder || "default";
queryParams.tab = action.target.tab || "tab-1";
queryParams.refresh = "Off";
queryParams.period = "15m";
queryParams.print = "false";
} else if (action.resource_type === "pipeline") {
// Pipelines use /pipeline/pipelines/edit path
path = "/pipeline/pipelines/edit";
queryParams.id =
action.target.pipeline_id ||
action.target.id ||
action.target.query?.id;
queryParams.name = action.target.name || action.target.query?.name;
}
await router.push({ path, query: queryParams });
}
// Use setTimeout to add message AFTER navigation fully completes and settles
setTimeout(async () => {
try {
// Add success message AFTER navigation completes
const successMessage = `Successfully navigated to ${pageName}`;
let lastMessage = chatMessages.value[chatMessages.value.length - 1];
if (!lastMessage || lastMessage.role !== "assistant") {
// Create new assistant message
chatMessages.value.push({
role: "assistant",
content: successMessage,
contentBlocks: [{ type: "text", text: successMessage }],
});
} else {
// Append to existing assistant message
if (lastMessage.content) {
lastMessage.content += "\n\n" + successMessage;
} else {
lastMessage.content = successMessage;
}
if (!lastMessage.contentBlocks) {
lastMessage.contentBlocks = [];
}
lastMessage.contentBlocks.push({
type: "text",
text: successMessage,
});
}
// Save to history after adding message
await saveToHistory();
await scrollToBottom();
} catch (error) {
console.error("Error adding navigation success message:", error);
}
}, 500);
};
const confirmClearAllConversations = async () => {
try {
// Clear all history using the composable
const success = await dbClearAllHistory();
if (success) {
// Reset to new chat
addNewChat();
// Clear the chat history array
chatHistory.value = [];
}
} catch (error) {
console.error("Error clearing all conversations:", error);
} finally {
showClearAllConfirmDialog.value = false;
}
};
const loadChat = async (chatId: number) => {
try {
if (chatId == null) {
addNewChat();
return;
}
// Detach any in-progress stream so it continues in the background
detachCurrentStream();
// Load chat using the composable
const chat = await dbLoadChat(chatId);
if (chat) {
// Check if this session has an active background stream.
// If so, re-attach by using the LIVE array that processStream is writing to
// instead of the stale IndexedDB snapshot. Setting chatMessages.value to the
// same array makes processStream's isActive() true again, so UI updates resume.
const bgCtx = chat.sessionId
? backgroundStreamMap.get(chat.sessionId)
: null;
if (bgCtx) {
// Re-attach: use the live streaming array
chatMessages.value = bgCtx.msgs;
currentChatId.value = bgCtx.chatId || chatId;
currentSessionId.value = chat.sessionId || null;
// Move controller back to foreground
currentAbortController.value = bgCtx.controller;
backgroundStreams.delete(bgCtx.controller);
backgroundStreamMap.delete(chat.sessionId!);
// Restore streaming UI state so loading indicator shows
isLoading.value = true;
startAnalyzingRotation();
} else {
// Normal load from IndexedDB snapshot (no active stream)
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;
// Load title from history (no animation for existing chats)
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);
}
// 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 () => {
// Allow sending with text or images (or both)
const hasText = inputMessage.value.trim().length > 0;
const hasImages = pendingImages.value.length > 0;
if ((!hasText && !hasImages) || isLoading.value) return;
// Get the message for backend (with unwrapped chips)
let backendMessage = inputMessage.value;
if (
chatInput.value &&
typeof chatInput.value.getMessageForBackend === "function"
) {
backendMessage = chatInput.value.getMessageForBackend();
}
// Use the plain text message for display
const userMessage = inputMessage.value;
const messagesToSend = [...pendingImages.value]; // Capture images before clearing
// Add to query history before clearing input
if (hasText) {
addToHistory(userMessage);
}
// Push user message with images for display
// But we'll use backendMessage for the API call
chatMessages.value.push({
role: "user",
content: backendMessage, // Use backend message with full context
...(hasImages && { images: messagesToSend }),
});
inputMessage.value = "";
contextReferences.value = []; // Clear reference chips
if (chatInput.value && typeof chatInput.value.clear === "function") {
chatInput.value.clear(); // Clear the rich text input
}
clearPendingImages(); // Clear pending images after capturing
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 = "";
resetTypewriterState(); // Reset typewriter animation for new message
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 {
// Ensure session ID exists for tracking this chat session
if (!currentSessionId.value) {
currentSessionId.value = getUUIDv7();
}
// Pass abort signal, session ID, and images to enable request cancellation and multimodal support
response = await fetchAiChat(
chatMessages.value,
"",
store.state.selectedOrganization.identifier,
currentAbortController.value.signal,
undefined, // explicitContext
currentSessionId.value, // sessionId for x-o2-session-id header
hasImages ? messagesToSend : undefined, // images for multimodal queries
);
} 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) {
// Read the actual error body before throwing
let errorBody = null;
try {
errorBody = await response.json();
} catch (_) {
// body may not be JSON
}
const err: any = new Error(
errorBody?.message || `Server error (${response.status})`,
);
err.status = response.status;
err.errorBody = errorBody;
throw err;
}
if (!response.body) {
throw new Error("No response body");
}
const reader = response.body.getReader();
// Capture the controller, messages ref, and sessionId so we can detect
// detachment and clean up after processStream
const streamController = currentAbortController.value;
const streamMsgs = chatMessages.value;
const streamSessionId = currentSessionId.value;
await processStream(reader);
// Remove controller from background set and clean up re-attachment map
if (streamController) backgroundStreams.delete(streamController);
if (streamSessionId) backgroundStreamMap.delete(streamSessionId);
// Only update UI/store if stream was NOT detached (session is still the same)
const wasDetached = chatMessages.value !== streamMsgs;
if (!wasDetached) {
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: string;
if (error.status === 403) {
errorMessage =
UNAUTHORIZED_MESSAGE;
} else if (error.message && error.message !== "No response body") {
errorMessage = error.message;
} else {
errorMessage =
"Error: Unable to get response from the server. Please try again later.";
}
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 selectWelcomePrompt = (prompt: string) => {
inputMessage.value = prompt;
nextTick(() => {
chatInput.value?.focus?.();
});
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); // Prevent the default enter behavior
sendMessage();
} else if (e.key === "Backspace") {
// Handle backspace for RichTextInput (contenteditable)
const target = e.target as HTMLElement;
const contenteditable =
target.closest('[contenteditable="true"]') ||
target.querySelector('[contenteditable="true"]');
if (contenteditable) {
// Check if cursor is right after an image reference span
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const cursorNode = range.startContainer;
let imageRefSpan: Element | null = null;
// Case 1: Cursor is in a text node at position 0, check previous sibling
if (
cursorNode.nodeType === Node.TEXT_NODE &&
range.startOffset === 0
) {
const prevSibling = cursorNode.previousSibling;
if (
prevSibling &&
(prevSibling as Element).classList?.contains("image-reference")
) {
imageRefSpan = prevSibling as Element;
}
}
// Case 2: Cursor is in an element node, check the child before cursor
else if (
cursorNode.nodeType === Node.ELEMENT_NODE &&
range.startOffset > 0
) {
const element = cursorNode as Element;
const prevChild = element.childNodes[range.startOffset - 1];
if (
prevChild &&
(prevChild as Element).classList?.contains("image-reference")
) {
imageRefSpan = prevChild as Element;
}
}
// If we found an image reference to delete
if (imageRefSpan) {
e.preventDefault();
// Extract filename from the span text
const refText = imageRefSpan.textContent || "";
const match = refText.match(/@\[([^\]]+)\]/);
if (match) {
const filename = match[1];
// Remove the associated image from pendingImages
const imageIndex = pendingImages.value.findIndex(
(img) => img.filename === filename,
);
if (imageIndex !== -1) {
pendingImages.value.splice(imageIndex, 1);
}
}
// Remove the span element
imageRefSpan.remove();
// Trigger input event to update model
if (contenteditable) {
contenteditable.dispatchEvent(
new Event("input", { bubbles: true }),
);
}
}
} else {
// Legacy textarea handling
const textarea = e.target as HTMLTextAreaElement;
const cursorPos = textarea.selectionStart;
const text = inputMessage.value;
// Find if cursor is at the end of a @[filename] pattern
const textBeforeCursor = text.substring(0, cursorPos);
const match = textBeforeCursor.match(/@\[([^\]]+)\]$/);
if (match) {
e.preventDefault();
const filename = match[1];
const refStart = cursorPos - match[0].length;
// Remove the entire @[filename] reference from text
inputMessage.value =
text.substring(0, refStart) + text.substring(cursorPos);
// Remove the associated image from pendingImages
const imageIndex = pendingImages.value.findIndex(
(img) => img.filename === filename,
);
if (imageIndex !== -1) {
pendingImages.value.splice(imageIndex, 1);
}
// Set cursor position after the deletion
nextTick(() => {
textarea.selectionStart = textarea.selectionEnd = refStart;
});
}
}
} else if (e.key === "ArrowUp") {
const target = e.target as HTMLElement;
const textarea =
target.tagName === "TEXTAREA"
? (target as HTMLTextAreaElement)
: null;
if (textarea && isOnFirstLine(textarea)) {
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 RichTextInput component, call its focusInput method
if (typeof chatInput.value.focusInput === "function") {
chatInput.value.focusInput();
} else {
// Fallback for other input types
chatInput.value.focus();
}
}
};
// Handle reference chip updates from RichTextInput
const handleReferencesUpdate = (refs: ReferenceChip[]) => {
contextReferences.value = refs;
};
// Image handling functions
const triggerImageUpload = () => {
imageInputRef.value?.click();
};
const handleImageSelect = async (event: Event) => {
const input = event.target as HTMLInputElement;
const files = input.files;
if (!files) return;
for (const file of Array.from(files)) {
await addImage(file);
}
// Reset input so the same file can be selected again
input.value = "";
};
const addImage = async (file: File): Promise<boolean> => {
// Validate file size first (before reading)
if (file.size > MAX_IMAGE_SIZE_BYTES) {
toast({
variant: "error",
message: `Image exceeds 2MB limit (${(file.size / 1024 / 1024).toFixed(1)}MB)`,
});
return false;
}
// Basic file type check for immediate feedback (backend will detect actual type)
if (!ALLOWED_IMAGE_TYPES.includes(file.type as any)) {
toast({
variant: "error",
message: "Only PNG and JPEG images are supported",
});
return false;
}
// Convert to base64
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
const base64 = (e.target?.result as string).split(",")[1]; // Remove data:image/...;base64, prefix
const imageRef = `@[${file.name}]`;
// Use file.type for display - backend will detect and correct actual mime type
pendingImages.value.push({
data: base64,
mimeType: file.type as "image/png" | "image/jpeg",
filename: file.name,
size: file.size,
});
// Insert image reference at cursor position
// Check if we're using RichTextInput (contenteditable)
const contenteditable =
chatInput.value?.$el?.querySelector('[contenteditable="true"]') ||
chatInput.value?.$el?.querySelector(".rich-text-input");
if (contenteditable) {
// RichTextInput - insert at cursor position in contenteditable
const selection = window.getSelection();
const range =
selection && selection.rangeCount > 0
? selection.getRangeAt(0)
: null;
// Create a non-editable span for the image reference
const imageRefSpan = document.createElement("span");
imageRefSpan.contentEditable = "false";
imageRefSpan.className = "image-reference";
imageRefSpan.style.cssText =
"display: inline-flex; align-items: center; gap: 4px; padding: 2px 6px; margin: 0 2px; background: #e8f5e9; border: 1px solid #a5d6a7; border-radius: 4px; font-size: 13px; color: #2e7d32; user-select: none;";
// Add image icon
const imageIcon = document.createElement("span");
imageIcon.textContent = "🖼️";
imageIcon.style.cssText = "font-size: 12px;";
// Add filename text
const filenameText = document.createElement("span");
filenameText.textContent = file.name;
// Add remove button
const removeBtn = document.createElement("button");
removeBtn.textContent = "×";
removeBtn.style.cssText =
"display: flex; align-items: center; justify-content: center; width: 14px; height: 14px; padding: 0; margin-left: 2px; background: transparent; border: none; border-radius: 3px; font-size: 16px; line-height: 1; cursor: pointer; color: #2e7d32; transition: all 0.15s ease;";
removeBtn.onmouseover = () => {
removeBtn.style.background = "#c62828";
removeBtn.style.color = "white";
};
removeBtn.onmouseout = () => {
removeBtn.style.background = "transparent";
removeBtn.style.color = "#2e7d32";
};
removeBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
// Find and remove the image from pendingImages
const imageIndex = pendingImages.value.findIndex(
(img) => img.filename === file.name,
);
if (imageIndex !== -1) {
pendingImages.value.splice(imageIndex, 1);
}
// Remove the span
imageRefSpan.remove();
// Trigger input event
contenteditable.dispatchEvent(
new Event("input", { bubbles: true }),
);
};
imageRefSpan.appendChild(imageIcon);
imageRefSpan.appendChild(filenameText);
imageRefSpan.appendChild(removeBtn);
if (range && contenteditable.contains(range.startContainer)) {
// Insert at cursor position
range.deleteContents();
// Add space before if needed
const textBefore = range.startContainer.textContent || "";
if (
textBefore.length > 0 &&
!textBefore.endsWith(" ") &&
!textBefore.endsWith("\n")
) {
range.insertNode(document.createTextNode(" "));
}
range.insertNode(imageRefSpan);
// Add space after for cursor positioning
const spaceAfter = document.createTextNode(" ");
range.setStartAfter(imageRefSpan);
range.insertNode(spaceAfter);
// Move cursor after the space
range.setStartAfter(spaceAfter);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
} else {
// No selection or selection outside - append to end
const spaceNeeded =
contenteditable.textContent &&
!contenteditable.textContent.endsWith(" ") &&
!contenteditable.textContent.endsWith("\n");
if (spaceNeeded) {
contenteditable.appendChild(document.createTextNode(" "));
}
contenteditable.appendChild(imageRefSpan);
const spaceAfter = document.createTextNode(" ");
contenteditable.appendChild(spaceAfter);
// Move cursor to end
const newRange = document.createRange();
newRange.setStartAfter(spaceAfter);
newRange.collapse(true);
selection?.removeAllRanges();
selection?.addRange(newRange);
}
// Trigger input event to update model
contenteditable.dispatchEvent(
new Event("input", { bubbles: true }),
);
focusInput();
} else {
// Legacy textarea fallback
const textarea = chatInput.value?.$el?.querySelector(
"textarea",
) as HTMLTextAreaElement | null;
if (textarea) {
const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || 0;
const text = inputMessage.value;
const before = text.substring(0, start);
const after = text.substring(end);
// Add space before if needed
const needsSpaceBefore =
before.length > 0 &&
!before.endsWith(" ") &&
!before.endsWith("\n");
const needsSpaceAfter =
after.length > 0 &&
!after.startsWith(" ") &&
!after.startsWith("\n");
const insertion =
(needsSpaceBefore ? " " : "") +
imageRef +
(needsSpaceAfter ? " " : "");
inputMessage.value = before + insertion + after;
// Set cursor position after the inserted reference
nextTick(() => {
const newPos = start + insertion.length;
textarea.setSelectionRange(newPos, newPos);
textarea.focus();
});
} else {
// Final fallback: append to end
const currentText = inputMessage.value;
const separator =
currentText &&
!currentText.endsWith(" ") &&
!currentText.endsWith("\n")
? " "
: "";
inputMessage.value = currentText + separator + imageRef + " ";
}
}
resolve(true);
};
reader.onerror = () => {
toast({
variant: "error",
message: `Failed to read image: ${file.name}`,
});
resolve(false);
};
reader.readAsDataURL(file);
});
};
const removeImage = (index: number) => {
// Get the filename before removing
const image = pendingImages.value[index];
if (image) {
const imageRef = `@[${image.filename}]`;
// Check if we're using RichTextInput (contenteditable)
const contenteditable =
chatInput.value?.$el?.querySelector('[contenteditable="true"]') ||
chatInput.value?.$el?.querySelector(".rich-text-input");
if (contenteditable) {
// Find and remove all image reference spans with this filename
const imageRefSpans =
contenteditable.querySelectorAll(".image-reference");
imageRefSpans.forEach((span: Element) => {
if (span.textContent === imageRef) {
span.remove();
}
});
// Trigger input event to update model
contenteditable.dispatchEvent(new Event("input", { bubbles: true }));
} else {
// Legacy textarea - remove from text
inputMessage.value = inputMessage.value
.replace(new RegExp(`\\s*${escapeRegExp(imageRef)}\\s*`, "g"), " ")
.trim();
}
}
pendingImages.value.splice(index, 1);
};
// Helper to escape special regex characters
const escapeRegExp = (str: string) => {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
};
const clearPendingImages = () => {
pendingImages.value = [];
};
// Handle drag and drop for images
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
};
const handleDrop = async (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
const files = event.dataTransfer?.files;
if (!files) return;
for (const file of Array.from(files)) {
if (file.type.startsWith("image/")) {
await addImage(file);
}
}
};
// Handle paste for images
const handlePaste = async (event: ClipboardEvent) => {
const items = event.clipboardData?.items;
if (!items) return;
for (const item of Array.from(items)) {
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
if (file) {
event.preventDefault();
await addImage(file);
}
}
}
};
// Open image preview dialog
const openImagePreview = (img: ImageAttachment) => {
previewImage.value = img;
showImagePreview.value = true;
};
const closeImagePreview = () => {
showImagePreview.value = false;
previewImage.value = null;
};
// Scroll input textarea to bottom to show latest appended content
const scrollInputToBottom = () => {
// Clear any pending scroll timeout
if (scrollTimeoutId.value !== null) {
clearTimeout(scrollTimeoutId.value);
}
// Set new timeout for scroll
scrollTimeoutId.value = setTimeout(() => {
const textarea = chatInput.value?.$el?.querySelector("textarea");
if (!textarea) return;
textarea.focus();
textarea.setSelectionRange(
textarea.value.length,
textarea.value.length,
);
// Scroll all scrollable parent elements
let element = textarea;
while (element && element !== document.body) {
if (element.scrollHeight > element.clientHeight) {
element.scrollTop = element.scrollHeight;
}
element = element.parentElement;
}
scrollTimeoutId.value = null;
}, 50);
};
// 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);
}
};
// Auto navigation preferences localStorage functions
const AUTO_NAV_KEY = "ai-chat-auto-navigation";
const loadAutoNavigationPreferences = () => {
try {
const stored = localStorage.getItem(AUTO_NAV_KEY);
if (stored) {
const data = JSON.parse(stored);
autoNavigationPreferences.value = new Map(
Object.entries(data).map(([k, v]) => [parseInt(k), v as boolean]),
);
}
} catch (error) {
console.error("Error loading auto navigation preferences:", error);
autoNavigationPreferences.value = new Map();
}
};
const saveAutoNavigationPreferences = () => {
try {
const data = Object.fromEntries(autoNavigationPreferences.value);
localStorage.setItem(AUTO_NAV_KEY, JSON.stringify(data));
} catch (error) {
console.error("Error saving auto navigation preferences:", 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
// Mark component as ready and process any pending chips
// Use a slight delay to ensure RichTextInput is fully mounted
nextTick(() => {
setTimeout(() => {
componentReady.value = true;
processPendingChips();
focusInput();
}, 100);
});
}
},
);
// Auto-focus input when chat is expanded
watch(
() => store.state.isAiChatExpanded,
(isExpanded) => {
if (isExpanded) {
nextTick(() => {
setTimeout(() => {
focusInput();
}, 150);
});
}
},
);
// Watch for organization switches — reset current chat and reload history
// scoped to the new org so users never see cross-org chat history.
watch(
() => store.state.selectedOrganization?.identifier,
(newOrgId, oldOrgId) => {
if (newOrgId && newOrgId !== oldOrgId) {
addNewChat();
if (props.isOpen) {
loadHistory();
}
}
},
);
// 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);
// Mark component as ready and process any pending chips
// Use a slight delay to ensure RichTextInput is fully mounted
nextTick(() => {
setTimeout(() => {
componentReady.value = true;
processPendingChips();
}, 100);
});
}
// Load query history from localStorage
loadQueryHistory();
// Load auto navigation preferences from localStorage
loadAutoNavigationPreferences();
});
onUnmounted(() => {
// Cancel any ongoing requests when component is unmounted to prevent memory leaks
if (currentAbortController.value) {
currentAbortController.value.abort();
currentAbortController.value = null;
}
// Abort all background streams to prevent memory leaks
for (const controller of backgroundStreams) {
controller.abort();
}
backgroundStreams.clear();
backgroundStreamMap.clear();
// Clean up typewriter animation to prevent memory leaks
if (typewriterAnimationId.value) {
cancelAnimationFrame(typewriterAnimationId.value);
typewriterAnimationId.value = null;
}
// Clean up title animation interval
if (titleIntervalId) {
clearInterval(titleIntervalId);
titleIntervalId = 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);
});
// Watch for typewriter animation updates to refresh the displayed text
watch(displayedStreamingContent, (newContent) => {
if (!isLoading.value) return;
// Don't overwrite existing text with empty string when displayedStreamingContent
// is reset (e.g., on tool_call). The reset signals "new segment starts" not
// "clear previous content".
if (!newContent) return;
const lastMessage = chatMessages.value[chatMessages.value.length - 1];
if (
lastMessage &&
lastMessage.role === "assistant" &&
lastMessage.contentBlocks
) {
const lastBlock =
lastMessage.contentBlocks[lastMessage.contentBlocks.length - 1];
if (lastBlock && lastBlock.type === "text") {
lastBlock.text = newContent;
}
}
});
// Filter markdown headers - convert # and ## to smaller formatting
// This should only process actual markdown headers, not code block comments
const filterMarkdownHeaders = (content: string): string => {
// First, protect code blocks by temporarily replacing them
const codeBlocks: string[] = [];
let filtered = content.replace(/```[\s\S]*?```/g, (match) => {
codeBlocks.push(match);
return `___CODE_BLOCK_${codeBlocks.length - 1}___`;
});
// Convert ## headers to bold with colon (only outside code blocks)
filtered = filtered.replace(/^## (.+)$/gm, "**$1:**");
// Convert # headers to bold with colon (only outside code blocks)
filtered = filtered.replace(/^# (.+)$/gm, "**$1:**");
// Restore code blocks
filtered = filtered.replace(
/___CODE_BLOCK_(\d+)___/g,
(_match, index) => {
return codeBlocks[parseInt(index)];
},
);
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;
};
// Helper to format JSON with syntax highlighting
const formatLogEntryContent = (content: string): string => {
try {
const parsed = JSON.parse(content);
const formatted = JSON.stringify(parsed, null, 2);
// Apply syntax highlighting
const highlighted = formatted.replace(
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
(match) => {
let cls = "json-number";
if (/^"/.test(match)) {
if (/:$/.test(match)) {
cls = "json-key";
} else {
cls = "json-string";
}
} else if (/true|false/.test(match)) {
cls = "json-boolean";
} else if (/null/.test(match)) {
cls = "json-null";
}
return `<span class="${cls}">${match}</span>`;
},
);
return DOMPurify.sanitize(highlighted);
} catch {
// Not JSON, return plain text with HTML escaping
return DOMPurify.sanitize(
content
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
.replace(/\n/g, "<br>"),
);
}
};
// Parse log entries from message content and maintain order
const parseLogEntries = (content: string) => {
const logEntryPattern =
/--- (.+?) (?:\(lines (\d+)-(\d+)\) )?---\n([\s\S]*?)\n--- end ---/g;
const orderedBlocks: any[] = [];
let lastIndex = 0;
let match;
// Reset regex state
logEntryPattern.lastIndex = 0;
while ((match = logEntryPattern.exec(content)) !== null) {
const [fullMatch, filename, lineStart, lineEnd, logContent] = match;
const matchIndex = match.index;
// Add text before this log entry (if any)
if (matchIndex > lastIndex) {
const textBefore = content.substring(lastIndex, matchIndex).trim();
if (textBefore) {
orderedBlocks.push({
type: "text",
text: textBefore,
});
}
}
// Add the log entry
orderedBlocks.push({
type: "log_entry",
filename,
lineStart: lineStart ? parseInt(lineStart) : undefined,
lineEnd: lineEnd ? parseInt(lineEnd) : undefined,
content: logContent.trim(),
preview: createPreview(logContent.trim(), 60),
});
lastIndex = matchIndex + fullMatch.length;
}
// Add any remaining text after the last log entry
if (lastIndex < content.length) {
const textAfter = content.substring(lastIndex).trim();
if (textAfter) {
orderedBlocks.push({
type: "text",
text: textAfter,
});
}
}
return orderedBlocks;
};
const processedMessages = computed(() => {
return chatMessages.value.map((message) => {
// For user messages, check for log entries
if (message.role === "user") {
const orderedBlocks = parseLogEntries(message.content);
// If we have ordered blocks from parsing, combine them with existing contentBlocks
const combinedContentBlocks =
orderedBlocks.length > 0
? [...orderedBlocks, ...(message.contentBlocks || [])]
: message.contentBlocks || [];
return {
...message,
blocks:
orderedBlocks.length > 0
? []
: processMessageContent(message.content),
contentBlocks: combinedContentBlocks,
};
}
// For assistant messages, keep as is
return {
...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",
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}`);
};
// Log entry expansion helpers
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}`);
};
// 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 testFunction context (VRL validation)
if (context.vrl) data.vrl = context.vrl;
if (context.request_body?.function)
data.vrl = context.request_body.function;
// Handle flat SQL from SearchSQL enriched context
if (context.sql) data.query = context.sql;
// Handle flat structure (StreamSchema, etc.)
if (context.stream_name) data.stream = context.stream_name;
if (context.type) data.type = context.type;
// CLI tools: surface the command string
if (context.command) data.command = context.command;
return Object.keys(data).length > 0 ? data : null;
};
const hasToolCallDetails = (block: ContentBlock) => {
// Show details for failed tools, successful tools with summary, tools with context data, or tools with response
if (block.success === false) return true;
if (block.success !== false && block.summary) return true;
if (block.response) return true;
return getToolCallDisplayData(block.context) !== null;
};
const formatToolCallMessage = (block: ContentBlock) => {
// Show error message for failed tools
// Tool-specific messages (both success and error)
if (block.tool === "testFunction") {
if (block.success === false) {
return { text: "VRL validation failed", highlight: null, suffix: "" };
}
return { text: "Validated VRL", highlight: null, suffix: "" };
}
if (block.tool === "SearchSQL") {
if (block.success === false) {
return { text: "Query failed", highlight: null, suffix: "" };
}
if (block.response?.total !== undefined) {
const streamType = block.context?.type || "logs";
return {
text: `Queried ${streamType} `,
highlight: `(${block.response.total} results)`,
suffix: "",
};
}
}
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: "",
};
}
// List tools: show count from normalized { total, items } response
if (block.response?.total !== undefined && block.success !== false) {
const base = block.message || block.tool || "Listed";
return {
text: base + " ",
highlight: `(Found ${block.response.total})`,
suffix: "",
};
}
// Generic fallback
if (block.success === false && block.resultMessage) {
// Truncate long error messages for the header
const msg =
block.resultMessage.length > 60
? block.resultMessage.substring(0, 60) + "..."
: block.resultMessage;
return { text: msg, highlight: null, suffix: "" };
}
if (block.success !== false && block.summary?.count !== undefined) {
const base = block.message || block.tool || "Tool";
return {
text: base + " ",
highlight: `(${block.summary.count} results)`,
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 = 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: "Thanks for your feedback!",
});
}
};
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: "Thanks for your feedback!",
});
}
};
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,
selectWelcomePrompt,
inputPlaceholder,
showHistory,
chatHistory,
currentChatId,
addNewChat,
toggleExpand,
openHistory,
loadChat,
showEditTitleDialog,
editingTitle,
openEditTitleDialog,
saveEditedTitle,
deleteChat,
confirmDeleteChat,
showDeleteChatConfirmDialog,
chatToDelete,
clearAllConversations,
showClearAllConfirmDialog,
confirmClearAllConversations,
// Tool confirmation
pendingConfirmation,
handleToolConfirm,
handleToolCancel,
handleToolAlwaysConfirm,
handleNavigationAction,
// Auto navigation
isAutoNavigationEnabled,
processedMessages,
pendingToolCalls,
processTextBlock,
copyToClipboard,
retryGeneration,
getLanguageDisplay,
processHtmlBlock,
formatTime,
loadHistory,
store,
isMac,
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,
expandedLogEntries,
toggleLogEntryExpanded,
isLogEntryExpanded,
formatLogEntryContent,
// AI-generated title
aiGeneratedTitle,
displayedTitle,
isTypingTitle,
// Image handling
pendingImages,
imageInputRef,
triggerImageUpload,
handleImageSelect,
removeImage,
handleDragOver,
handleDrop,
handlePaste,
// Image preview
showImagePreview,
previewImage,
openImagePreview,
closeImagePreview,
contextReferences,
handleReferencesUpdate,
t,
};
},
});
</script>
<style lang="scss" scoped>
.chat-container {
width: 100%;
height: 100%;
color: var(--q-primary-text);
display: flex;
flex-direction: column;
overflow: hidden;
background-color: var(--o2-card-bg-solid);
border-radius: 0.375rem;
box-shadow: 0 0 5px 1px var(--o2-hover-shadow);
.chat-content-wrapper {
display: flex;
flex-direction: column;
height: 100%;
background: transparent;
}
.chat-header {
padding: 0px 12px 4px 12px;
display: flex;
justify-content: space-between;
align-items: end;
border-bottom: 1px solid var(--color-separator);
flex-shrink: 0;
background: var(--q-page-background);
z-index: 2;
.chat-title {
font-weight: bold;
}
.chat-title-dropdown {
padding: 6px 12px;
border-radius: 4px;
transition: background-color 0.2s;
max-width: 210px;
height: 32px;
min-height: 32px;
display: flex;
align-items: center;
overflow: hidden;
&:hover {
background-color: var(--q-hover-color);
}
span {
color: var(--q-primary-text);
}
.chat-title-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 180px;
}
}
}
// Chat session title with typewriter animation
.chat-session-title {
padding: 8px 16px;
font-size: 14px;
min-height: 32px;
display: flex;
align-items: center;
flex-shrink: 0;
border-bottom: 1px solid var(--color-separator);
&.light-mode {
color: #1a202c;
background: linear-gradient(
to right,
rgba(99, 102, 241, 0.08),
transparent
);
}
&.dark-mode {
color: #e2e8f0;
background: linear-gradient(
to right,
rgba(99, 102, 241, 0.15),
transparent
);
}
.title-text {
font-weight: 600;
}
.typing-cursor {
animation: blink 0.7s infinite;
margin-left: 2px;
font-weight: 400;
}
}
@keyframes blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
.chat-content {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
background: transparent;
position: relative;
}
.messages-container {
flex: 1;
min-height: 0;
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;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
&.welcome-section--centered {
background: transparent;
padding: 0;
margin-bottom: 0;
}
}
.centered-input-wrap {
max-width: 900px;
width: calc(100% - 16px);
margin-top: 1.25em;
font-size: 1rem;
:deep(.rich-text-input) {
font-size: 1rem;
}
}
// Fixed analyzing indicator above input
.fixed-analyzing-indicator {
padding: 12px 16px;
margin: 0 16px 8px 16px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
animation: fadeInSlide 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
&.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;
}
.analyzing-content {
display: flex;
align-items: center;
gap: 12px;
max-width: 900px;
width: 100%;
}
.analyzing-message {
font-size: 14px;
font-weight: 500;
color: var(--q-primary);
}
.tool-call-info {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
}
.tool-call-context-inline {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-top: 4px;
}
.context-query-inline {
font-size: 12px;
padding: 4px 8px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.05);
max-width: 500px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.context-tag-inline {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
background: rgba(var(--q-primary-rgb), 0.1);
color: var(--q-primary);
font-weight: 500;
}
}
@keyframes fadeInSlide {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.chat-input-container {
position: relative;
flex-shrink: 0;
max-width: 900px;
width: calc(100% - 0px);
margin: 8px auto;
padding: 0 8px;
}
.unified-input-box {
display: flex;
flex-direction: column;
padding: 4px 8px;
border-radius: 12px;
transition: all 0.2s ease;
gap: 12px;
&.light-mode {
background: #ffffff;
border: 1px solid #e4e7ec;
&:focus-within {
border: 1px solid transparent;
box-shadow: 0 0 0 2px #8b5cf6;
}
}
&.dark-mode {
background: #191919;
border: 1px solid #323232;
&:focus-within {
border: 1px solid transparent;
box-shadow: 0 0 0 2px #5a6ec3;
}
}
:deep(.rich-text-input-wrapper) {
width: 100%;
min-height: 40px;
}
:deep(.rich-text-input) {
padding: 4px 0;
}
}
.input-bottom-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 8px;
}
.auto-nav-toggle-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 6px;
transition: all 0.2s ease;
.auto-nav-label {
font-size: 12px;
font-weight: 500;
}
&:hover {
.light-mode & {
background: #f3f4f6;
}
.dark-mode & {
background: #374151;
}
}
&.auto-nav-enabled {
.auto-nav-icon {
color: var(--q-primary) !important;
}
.auto-nav-label {
color: var(--q-primary);
}
}
.light-mode & .auto-nav-label {
color: #6b7280;
}
.dark-mode & .auto-nav-label {
color: #9ca3af;
}
}
.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;
}
.feedback-buttons {
display: flex;
align-items: center;
gap: 2px;
margin-top: 4px;
> * {
opacity: 0.5;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
}
&.feedback-active > * {
opacity: 1;
}
.feedback-selected {
color: var(--o2-primary-color);
opacity: 1;
}
}
.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%;
}
// Restore list styling (Tailwind preflight removes it)
:deep(ol) {
list-style-type: decimal;
padding-left: 1.5em;
margin: 0.5em 0;
}
:deep(ul) {
list-style-type: disc;
padding-left: 1.5em;
margin: 0.5em 0;
}
:deep(li) {
margin: 0.25em 0;
}
}
.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, #8b5cf6 0%, #ec4899 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, #8b5cf6 0%, #ec4899 100%) !important;
transition: all 0.3s ease !important;
box-shadow: 0 4px 15px 0 rgba(139, 92, 246, 0.3) !important;
&:hover:not(.disabled):not([disabled]):not(:disabled) {
background: linear-gradient(135deg, #7c3aed 0%, #db2777 100%) !important;
box-shadow: 0 6px 20px 0 rgba(139, 92, 246, 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(139, 92, 246, 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;
}
}
// Image upload button styling
.image-upload-btn {
opacity: 0.7;
transition: opacity 0.2s ease;
&:hover {
opacity: 1;
}
}
// Image preview strip in input area
.image-preview-strip {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px 0;
margin-bottom: 8px;
.image-preview-item {
position: relative;
display: inline-block;
.preview-image {
width: 64px;
height: 64px;
object-fit: cover;
border-radius: 8px;
border: 1px solid #d1d5db;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
}
}
.image-remove-btn {
position: absolute !important;
top: -6px !important;
right: -6px !important;
width: 20px !important;
height: 20px !important;
min-width: 20px !important;
min-height: 20px !important;
padding: 0 !important;
background-color: #ef4444 !important;
z-index: 10;
&:hover {
background-color: #dc2626 !important;
}
}
}
}
// Images in chat messages
.message-images {
.message-image-item {
img {
border-radius: 8px;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
&:hover {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
}
}
// Image preview dialog
.image-preview-dialog {
background: var(--q-dark) !important;
.q-card__section {
background: transparent;
}
}
.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: 450px;
display: flex;
flex-direction: column;
width: 300px;
}
.search-history-bar-sticky {
position: sticky;
top: 0;
z-index: 2;
background: var(--q-page-background);
padding: 8px;
border-bottom: 1px solid var(--color-separator);
flex-shrink: 0;
}
.history-list-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
max-height: 350px;
}
.history-item {
position: relative;
.delete-history-btn {
opacity: 0;
transition: opacity 0.2s;
}
&:hover .delete-history-btn {
opacity: 1;
}
}
.clear-all-container {
background: var(--q-page-background);
padding: 8px;
border-top: 1px solid var(--color-separator);
flex-shrink: 0;
.clear-all-btn {
width: 100%;
color: var(--q-negative);
font-size: 13px;
&:hover {
background-color: rgba(var(--q-negative-rgb), 0.1);
}
}
}
// 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 #8b5cf6 !important;
color: #8b5cf6 !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);
}
}
// 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);
}
}
// 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;
}
// Completed step shown live during streaming — more compact and subdued
// than the active (spinner) indicator so the in-flight step still stands out.
&.completed {
padding: 8px 16px;
margin: 4px 0;
.tool-call-message {
font-weight: 500;
opacity: 0.85;
}
}
.tool-call-content {
display: flex;
align-items: center;
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(139, 92, 246, 0.1);
color: #8b5cf6;
}
.dark-mode & {
background: rgba(139, 92, 246, 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);
}
&.has-details:hover {
&.light-mode {
background: rgba(244, 67, 54, 0.15);
}
&.dark-mode {
background: rgba(244, 67, 54, 0.22);
}
}
}
// Timeout state styling
&.timeout {
&.light-mode {
background: rgba(255, 152, 0, 0.08);
}
&.dark-mode {
background: rgba(255, 152, 0, 0.12);
}
&.has-details:hover {
&.light-mode {
background: rgba(255, 152, 0, 0.15);
}
&.dark-mode {
background: rgba(255, 152, 0, 0.22);
}
}
}
// Pending confirmation state styling (yellow/amber)
&.pending-confirmation {
cursor: default;
&.light-mode {
background: rgba(255, 193, 7, 0.12);
border: 1px solid rgba(255, 193, 7, 0.3);
}
&.dark-mode {
background: rgba(255, 193, 7, 0.15);
border: 1px solid rgba(255, 193, 7, 0.25);
}
}
// Pending navigation confirmation styling (blue)
&.pending-navigation {
cursor: default;
&.light-mode {
background: rgba(25, 118, 210, 0.08);
border: 1px solid rgba(25, 118, 210, 0.3);
}
&.dark-mode {
background: rgba(66, 165, 245, 0.12);
border: 1px solid rgba(66, 165, 245, 0.25);
}
}
.tool-confirmation-inline {
margin-top: 12px;
.confirmation-content {
padding: 16px;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 12px;
.light-mode & {
background: #fffbeb;
border: 1px solid #fde68a;
}
.dark-mode & {
background: rgba(251, 191, 36, 0.15);
border: 1px solid rgba(251, 191, 36, 0.3);
}
}
}
.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;
}
.navigation-icon {
cursor: pointer;
margin-left: auto;
opacity: 0.7;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
}
.navigation-block {
margin: 4px 0;
&.light-mode {
background: rgba(66, 165, 245, 0.08);
}
&.dark-mode {
background: rgba(66, 165, 245, 0.12);
}
.navigation-block-btn {
font-size: 13px;
}
}
.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);
}
}
}
}
}
// Log entry item - expandable log content display
.log-entry-item {
display: flex;
flex-direction: column;
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
margin-bottom: 4px;
cursor: pointer;
&.light-mode {
background: rgba(33, 150, 243, 0.08);
color: #4a5568;
}
&.dark-mode {
background: #252a31;
border: 1px solid #3a4149;
color: #e2e8f0;
}
&:hover {
&.light-mode {
background: rgba(33, 150, 243, 0.12);
}
&.dark-mode {
background: #20242e;
border-color: #4a5568;
}
}
.log-entry-header {
display: flex;
align-items: center;
gap: 6px;
}
.log-entry-info {
flex: 1;
font-weight: 500;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.expand-icon {
opacity: 0.6;
transition: transform 0.2s;
}
.log-entry-details {
margin-top: 10px;
}
.log-entry-content {
position: relative;
border-radius: 6px;
border: 1px solid;
overflow: hidden;
.light-mode & {
background: #ffffff;
border-color: #e4e7ec;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.dark-mode & {
background: #1e293b;
border-color: #475569;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.copy-btn {
position: absolute;
top: 8px;
right: 8px;
opacity: 0.6;
z-index: 1;
background: rgba(128, 128, 128, 0.1);
border-radius: 4px;
padding: 4px 8px;
&:hover {
opacity: 1;
.light-mode & {
background: rgba(0, 0, 0, 0.08);
}
.dark-mode & {
background: rgba(255, 255, 255, 0.15);
}
}
}
.log-entry-code {
display: block;
font-family: "Monaco", "Menlo", "Courier New", monospace;
font-size: 11px;
line-height: 1.5;
padding: 12px;
padding-right: 40px;
white-space: pre-wrap;
word-wrap: break-word;
user-select: text;
cursor: text;
max-height: 300px;
overflow-y: auto;
.light-mode & {
background: #f8fafc;
color: #1a202c;
}
.dark-mode & {
background: #0d1017;
color: #e2e8f0;
}
// JSON syntax highlighting - use :deep() for v-html content
:deep(.json-key) {
color: #0066cc;
font-weight: 600;
}
:deep(.json-string) {
color: #22863a;
}
:deep(.json-number) {
color: #005cc5;
}
:deep(.json-boolean) {
color: #d73a49;
font-weight: 600;
}
:deep(.json-null) {
color: #6f42c1;
font-weight: 600;
}
}
}
}
// Dark mode JSON syntax highlighting for log entries
.dark-mode .log-entry-code {
:deep(.json-key) {
color: #60a5fa;
}
:deep(.json-string) {
color: #86efac;
}
:deep(.json-number) {
color: #7dd3fc;
}
:deep(.json-boolean) {
color: #fca5a5;
}
:deep(.json-null) {
color: #c4b5fd;
}
}
.tool-call-item {
.tool-response-hits {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
font-family: "Fira Code", "Consolas", monospace;
padding: 6px 8px;
border-radius: 4px;
max-height: 200px;
overflow-y: auto;
.light-mode & {
background: rgba(0, 0, 0, 0.04);
}
.dark-mode & {
background: rgba(255, 255, 255, 0.06);
}
}
.tool-response-hit {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
padding: 2px 0;
&:not(:last-child) {
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
padding-bottom: 4px;
}
}
.tool-response-list-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 0;
&:not(:last-child) {
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
padding-bottom: 6px;
}
}
.hit-field {
word-break: break-all;
user-select: text;
cursor: text;
}
.hit-key {
opacity: 0.6;
font-weight: 600;
}
.hit-error {
color: #f44336;
}
.tool-response-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.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;
}
}
.tool-error-message {
color: #f44336;
}
.tool-suggestion {
font-style: italic;
opacity: 0.85;
}
}
// Stream-level error block - inline in chat flow
.stream-error-block {
display: flex;
flex-direction: column;
padding: 10px 12px;
border-radius: 6px;
border-left: 3px solid #f44336;
margin-bottom: 8px;
font-size: 13px;
&.light-mode {
background: rgba(244, 67, 54, 0.06);
color: #4a5568;
}
&.dark-mode {
background: rgba(244, 67, 54, 0.1);
color: #a0aec0;
}
.stream-error-header {
display: flex;
align-items: center;
gap: 8px;
}
.stream-error-message {
font-weight: 500;
color: #f44336;
}
.stream-error-suggestion {
margin-top: 6px;
padding-left: 24px;
font-style: italic;
font-size: 12px;
opacity: 0.85;
}
.stream-error-recoverable {
margin-top: 4px;
padding-left: 24px;
font-size: 11px;
opacity: 0.7;
}
}
// 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>