mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 00:09:35 +00:00
Co-authored-by: sai nikhil kethe <nikhil@openobserve.ai> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
6362 lines
264 KiB
Vue
6362 lines
264 KiB
Vue
<template>
|
||
<div
|
||
class="chat-container rounded-surface text-text-body bg-card-glass-solid shadow-hover-shadow flex h-full w-full flex-col overflow-hidden shadow-md"
|
||
:class="[{ 'chat-open': isOpen }]"
|
||
>
|
||
<div v-if="isOpen" class="chat-content-wrapper flex h-full flex-col bg-transparent">
|
||
<div
|
||
class="chat-header border-separator bg-surface-base z-2 flex shrink-0 items-end justify-between border-b px-3 pt-0 pb-1"
|
||
:style="{ height: headerHeight ? headerHeight + 'px' : '' }"
|
||
>
|
||
<div class="chat-title flex w-full items-center justify-between font-bold">
|
||
<div class="flex min-w-0 items-center gap-2">
|
||
<div class="inline-flex h-6 w-6 shrink-0 overflow-hidden rounded-full">
|
||
<img :src="o2AiTitleLogo" class="h-full w-full object-cover" />
|
||
</div>
|
||
|
||
<ODropdown @update:open="(v) => v && loadHistory()">
|
||
<template #trigger>
|
||
<OButton
|
||
variant="ghost"
|
||
size="sm"
|
||
class="chat-title-dropdown rounded-default hover:bg-interactive-hover-bg flex h-8 min-h-8 max-w-40 min-w-0 items-center overflow-hidden px-3 py-1.5 transition-colors duration-200"
|
||
>
|
||
<div class="flex min-w-0 items-center gap-2">
|
||
<span
|
||
class="chat-title-text text-text-body block min-w-0 truncate text-sm font-medium"
|
||
>
|
||
{{ displayedTitle || t("common.newChat") }}
|
||
<OTooltip
|
||
v-if="displayedTitle && displayedTitle.length > 25"
|
||
:sideOffset="8"
|
||
side="bottom"
|
||
align="center"
|
||
:content="raw(displayedTitle)"
|
||
/>
|
||
</span>
|
||
<OIcon name="arrow-drop-down" size="md" class="flex-shrink-0" />
|
||
</div>
|
||
</OButton>
|
||
</template>
|
||
<div class="history-menu-container relative flex max-h-112.5 w-75 flex-col">
|
||
<OSearchInput
|
||
v-model="historySearchTerm"
|
||
:placeholder="t('aiAssistant.searchChatHistory')"
|
||
class="sticky top-0 z-2 shrink-0 p-2"
|
||
/>
|
||
<div
|
||
class="history-list-container max-h-87.5 flex-1 overflow-x-hidden overflow-y-auto"
|
||
>
|
||
<ODropdownItem
|
||
v-for="chat in filteredChatHistory"
|
||
:key="chat.id"
|
||
class="history-item group relative"
|
||
@select="loadChat(chat.id)"
|
||
>
|
||
<div class="flex w-full items-center justify-between">
|
||
<div class="flex-1 overflow-hidden">
|
||
<div class="text-compact truncate">
|
||
{{ chat.title }}
|
||
</div>
|
||
<div class="text-2xs text-text-secondary">
|
||
{{ formatTime(chat.timestamp) }}
|
||
</div>
|
||
</div>
|
||
<OButton
|
||
variant="ghost"
|
||
size="icon-xs-circle"
|
||
class="delete-history-btn opacity-0 transition-opacity duration-200 group-hover:opacity-100"
|
||
@click.stop="deleteChat(chat.id)"
|
||
>
|
||
<OIcon name="delete" size="sm" />
|
||
<OTooltip :content="t('aiAssistant.deleteChatTooltip')" />
|
||
</OButton>
|
||
</div>
|
||
</ODropdownItem>
|
||
<div
|
||
v-if="filteredChatHistory.length === 0"
|
||
class="text-text-muted p-2 text-center"
|
||
>
|
||
{{ t("aiAssistant.noMatchingChatsFound") }}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Clear all conversations button -->
|
||
<div
|
||
v-if="filteredChatHistory.length > 0"
|
||
class="clear-all-container bg-surface-base shrink-0"
|
||
>
|
||
<ODropdownSeparator />
|
||
<OButton
|
||
variant="ghost-destructive"
|
||
class="clear-all-btn text-compact w-full justify-start px-3 py-1.5"
|
||
@click.stop="clearAllConversations"
|
||
>
|
||
<template #icon-left>
|
||
<OIcon name="delete-sweep" size="sm" />
|
||
</template>
|
||
{{ t("aiAssistant.clearAllConversations") }}
|
||
</OButton>
|
||
</div>
|
||
</div>
|
||
</ODropdown>
|
||
</div>
|
||
|
||
<div class="chat-header-actions flex shrink-0 items-center gap-1">
|
||
<!-- Edit title button -->
|
||
<OButton
|
||
v-if="currentChatId"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
@click.stop="openEditTitleDialog"
|
||
>
|
||
<OIcon name="edit" size="sm" />
|
||
<OTooltip :content="t('aiAssistant.editTitleTooltip')" />
|
||
</OButton>
|
||
<OButton variant="ghost" size="icon-sm" @click="addNewChat">
|
||
<OIcon name="add" size="sm" />
|
||
</OButton>
|
||
<OButton
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
data-test="ai-chat-expand-btn"
|
||
@click="toggleExpand"
|
||
>
|
||
<OIcon
|
||
:name="store.state.isAiChatExpanded ? 'close-fullscreen' : 'open-in-full'"
|
||
size="sm"
|
||
/>
|
||
<OTooltip
|
||
:content="
|
||
t('common.collapseExpandShortcut', {
|
||
action: store.state.isAiChatExpanded
|
||
? t('common.collapse')
|
||
: t('common.expand'),
|
||
shortcut: isMac ? '⌘' : 'Ctrl+',
|
||
})
|
||
"
|
||
/>
|
||
</OButton>
|
||
<OButton variant="ghost" size="icon-sm" @click="$emit('close')">
|
||
<OIcon name="close" size="sm" />
|
||
</OButton>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- History Panel -->
|
||
<ODrawer
|
||
data-test="o2-ai-chat-history-drawer"
|
||
bleed
|
||
v-model:open="showHistory"
|
||
size="sm"
|
||
:title="t('aiAssistant.chatHistory')"
|
||
>
|
||
<ul class="divide-border flex flex-col divide-y">
|
||
<li
|
||
v-for="chat in chatHistory"
|
||
:key="chat.id"
|
||
:data-test="`o2-ai-chat-history-item-${chat.id}`"
|
||
class="hover:bg-muted/50 flex cursor-pointer flex-col px-3 py-2"
|
||
@click="loadChat(chat.id)"
|
||
>
|
||
<span class="text-sm">{{ chat.title }}</span>
|
||
<span class="text-muted-foreground block text-xs">
|
||
{{ new Date(chat.timestamp).toLocaleString() }}
|
||
</span>
|
||
<span class="text-muted-foreground block text-xs">
|
||
{{ t("aiAssistant.modelLabel") }} {{ chat.model }}
|
||
</span>
|
||
</li>
|
||
</ul>
|
||
</ODrawer>
|
||
|
||
<!-- Edit Title Dialog -->
|
||
<ODialog
|
||
data-test="o2-ai-chat-edit-title-dialog"
|
||
v-model:open="showEditTitleDialog"
|
||
size="sm"
|
||
:title="t('aiAssistant.editChatTitle')"
|
||
:secondary-button-label="t('common.cancel')"
|
||
:primary-button-label="t('common.save')"
|
||
@click:secondary="showEditTitleDialog = false"
|
||
@click:primary="saveEditedTitle"
|
||
>
|
||
<OInput
|
||
v-model="editingTitle"
|
||
autofocus
|
||
@keyup.enter="saveEditedTitle"
|
||
:placeholder="t('aiAssistant.enterChatTitle')"
|
||
/>
|
||
</ODialog>
|
||
|
||
<!-- Delete Chat Confirmation Dialog -->
|
||
<ConfirmDialog
|
||
v-model="showDeleteChatConfirmDialog"
|
||
:title="t('aiAssistant.deleteChat')"
|
||
:message="t('aiAssistant.deleteChatConfirmMessage')"
|
||
@update:ok="confirmDeleteChat"
|
||
@update:cancel="showDeleteChatConfirmDialog = false"
|
||
/>
|
||
|
||
<!-- Clear All Conversations Confirmation Dialog -->
|
||
<ConfirmDialog
|
||
v-model="showClearAllConfirmDialog"
|
||
:title="t('aiAssistant.clearAllConversationsTitle')"
|
||
:message="t('aiAssistant.clearAllConversationsMessage')"
|
||
@update:ok="confirmClearAllConversations"
|
||
@update:cancel="showClearAllConfirmDialog = false"
|
||
/>
|
||
|
||
<!-- Image Preview Dialog -->
|
||
<ODialog
|
||
data-test="o2-ai-chat-image-preview-dialog"
|
||
v-model:open="showImagePreview"
|
||
@update:open="(v) => !v && closeImagePreview()"
|
||
size="lg"
|
||
:title="raw(previewImage?.filename)"
|
||
>
|
||
<div class="flex justify-center">
|
||
<img
|
||
v-if="previewImage"
|
||
:src="'data:' + previewImage.mimeType + ';base64,' + previewImage.data"
|
||
:alt="previewImage.filename"
|
||
class="max-h-[80vh] max-w-full object-contain"
|
||
/>
|
||
</div>
|
||
</ODialog>
|
||
|
||
<div
|
||
class="chat-content relative flex min-h-0 flex-1 flex-col overflow-hidden bg-transparent"
|
||
>
|
||
<div
|
||
class="messages-container mx-auto flex min-h-0 w-full max-w-225 flex-1 flex-col gap-4 overflow-y-auto bg-transparent p-2"
|
||
ref="messagesContainer"
|
||
@scroll="checkIfShouldAutoScroll"
|
||
>
|
||
<div
|
||
v-if="chatMessages.length === 0"
|
||
class="welcome-section rounded-default mb-0 flex flex-1 items-center justify-center bg-transparent p-0"
|
||
>
|
||
<!-- Home tab: rich V2 welcome -->
|
||
<O2AIHomeWelcome v-if="centeredStart" @select-prompt="selectWelcomePrompt" />
|
||
<!-- Sidepanel: minimal logo + title -->
|
||
<div v-else class="flex h-full w-full flex-col items-center justify-center">
|
||
<div class="flex flex-col items-center gap-2">
|
||
<img :src="o2AiTitleLogo" />
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-sm font-[600]">{{
|
||
t("aiAssistant.welcome.taglineHighlight")
|
||
}}</span>
|
||
<!-- Same shared Beta tag as the Workflows screens. -->
|
||
<BetaBadge />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-for="(message, index) in processedMessages"
|
||
:key="index"
|
||
class="message rounded-default border-border-default shadow-text-heading/10 border p-3 shadow-md"
|
||
:class="[
|
||
message.role,
|
||
message.role === 'user'
|
||
? 'text-text-body dark:text-text-secondary ms-10 w-[calc(100%-2.5rem)] [background:var(--color-chat-bubble-ai)]'
|
||
: 'bg-surface-base text-text-body dark:text-text-secondary ms-0 w-full',
|
||
{ 'error-message': message.content.startsWith('Error:') },
|
||
]"
|
||
>
|
||
<div class="message-content flex w-full items-start gap-1.5">
|
||
<div
|
||
v-if="message.role === 'user'"
|
||
class="text-text-inverse inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full [background:var(--color-gradient-ai)]"
|
||
>
|
||
<OIcon size="sm" name="person" class="text-text-inverse" />
|
||
</div>
|
||
<div
|
||
class="message-blocks flex max-w-full min-w-0 flex-1 flex-col gap-0 overflow-x-auto bg-transparent wrap-break-word [word-wrap:break-word]"
|
||
>
|
||
<!-- 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 text-text-secondary flex items-center gap-2.5 py-2 text-sm"
|
||
>
|
||
<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 text-text-secondary rounded-default text-compact mb-2 flex max-w-full min-w-0 flex-col px-3 py-2"
|
||
:class="[
|
||
{ '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 flex items-center gap-2">
|
||
<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'
|
||
? 'text-accent'
|
||
: 'text-warning'
|
||
: block.success === false
|
||
? 'text-status-negative'
|
||
: 'text-status-positive'
|
||
"
|
||
/>
|
||
<span class="tool-call-name flex-1 font-medium">
|
||
{{ 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 ms-auto cursor-pointer opacity-70 transition-opacity duration-200 hover:opacity-100"
|
||
@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 opacity-60 transition-transform duration-200"
|
||
/>
|
||
</div>
|
||
<!-- Expandable details -->
|
||
<div
|
||
v-if="isToolCallExpanded(index, blockIndex)"
|
||
class="tool-call-details border-border-default mt-2.5 flex min-w-0 flex-col gap-2 border-t pt-2.5"
|
||
@click.stop
|
||
>
|
||
<!-- Error details for failed tool calls -->
|
||
<template v-if="block.success === false">
|
||
<div v-if="block.resultMessage" class="detail-item flex flex-col gap-1">
|
||
<span class="detail-label text-2xs font-semibold uppercase opacity-60">{{
|
||
t("common.error")
|
||
}}</span>
|
||
<span
|
||
class="detail-value text-status-negative max-w-full min-w-0 text-xs [overflow-wrap:anywhere] break-words select-text"
|
||
>{{ block.resultMessage }}</span
|
||
>
|
||
</div>
|
||
<div v-if="block.errorType" class="detail-item flex flex-col gap-1">
|
||
<span class="detail-label text-2xs font-semibold uppercase opacity-60">{{
|
||
t("common.type")
|
||
}}</span>
|
||
<code
|
||
class="detail-value max-w-full min-w-0 text-xs [overflow-wrap:anywhere] break-words select-text"
|
||
>{{ block.errorType }}</code
|
||
>
|
||
</div>
|
||
<div v-if="block.suggestion" class="detail-item flex flex-col gap-1">
|
||
<span class="detail-label text-2xs font-semibold uppercase opacity-60">{{
|
||
t("aiAssistant.suggestion")
|
||
}}</span>
|
||
<span
|
||
class="detail-value max-w-full min-w-0 text-xs [overflow-wrap:anywhere] break-words italic opacity-85 select-text"
|
||
>{{ 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 flex flex-col gap-1"
|
||
>
|
||
<span class="detail-label text-2xs font-semibold uppercase opacity-60">{{
|
||
t("aiAssistant.results")
|
||
}}</span>
|
||
<span
|
||
class="detail-value max-w-full min-w-0 text-xs [overflow-wrap:anywhere] break-words select-text"
|
||
>{{ block.summary.count }} {{ t("aiAssistant.recordsSuffix") }}</span
|
||
>
|
||
</div>
|
||
<div
|
||
v-if="block.summary.took !== undefined"
|
||
class="detail-item flex flex-col gap-1"
|
||
>
|
||
<span class="detail-label text-2xs font-semibold uppercase opacity-60">{{
|
||
t("common.duration")
|
||
}}</span>
|
||
<span
|
||
class="detail-value max-w-full min-w-0 text-xs [overflow-wrap:anywhere] break-words select-text"
|
||
>{{ block.summary.took }}{{ t("aiAssistant.ms") }}</span
|
||
>
|
||
</div>
|
||
<!-- CLI tool summary (return_code / stdout_lines / stderr_lines / truncated) -->
|
||
<div
|
||
v-if="block.summary.return_code !== undefined"
|
||
class="detail-item flex flex-col gap-1"
|
||
>
|
||
<span class="detail-label text-2xs font-semibold uppercase opacity-60">{{
|
||
t("aiAssistant.exitCode")
|
||
}}</span>
|
||
<code
|
||
class="detail-value max-w-full min-w-0 text-xs [overflow-wrap:anywhere] break-words select-text"
|
||
>{{ block.summary.return_code }}</code
|
||
>
|
||
</div>
|
||
<div
|
||
v-if="block.summary.stdout_lines !== undefined"
|
||
class="detail-item flex flex-col gap-1"
|
||
>
|
||
<span class="detail-label text-2xs font-semibold uppercase opacity-60">{{
|
||
t("aiAssistant.stdout")
|
||
}}</span>
|
||
<span
|
||
class="detail-value max-w-full min-w-0 text-xs [overflow-wrap:anywhere] break-words select-text"
|
||
>{{ block.summary.stdout_lines }} {{ t("aiAssistant.lines") }}</span
|
||
>
|
||
</div>
|
||
<div
|
||
v-if="block.summary.stderr_lines"
|
||
class="detail-item flex flex-col gap-1"
|
||
>
|
||
<span class="detail-label text-2xs font-semibold uppercase opacity-60">{{
|
||
t("aiAssistant.stderr")
|
||
}}</span>
|
||
<span
|
||
class="detail-value max-w-full min-w-0 text-xs [overflow-wrap:anywhere] break-words select-text"
|
||
>{{ block.summary.stderr_lines }} {{ t("aiAssistant.lines") }}</span
|
||
>
|
||
</div>
|
||
<div v-if="block.summary.truncated" class="detail-item flex flex-col gap-1">
|
||
<span class="detail-label text-2xs font-semibold uppercase opacity-60">{{
|
||
t("common.output")
|
||
}}</span>
|
||
<span
|
||
class="detail-value max-w-full min-w-0 text-xs [overflow-wrap:anywhere] break-words select-text"
|
||
>{{ t("aiAssistant.truncatedLabel") }}</span
|
||
>
|
||
</div>
|
||
</template>
|
||
<!-- Existing context details -->
|
||
<div
|
||
v-if="getToolCallDisplayData(block.context)?.query"
|
||
class="detail-item flex flex-col gap-1"
|
||
>
|
||
<div class="detail-header flex items-center justify-between">
|
||
<span class="detail-label text-2xs font-semibold uppercase opacity-60">{{
|
||
t("common.query")
|
||
}}</span>
|
||
<OButton
|
||
variant="ghost"
|
||
size="icon-xs-circle"
|
||
class="copy-btn opacity-60 hover:opacity-100"
|
||
@click.stop="
|
||
copyToClipboard(getToolCallDisplayData(block.context)?.query, t)
|
||
"
|
||
>
|
||
<OIcon name="content-copy" size="sm" />
|
||
<OTooltip :content="t('aiAssistant.copyQuery')" />
|
||
</OButton>
|
||
</div>
|
||
<code
|
||
class="detail-value query-value rounded-default cursor-text p-2 font-mono text-xs break-all whitespace-pre-wrap select-text [background:color-mix(in_srgb,var(--color-text-heading)_5%,transparent)]"
|
||
>{{ getToolCallDisplayData(block.context)?.query }}</code
|
||
>
|
||
</div>
|
||
<div
|
||
v-if="getToolCallDisplayData(block.context)?.stream"
|
||
class="detail-item flex flex-col gap-1"
|
||
>
|
||
<span class="detail-label text-2xs font-semibold uppercase opacity-60">{{
|
||
t("aiAssistant.stream")
|
||
}}</span>
|
||
<code
|
||
class="detail-value max-w-full min-w-0 text-xs [overflow-wrap:anywhere] break-words select-text"
|
||
>{{ getToolCallDisplayData(block.context)?.stream }}</code
|
||
>
|
||
</div>
|
||
<div
|
||
v-if="getToolCallDisplayData(block.context)?.type"
|
||
class="detail-item flex flex-col gap-1"
|
||
>
|
||
<span class="detail-label text-2xs font-semibold uppercase opacity-60">{{
|
||
t("common.type")
|
||
}}</span>
|
||
<code
|
||
class="detail-value max-w-full min-w-0 text-xs [overflow-wrap:anywhere] break-words select-text"
|
||
>{{ getToolCallDisplayData(block.context)?.type }}</code
|
||
>
|
||
</div>
|
||
<div
|
||
v-if="getToolCallDisplayData(block.context)?.start_time"
|
||
class="detail-item flex flex-col gap-1"
|
||
>
|
||
<span class="detail-label text-2xs font-semibold uppercase opacity-60">{{
|
||
t("aiAssistant.start")
|
||
}}</span>
|
||
<span
|
||
class="detail-value max-w-full min-w-0 text-xs [overflow-wrap:anywhere] break-words select-text"
|
||
>{{
|
||
formatTimestamp(getToolCallDisplayData(block.context)?.start_time)
|
||
}}</span
|
||
>
|
||
</div>
|
||
<div
|
||
v-if="getToolCallDisplayData(block.context)?.end_time"
|
||
class="detail-item flex flex-col gap-1"
|
||
>
|
||
<span class="detail-label text-2xs font-semibold uppercase opacity-60">{{
|
||
t("aiAssistant.end")
|
||
}}</span>
|
||
<span
|
||
class="detail-value max-w-full min-w-0 text-xs [overflow-wrap:anywhere] break-words select-text"
|
||
>{{
|
||
formatTimestamp(getToolCallDisplayData(block.context)?.end_time)
|
||
}}</span
|
||
>
|
||
</div>
|
||
<div
|
||
v-if="getToolCallDisplayData(block.context)?.from !== undefined"
|
||
class="detail-item flex flex-col gap-1"
|
||
>
|
||
<span class="detail-label text-2xs font-semibold uppercase opacity-60">{{
|
||
t("aiAssistant.from")
|
||
}}</span>
|
||
<span
|
||
class="detail-value max-w-full min-w-0 text-xs [overflow-wrap:anywhere] break-words select-text"
|
||
>{{ getToolCallDisplayData(block.context)?.from }}</span
|
||
>
|
||
</div>
|
||
<div
|
||
v-if="getToolCallDisplayData(block.context)?.size !== undefined"
|
||
class="detail-item flex flex-col gap-1"
|
||
>
|
||
<span class="detail-label text-2xs font-semibold uppercase opacity-60">{{
|
||
t("aiAssistant.size")
|
||
}}</span>
|
||
<span
|
||
class="detail-value max-w-full min-w-0 text-xs [overflow-wrap:anywhere] break-words select-text"
|
||
>{{ getToolCallDisplayData(block.context)?.size }}</span
|
||
>
|
||
</div>
|
||
<div
|
||
v-if="getToolCallDisplayData(block.context)?.query_type"
|
||
class="detail-item flex flex-col gap-1"
|
||
>
|
||
<span class="detail-label text-2xs font-semibold uppercase opacity-60">{{
|
||
t("aiAssistant.queryType")
|
||
}}</span>
|
||
<code
|
||
class="detail-value max-w-full min-w-0 text-xs [overflow-wrap:anywhere] break-words select-text"
|
||
>{{ getToolCallDisplayData(block.context)?.query_type }}</code
|
||
>
|
||
</div>
|
||
<div
|
||
v-if="getToolCallDisplayData(block.context)?.vrl"
|
||
class="detail-item flex flex-col gap-1"
|
||
>
|
||
<div class="detail-header flex items-center justify-between">
|
||
<span class="detail-label text-2xs font-semibold uppercase opacity-60">{{
|
||
t("aiAssistant.welcome.taglineVrl")
|
||
}}</span>
|
||
<OButton
|
||
variant="ghost"
|
||
size="icon-xs-circle"
|
||
class="copy-btn opacity-60 hover:opacity-100"
|
||
@click.stop="
|
||
copyToClipboard(getToolCallDisplayData(block.context)?.vrl, t)
|
||
"
|
||
>
|
||
<OIcon name="content-copy" size="sm" />
|
||
<OTooltip :content="t('aiAssistant.copyVrl')" />
|
||
</OButton>
|
||
</div>
|
||
<code
|
||
class="detail-value query-value rounded-default cursor-text p-2 font-mono text-xs break-all whitespace-pre-wrap select-text [background:color-mix(in_srgb,var(--color-text-heading)_5%,transparent)]"
|
||
>{{ getToolCallDisplayData(block.context)?.vrl }}</code
|
||
>
|
||
</div>
|
||
<div
|
||
v-if="getToolCallDisplayData(block.context)?.command"
|
||
class="detail-item flex flex-col gap-1"
|
||
>
|
||
<div class="detail-header flex items-center justify-between">
|
||
<span class="detail-label text-2xs font-semibold uppercase opacity-60">{{
|
||
t("aiAssistant.command")
|
||
}}</span>
|
||
<OButton
|
||
variant="ghost"
|
||
size="icon-xs-circle"
|
||
class="copy-btn opacity-60 hover:opacity-100"
|
||
@click.stop="
|
||
copyToClipboard(getToolCallDisplayData(block.context)?.command, t)
|
||
"
|
||
>
|
||
<OIcon name="content-copy" size="sm" />
|
||
<OTooltip :content="t('aiAssistant.copyCommand')" />
|
||
</OButton>
|
||
</div>
|
||
<code
|
||
class="detail-value query-value rounded-default cursor-text p-2 font-mono text-xs break-all whitespace-pre-wrap select-text [background:color-mix(in_srgb,var(--color-text-heading)_5%,transparent)]"
|
||
>{{ getToolCallDisplayData(block.context)?.command }}</code
|
||
>
|
||
</div>
|
||
<!-- Tool response: SearchSQL hits -->
|
||
<template v-if="block.response && block.response.hits">
|
||
<div class="detail-item flex flex-col gap-1">
|
||
<div class="detail-header flex items-center justify-between">
|
||
<span
|
||
class="detail-label text-2xs font-semibold uppercase opacity-60"
|
||
>{{ t("aiAssistant.results") }}</span
|
||
>
|
||
<OButton
|
||
variant="ghost"
|
||
size="icon-xs-circle"
|
||
class="copy-btn opacity-60 hover:opacity-100"
|
||
@click.stop="
|
||
copyToClipboard(JSON.stringify(block.response.hits, null, 2), t)
|
||
"
|
||
>
|
||
<OIcon name="content-copy" size="sm" />
|
||
<OTooltip :content="t('aiAssistant.copyResults')" />
|
||
</OButton>
|
||
</div>
|
||
<div
|
||
class="tool-response-hits rounded-default flex max-h-50 flex-col gap-1 overflow-y-auto px-2 py-1.5 font-mono text-xs [background:color-mix(in_srgb,var(--color-text-heading)_5%,transparent)]"
|
||
>
|
||
<div
|
||
v-for="(hit, hIdx) in block.response.hits"
|
||
:key="hIdx"
|
||
class="tool-response-hit [&:not(:last-child)]:border-border-default flex flex-wrap gap-x-3 gap-y-1 py-0.5 [&:not(:last-child)]:border-b [&:not(:last-child)]:pb-1"
|
||
>
|
||
<span
|
||
v-for="(val, key) in hit"
|
||
:key="key"
|
||
class="hit-field cursor-text break-all select-text"
|
||
>
|
||
<span class="hit-key font-semibold opacity-60">{{ key }}:</span>
|
||
{{ val }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="tool-response-meta mt-1 flex flex-wrap gap-1.5">
|
||
<span v-if="block.response.total !== undefined" class="context-tag"
|
||
>{{ t("aiAssistant.total") }} {{ block.response.total }}</span
|
||
>
|
||
<span v-if="block.response.took !== undefined" class="context-tag"
|
||
>{{ t("aiAssistant.took") }} {{ block.response.took
|
||
}}{{ t("aiAssistant.ms") }}</span
|
||
>
|
||
<span v-if="block.response.hits_truncated" class="context-tag"
|
||
>{{ t("aiAssistant.showingFirst") }}
|
||
{{ 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 flex flex-col gap-1">
|
||
<span class="detail-label text-2xs font-semibold uppercase opacity-60">{{
|
||
t("aiAssistant.inputEvents")
|
||
}}</span>
|
||
<div
|
||
class="tool-response-hits rounded-default flex max-h-50 flex-col gap-1 overflow-y-auto px-2 py-1.5 font-mono text-xs [background:color-mix(in_srgb,var(--color-text-heading)_5%,transparent)]"
|
||
>
|
||
<div
|
||
v-for="(evt, eIdx) in block.response.input"
|
||
:key="eIdx"
|
||
class="tool-response-hit [&:not(:last-child)]:border-border-default flex flex-wrap gap-x-3 gap-y-1 py-0.5 [&:not(:last-child)]:border-b [&:not(:last-child)]:pb-1"
|
||
>
|
||
<span
|
||
v-for="(val, key) in evt"
|
||
:key="key"
|
||
class="hit-field cursor-text break-all select-text"
|
||
>
|
||
<span class="hit-key font-semibold opacity-60">{{ 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 flex flex-col gap-1">
|
||
<span class="detail-label text-2xs font-semibold uppercase opacity-60">{{
|
||
t("common.output")
|
||
}}</span>
|
||
<div
|
||
class="tool-response-hits rounded-default flex max-h-50 flex-col gap-1 overflow-y-auto px-2 py-1.5 font-mono text-xs [background:color-mix(in_srgb,var(--color-text-heading)_5%,transparent)]"
|
||
>
|
||
<div
|
||
v-for="(res, rIdx) in block.response.output"
|
||
:key="rIdx"
|
||
class="tool-response-hit [&:not(:last-child)]:border-border-default flex flex-wrap gap-x-3 gap-y-1 py-0.5 [&:not(:last-child)]:border-b [&:not(:last-child)]:pb-1"
|
||
>
|
||
<template v-if="res.event">
|
||
<span
|
||
v-for="(val, key) in res.event"
|
||
:key="key"
|
||
class="hit-field cursor-text break-all select-text"
|
||
>
|
||
<span class="hit-key font-semibold opacity-60">{{ key }}:</span>
|
||
{{
|
||
typeof val === "string" && val.length > 120
|
||
? val.substring(0, 120) + "..."
|
||
: val
|
||
}}
|
||
</span>
|
||
</template>
|
||
<span
|
||
v-if="res.message"
|
||
class="hit-field text-status-negative cursor-text break-all select-text"
|
||
>
|
||
<span class="hit-key font-semibold opacity-60">{{
|
||
t("aiAssistant.errorLabel")
|
||
}}</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 flex flex-col gap-1"
|
||
>
|
||
<div class="detail-header flex items-center justify-between">
|
||
<span
|
||
class="detail-label text-2xs font-semibold uppercase opacity-60"
|
||
>{{ t("aiAssistant.items") }}</span
|
||
>
|
||
<OButton
|
||
variant="ghost"
|
||
size="icon-xs-circle"
|
||
class="copy-btn opacity-60 hover:opacity-100"
|
||
@click.stop="
|
||
copyToClipboard(JSON.stringify(block.response.items, null, 2), t)
|
||
"
|
||
>
|
||
<OIcon name="content-copy" size="sm" />
|
||
<OTooltip :content="t('aiAssistant.copyItems')" />
|
||
</OButton>
|
||
</div>
|
||
<div
|
||
class="tool-response-hits rounded-default flex max-h-50 flex-col gap-1 overflow-y-auto px-2 py-1.5 font-mono text-xs [background:color-mix(in_srgb,var(--color-text-heading)_5%,transparent)]"
|
||
>
|
||
<div
|
||
v-for="(item, iIdx) in block.response.items"
|
||
:key="iIdx"
|
||
class="tool-response-list-item [&:not(:last-child)]:border-border-default flex flex-col gap-0.5 py-1 [&:not(:last-child)]:border-b [&:not(:last-child)]:pb-1.5"
|
||
>
|
||
<div
|
||
v-for="(val, key) in item"
|
||
:key="key"
|
||
class="hit-field cursor-text break-all select-text"
|
||
>
|
||
<span class="hit-key font-semibold opacity-60">{{ 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 flex flex-col gap-1">
|
||
<div class="detail-header flex items-center justify-between">
|
||
<span class="detail-label text-2xs font-semibold uppercase opacity-60">{{
|
||
t("aiAssistant.response")
|
||
}}</span>
|
||
<OButton
|
||
variant="ghost"
|
||
size="icon-xs-circle"
|
||
class="copy-btn opacity-60 hover:opacity-100"
|
||
@click.stop="
|
||
copyToClipboard(
|
||
typeof block.response === 'string'
|
||
? block.response
|
||
: JSON.stringify(block.response, null, 2),
|
||
t,
|
||
)
|
||
"
|
||
>
|
||
<OIcon name="content-copy" size="sm" />
|
||
<OTooltip :content="t('aiAssistant.copyResponse')" />
|
||
</OButton>
|
||
</div>
|
||
<code
|
||
class="detail-value query-value rounded-default cursor-text p-2 font-mono text-xs break-all whitespace-pre-wrap select-text [background:color-mix(in_srgb,var(--color-text-heading)_5%,transparent)]"
|
||
>{{
|
||
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 rounded-default text-text-secondary dark:bg-surface-panel dark:border-border-default dark:hover:bg-surface-panel dark:hover:border-text-secondary mb-1 flex cursor-pointer flex-col px-2.5 py-1.5 text-xs [background:color-mix(in_srgb,var(--color-info)_8%,transparent)] hover:[background:color-mix(in_srgb,var(--color-info)_12%,transparent)] dark:border"
|
||
@click="toggleLogEntryExpanded(index, blockIndex)"
|
||
>
|
||
<div class="log-entry-header flex items-center gap-1.5">
|
||
<OIcon name="description" size="xs" />
|
||
<span
|
||
class="log-entry-info flex-1 overflow-hidden text-xs font-medium text-ellipsis whitespace-nowrap"
|
||
>
|
||
{{ block.preview }}
|
||
</span>
|
||
<OIcon
|
||
:name="
|
||
isLogEntryExpanded(index, blockIndex) ? 'expand-less' : 'expand-more'
|
||
"
|
||
size="sm"
|
||
class="expand-icon opacity-60 transition-transform duration-200"
|
||
/>
|
||
</div>
|
||
<!-- Expandable details -->
|
||
<div
|
||
v-if="isLogEntryExpanded(index, blockIndex)"
|
||
class="log-entry-details mt-2.5"
|
||
@click.stop
|
||
>
|
||
<div
|
||
class="log-entry-content rounded-default bg-surface-base border-border-default dark:bg-surface-panel relative overflow-hidden border shadow-sm dark:shadow-sm"
|
||
>
|
||
<OButton
|
||
variant="ghost"
|
||
size="icon-xs-circle"
|
||
class="copy-btn rounded-default absolute top-2 right-2 z-1 px-2 py-1 opacity-60 [background:color-mix(in_srgb,var(--color-text-heading)_10%,transparent)] hover:opacity-100 hover:[background:color-mix(in_srgb,var(--color-text-heading)_8%,transparent)] dark:hover:[background:color-mix(in_srgb,var(--color-text-heading)_15%,transparent)]"
|
||
@click.stop="copyToClipboard(block.content, t)"
|
||
>
|
||
<OIcon name="content-copy" size="sm" />
|
||
<OTooltip :content="t('aiAssistant.copyContent')" />
|
||
</OButton>
|
||
<code
|
||
class="log-entry-code text-2xs bg-surface-base text-text-body dark:text-text-secondary block max-h-75 cursor-text overflow-y-auto p-3 pe-10 font-mono leading-relaxed whitespace-pre-wrap select-text [word-wrap:break-word] dark:[background:var(--color-syntax-bg)]"
|
||
v-html="formatLogEntryContent(block.content)"
|
||
></code>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Stream-level error block -->
|
||
<div
|
||
v-else-if="block.type === 'error'"
|
||
class="stream-error-block rounded-default border-border-default text-compact text-text-secondary mb-2 flex flex-col border-s-3 px-3 py-2.5 [background:color-mix(in_srgb,var(--color-status-negative)_6%,transparent)] dark:[background:color-mix(in_srgb,var(--color-status-negative)_10%,transparent)]"
|
||
>
|
||
<div class="stream-error-header flex items-center gap-2">
|
||
<OIcon name="warning" size="sm" />
|
||
<span class="stream-error-message text-status-negative font-medium">{{
|
||
block.message
|
||
}}</span>
|
||
</div>
|
||
<div
|
||
v-if="block.suggestion"
|
||
class="stream-error-suggestion mt-1.5 ps-6 text-xs italic opacity-85"
|
||
>
|
||
{{ block.suggestion }}
|
||
</div>
|
||
<div
|
||
v-if="block.recoverable"
|
||
class="stream-error-recoverable text-2xs mt-1 ps-6 opacity-70"
|
||
>
|
||
{{ t("aiAssistant.errorMayBeTemporary") }}
|
||
</div>
|
||
</div>
|
||
<!-- Navigation block - standalone navigation button -->
|
||
<div
|
||
v-else-if="block.type === 'navigation' && block.navigationAction"
|
||
class="navigation-block my-1 [background:color-mix(in_srgb,var(--color-info)_8%,transparent)] dark:[background:color-mix(in_srgb,var(--color-info)_12%,transparent)]"
|
||
>
|
||
<OButton
|
||
variant="primary"
|
||
size="xs"
|
||
class="navigation-block-btn text-compact"
|
||
@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 rounded-default m-0 overflow-hidden"
|
||
>
|
||
<div
|
||
class="code-block-header bg-surface-subtle flex items-center justify-between px-2 py-1"
|
||
>
|
||
<span
|
||
v-if="textBlock.language"
|
||
class="code-type-label rounded-default text-theme-accent dark:text-text-secondary px-1.5 py-0.5 text-xs font-semibold [background:color-mix(in_srgb,var(--color-theme-accent)_10%,transparent)]"
|
||
>
|
||
{{ getLanguageDisplay(textBlock.language) }}
|
||
</span>
|
||
<OButton
|
||
variant="ghost"
|
||
size="xs"
|
||
class="copy-button"
|
||
@click="copyToClipboard(textBlock.content, t)"
|
||
>
|
||
<OIcon size="sm" name="content-copy" />
|
||
<span class="ms-1">{{ t("common.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 flex w-full items-center justify-between px-2 py-1"
|
||
>
|
||
<OButton
|
||
variant="ghost"
|
||
size="xs"
|
||
class="retry-button"
|
||
@click="retryGeneration(message)"
|
||
>
|
||
<OIcon size="sm" name="refresh" />
|
||
<span class="ms-1">{{ t("common.retry") }}</span>
|
||
</OButton>
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-else
|
||
class="text-block w-full max-w-full wrap-break-word [&:not(:last-child)]:mb-1"
|
||
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 mb-2 flex flex-wrap gap-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="rounded-default border-border-default max-h-37.5 max-w-50 cursor-pointer border object-contain [transition:transform_0.2s_ease,box-shadow_0.2s_ease] hover:scale-102 hover:shadow-md"
|
||
@click="openImagePreview(img)"
|
||
/>
|
||
<OTooltip :content="raw(img.filename)" />
|
||
</div>
|
||
</div>
|
||
<template v-for="(block, blockIndex) in message.blocks" :key="'fb-' + blockIndex">
|
||
<div
|
||
v-if="block.type === 'code'"
|
||
class="code-block rounded-default m-0 overflow-hidden"
|
||
>
|
||
<div
|
||
class="code-block-header bg-surface-subtle flex items-center justify-between px-2 py-1"
|
||
>
|
||
<span
|
||
v-if="block.language"
|
||
class="code-type-label rounded-default text-theme-accent dark:text-text-secondary px-1.5 py-0.5 text-xs font-semibold [background:color-mix(in_srgb,var(--color-theme-accent)_10%,transparent)]"
|
||
>
|
||
{{ getLanguageDisplay(block.language) }}
|
||
</span>
|
||
<OButton
|
||
variant="ghost"
|
||
size="xs"
|
||
class="copy-button"
|
||
@click="copyToClipboard(block.content, t)"
|
||
>
|
||
<OIcon size="sm" name="content-copy" />
|
||
<span class="ms-1">{{ t("common.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 w-full max-w-full wrap-break-word [&:not(:last-child)]:mb-1"
|
||
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 mt-1 flex items-center gap-0.5 *:transition-opacity *:duration-200 [&>*:hover]:opacity-100"
|
||
:class="message.feedback ? '*:opacity-100' : '*:opacity-50'"
|
||
>
|
||
<OButton
|
||
variant="ghost"
|
||
size="icon-xs-circle"
|
||
:disabled="message.feedback === 'thumbs_up'"
|
||
:class="message.feedback === 'thumbs_up' ? 'text-accent opacity-100!' : ''"
|
||
data-test="o2-ai-chat-thumbs-up-btn"
|
||
@click="likeCodeBlock(index)"
|
||
>
|
||
<OIcon name="thumb-up-off-alt" size="xs" />
|
||
<OTooltip :content="t('aiAssistant.helpful')" />
|
||
</OButton>
|
||
<OButton
|
||
variant="ghost"
|
||
size="icon-xs-circle"
|
||
:disabled="message.feedback === 'thumbs_down'"
|
||
:class="message.feedback === 'thumbs_down' ? 'text-accent opacity-100!' : ''"
|
||
data-test="o2-ai-chat-thumbs-down-btn"
|
||
@click="dislikeCodeBlock(index)"
|
||
>
|
||
<OIcon name="thumb-down-off-alt" size="xs" />
|
||
<OTooltip :content="t('aiAssistant.notHelpful')" />
|
||
</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 rounded-default border-border-default my-1 flex items-center border px-4 py-2 [background:var(--color-chat-bubble-user)]"
|
||
>
|
||
<div class="tool-call-content flex w-full items-center gap-3">
|
||
<OIcon :name="block.success === false ? 'error' : 'check-circle'" size="sm" />
|
||
<div class="tool-call-info flex min-w-0 flex-1 flex-col gap-1.5">
|
||
<span
|
||
class="tool-call-message text-text-secondary text-sm font-medium opacity-85"
|
||
>{{ block.message }}</span
|
||
>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Tool call indicator - shows outside message box -->
|
||
<div
|
||
v-if="activeToolCall"
|
||
class="tool-call-indicator rounded-default border-border-default my-2 flex items-center border px-4 py-3 [background:var(--color-chat-bubble-user)]"
|
||
>
|
||
<div class="tool-call-content flex w-full items-center gap-3">
|
||
<OSpinner variant="dots" size="xs" />
|
||
<div class="tool-call-info flex min-w-0 flex-1 flex-col gap-1.5">
|
||
<span class="tool-call-message text-text-secondary text-sm font-semibold">{{
|
||
activeToolCall.message
|
||
}}</span>
|
||
<div
|
||
v-if="getToolCallDisplayData(activeToolCall.context)"
|
||
class="tool-call-context flex flex-wrap items-center gap-2"
|
||
>
|
||
<div
|
||
v-if="getToolCallDisplayData(activeToolCall.context)?.query"
|
||
class="context-item w-full"
|
||
>
|
||
<code
|
||
class="context-query rounded-default bg-surface-base border-border-default text-text-body dark:text-text-secondary block max-w-full overflow-hidden border px-3 py-2 font-mono text-xs break-all whitespace-pre-wrap"
|
||
>{{
|
||
truncateQuery(getToolCallDisplayData(activeToolCall.context)?.query)
|
||
}}</code
|
||
>
|
||
</div>
|
||
<div
|
||
v-if="
|
||
getToolCallDisplayData(activeToolCall.context)?.vrl &&
|
||
!getToolCallDisplayData(activeToolCall.context)?.query
|
||
"
|
||
class="context-item w-full"
|
||
>
|
||
<code
|
||
class="context-query rounded-default bg-surface-base border-border-default text-text-body dark:text-text-secondary block max-w-full overflow-hidden border px-3 py-2 font-mono text-xs break-all whitespace-pre-wrap"
|
||
>{{
|
||
truncateQuery(getToolCallDisplayData(activeToolCall.context)?.vrl)
|
||
}}</code
|
||
>
|
||
</div>
|
||
<span
|
||
v-if="getToolCallDisplayData(activeToolCall.context)?.stream"
|
||
class="context-tag text-2xs rounded-default text-ai-accent dark:text-text-secondary inline-flex items-center px-2 py-1 font-medium [background:color-mix(in_srgb,var(--color-ai-accent)_10%,transparent)] dark:[background:color-mix(in_srgb,var(--color-ai-accent)_20%,transparent)]"
|
||
>
|
||
{{ t("aiAssistant.streamPrefix") }}
|
||
{{ getToolCallDisplayData(activeToolCall.context)?.stream }}
|
||
</span>
|
||
<span
|
||
v-if="getToolCallDisplayData(activeToolCall.context)?.query_type"
|
||
class="context-tag text-2xs rounded-default text-ai-accent dark:text-text-secondary inline-flex items-center px-2 py-1 font-medium [background:color-mix(in_srgb,var(--color-ai-accent)_10%,transparent)] dark:[background:color-mix(in_srgb,var(--color-ai-accent)_20%,transparent)]"
|
||
>
|
||
{{ t("aiAssistant.typePrefix") }}
|
||
{{ 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 rounded-default border-border-default my-2 flex items-center border px-4 py-3 [background:var(--color-chat-bubble-user)]"
|
||
>
|
||
<div class="tool-call-content flex w-full items-center gap-3">
|
||
<OSpinner variant="dots" size="xs" />
|
||
<span class="tool-call-message text-text-secondary text-sm font-semibold">{{
|
||
currentAnalyzingMessage
|
||
}}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Scroll to bottom button -->
|
||
<div
|
||
v-show="showScrollToBottom"
|
||
class="scroll-to-bottom-container pointer-events-none absolute bottom-2.5 left-1/2 z-1000 -translate-x-1/2 [transition:all_0.3s_ease]"
|
||
>
|
||
<OButton
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
class="scroll-to-bottom-btn border-text-link! text-text-link! bg-surface-base! dark:border-ai-accent! dark:text-ai-accent! dark:bg-surface-base! hover:border-text-link! hover:text-text-link! hover:bg-surface-base! dark:hover:border-ai-accent! dark:hover:text-ai-accent! dark:hover:bg-surface-base! pointer-events-auto border-2! shadow-sm [backdrop-filter:blur(0.5rem)] transition-all duration-300 hover:scale-110 hover:shadow-md active:scale-100"
|
||
@click="scrollToBottomSmooth"
|
||
>
|
||
<OIcon name="arrow-downward" size="sm" />
|
||
<OTooltip side="top" align="center" :content="t('aiAssistant.scrollToBottom')" />
|
||
</OButton>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Fixed loading indicator above input - only shown when scrolled up -->
|
||
<div
|
||
v-if="(isLoading || activeToolCall) && showScrollToBottom"
|
||
class="fixed-analyzing-indicator rounded-default border-border-default mx-4 mb-2 flex items-center justify-center border px-4 py-3 shadow-sm [background:var(--color-chat-bubble-user)]"
|
||
>
|
||
<!-- Show tool call if active -->
|
||
<div
|
||
v-if="activeToolCall"
|
||
class="analyzing-content flex w-full max-w-225 items-center gap-3"
|
||
>
|
||
<OSpinner variant="dots" size="xs" />
|
||
<span class="analyzing-message text-theme-accent text-sm font-medium">{{
|
||
activeToolCall.message
|
||
}}</span>
|
||
</div>
|
||
<!-- Show analyzing message if loading but no active tool call -->
|
||
<div
|
||
v-else-if="isLoading"
|
||
class="analyzing-content flex w-full max-w-225 items-center gap-3"
|
||
>
|
||
<OSpinner variant="dots" size="xs" />
|
||
<span class="analyzing-message text-theme-accent text-sm font-medium">{{
|
||
currentAnalyzingMessage
|
||
}}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="chat-input-container relative mx-auto my-2 w-full max-w-225 shrink-0 px-2">
|
||
<!-- Confirmation dialog -->
|
||
<O2AIConfirmDialog
|
||
:visible="pendingConfirmation !== null"
|
||
:confirmation="pendingConfirmation"
|
||
@confirm="handleToolConfirm"
|
||
@cancel="handleToolCancel"
|
||
@always-confirm="handleToolAlwaysConfirm"
|
||
/>
|
||
|
||
<!-- Hidden file input for image upload -->
|
||
<input
|
||
ref="imageInputRef"
|
||
type="file"
|
||
accept="image/png,image/jpeg"
|
||
multiple
|
||
class="hidden"
|
||
@change="handleImageSelect"
|
||
/>
|
||
|
||
<div
|
||
v-if="!pendingConfirmation"
|
||
class="unified-input-box rounded-default bg-surface-base border-border-default focus-within:ring-accent flex flex-col gap-3 border px-2 py-1 transition-all duration-200 focus-within:border-transparent focus-within:ring-2"
|
||
@dragover="handleDragOver"
|
||
@drop="handleDrop"
|
||
@paste="handlePaste"
|
||
>
|
||
<!-- Image preview strip -->
|
||
<div
|
||
v-if="pendingImages.length > 0"
|
||
class="image-preview-strip mb-2 flex flex-wrap gap-2 py-2"
|
||
>
|
||
<div
|
||
v-for="(img, index) in pendingImages"
|
||
:key="index"
|
||
class="image-preview-item relative inline-block"
|
||
>
|
||
<img
|
||
:src="'data:' + img.mimeType + ';base64,' + img.data"
|
||
:alt="img.filename"
|
||
class="preview-image rounded-default border-border-default h-16 w-16 border object-cover [transition:transform_0.2s_ease] hover:scale-105"
|
||
/>
|
||
<OButton
|
||
variant="ghost"
|
||
size="icon-xs-circle"
|
||
class="image-remove-btn bg-status-negative! hover:bg-status-negative! absolute! -top-1.5! -right-1.5! z-10 h-5! min-h-5! w-5! min-w-5! p-0!"
|
||
@click.stop="removeImage(index)"
|
||
>
|
||
<OIcon name="close" size="xs" />
|
||
</OButton>
|
||
<OTooltip
|
||
:content="
|
||
t('common.fileWithSize', {
|
||
name: img.filename,
|
||
size: (img.size / 1024).toFixed(0),
|
||
})
|
||
"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<RichTextInput
|
||
ref="chatInput"
|
||
v-model="inputMessage"
|
||
:placeholder="raw(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 flex items-center justify-between pt-2">
|
||
<div class="flex items-center gap-2">
|
||
<!-- Image upload button -->
|
||
<OButton
|
||
v-if="!isLoading"
|
||
@click.stop="triggerImageUpload"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
class="image-upload-btn opacity-70 transition-opacity duration-200 hover:opacity-100"
|
||
>
|
||
<OIcon name="image" size="sm" class="text-icon-color" />
|
||
<OTooltip :content="t('aiAssistant.attachImageTooltip')" />
|
||
</OButton>
|
||
<div v-else class="w-8"></div>
|
||
|
||
<!-- Auto navigation toggle button -->
|
||
<OButton
|
||
v-if="!isLoading"
|
||
@click.stop="isAutoNavigationEnabled = !isAutoNavigationEnabled"
|
||
variant="ghost"
|
||
size="sm"
|
||
class="auto-nav-toggle-btn rounded-default hover:bg-surface-subtle flex items-center gap-1.5 px-2 py-1 transition-all duration-200"
|
||
>
|
||
<OIcon
|
||
:name="isAutoNavigationEnabled ? 'check-circle' : 'radio-button-unchecked'"
|
||
size="sm"
|
||
:class="[
|
||
'auto-nav-icon',
|
||
isAutoNavigationEnabled ? 'text-theme-accent!' : 'text-icon-color',
|
||
]"
|
||
/>
|
||
<span
|
||
class="auto-nav-label ms-1 text-xs font-medium"
|
||
:class="isAutoNavigationEnabled ? 'text-theme-accent' : 'text-text-secondary'"
|
||
>{{ t("aiAssistant.autoNavigation.label") }}</span
|
||
>
|
||
<OTooltip
|
||
:content="
|
||
isAutoNavigationEnabled
|
||
? t('aiAssistant.autoNavigation.enabledTooltip')
|
||
: t('aiAssistant.autoNavigation.disabledTooltip')
|
||
"
|
||
/>
|
||
</OButton>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-2">
|
||
<!-- Send button - shown when not loading -->
|
||
<OButton
|
||
v-if="!isLoading"
|
||
:disabled="!inputMessage.trim() && pendingImages.length === 0"
|
||
@click="sendMessage"
|
||
variant="primary"
|
||
size="icon-xs-circle"
|
||
class="send-button hover:bg-gradient-ai!"
|
||
>
|
||
<OIcon name="arrow-upward" size="sm" />
|
||
</OButton>
|
||
|
||
<!-- Stop button - shown when loading/streaming -->
|
||
<OButton
|
||
v-if="isLoading"
|
||
@click="cancelCurrentRequest"
|
||
variant="ghost"
|
||
size="icon-xs-circle"
|
||
class="stop-button shadow-status-negative/30! hover:shadow-status-negative/40! active:shadow-status-negative/30! bg-gradient-danger! hover:bg-gradient-danger-hover! shadow-lg! [transition:all_0.3s_ease]! hover:-translate-y-px! hover:shadow-lg! active:translate-y-0! active:shadow-md!"
|
||
>
|
||
<OIcon name="stop" size="sm" />
|
||
</OButton>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script lang="ts">
|
||
import {
|
||
defineComponent,
|
||
ref,
|
||
reactive,
|
||
onMounted,
|
||
nextTick,
|
||
watch,
|
||
computed,
|
||
onUnmounted,
|
||
} from "vue";
|
||
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
|
||
import { useRouter, useRoute } 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 { useTheme } from "@/composables/useTheme";
|
||
import useAiChat from "@/composables/useAiChat";
|
||
import { getImageURL, getUUIDv7 } from "@/utils/zincutils";
|
||
import { chartColor } from "@/utils/chartTheme";
|
||
import {
|
||
ChatMessage,
|
||
ChatHistoryEntry,
|
||
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 BetaBadge from "@/components/common/BetaBadge.vue";
|
||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||
import ODrawer from "@/lib/overlay/Drawer/ODrawer.vue";
|
||
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
|
||
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
|
||
import 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 { UNAUTHORIZED_MESSAGE_KEY, 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);
|
||
}
|
||
|
||
// --- Shared, cross-instance streaming registry ---
|
||
// O2AIChat is instantiated more than once (the Home page's inline AI tab and
|
||
// the sidebar panel in MainLayout are SEPARATE component instances). When the
|
||
// user starts a chat on Home and navigates to another page, the Home instance
|
||
// unmounts and the sidebar instance mounts — a brand new setup() scope.
|
||
//
|
||
// For an in-flight stream to keep rendering after that hand-off, the detach/
|
||
// re-attach bookkeeping MUST live outside setup() so both instances see the
|
||
// same live array + AbortController. When these were per-instance, the sidebar
|
||
// instance's map was empty, so loadChat() never re-attached and fell back to
|
||
// the stale IndexedDB snapshot — the stream kept running but its text never
|
||
// rendered in the new instance. Module scope is what makes the hand-off work.
|
||
const backgroundStreams = new Set<AbortController>();
|
||
const MAX_BACKGROUND_STREAMS = 3;
|
||
|
||
// Map sessionId → live stream context for re-attachment when a (possibly
|
||
// different) instance loads the same session. loadChat swaps chatMessages.value
|
||
// back to `msgs` so 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;
|
||
}
|
||
>();
|
||
|
||
// Cross-instance streaming status, keyed by sessionId. processStream runs in the
|
||
// closure of the instance that STARTED it, so its completion resets isLoading on
|
||
// THAT instance's ref — not on a different instance that re-attached to the same
|
||
// stream (e.g. the sidebar taking over from the Home tab). Each instance watches
|
||
// this shared reactive map for its current session and clears its own streaming
|
||
// UI when the background turn finishes, so the sidebar's loading indicator
|
||
// doesn't hang forever after re-attaching. true = streaming, false/absent = done.
|
||
const sessionStreamingState = reactive<Record<string, boolean>>({});
|
||
|
||
// Detached streams deliberately outlive the component that started them, and
|
||
// this registry is module scope, so nothing else will ever stop them. Call when
|
||
// the turn is no longer authorized for what it is writing — org switch, logout
|
||
// — never for ordinary navigation, which is the case detaching exists for.
|
||
const abortBackgroundStreams = () => {
|
||
for (const controller of backgroundStreams) controller.abort();
|
||
backgroundStreams.clear();
|
||
backgroundStreamMap.clear();
|
||
for (const key of Object.keys(sessionStreamingState)) {
|
||
delete sessionStreamingState[key];
|
||
}
|
||
};
|
||
|
||
export default defineComponent({
|
||
name: "O2AIChat",
|
||
components: {
|
||
OButton,
|
||
BetaBadge,
|
||
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 route = useRoute();
|
||
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 currentStreamingMessage = ref("");
|
||
const currentTextSegment = ref(""); // Track current text segment (resets after each tool call)
|
||
const showHistory = ref(false);
|
||
// `model` is stored on persisted entries but missing from the shared interface
|
||
const chatHistory = ref<(ChatHistoryEntry & { model?: string })[]>([]);
|
||
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 { isDark } = useTheme();
|
||
const { t } = useI18nTyped();
|
||
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 || t("common.writeYourPrompt")
|
||
: t("common.writeYourPrompt"),
|
||
);
|
||
|
||
// 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 ?? "",
|
||
t,
|
||
);
|
||
|
||
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: I18nText;
|
||
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: I18nText;
|
||
context: Record<string, any>;
|
||
call_id?: string;
|
||
} | 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);
|
||
|
||
// NOTE: backgroundStreams / backgroundStreamMap / MAX_BACKGROUND_STREAMS are
|
||
// declared at MODULE scope (above defineComponent), not here. They must be
|
||
// shared across all O2AIChat instances so an in-flight stream started on the
|
||
// Home tab keeps rendering after navigating to a page where the sidebar
|
||
// instance takes over. See the comment on their declaration for why.
|
||
|
||
// 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[]>([]);
|
||
|
||
// Set true in onUnmounted so watchers firing during teardown don't re-attach
|
||
// a just-detached stream back to this dying instance (see chatUpdated watch).
|
||
const isUnmounting = ref(false);
|
||
|
||
// Analyzing messages for loading indicator
|
||
const ANALYZING_MESSAGES = [
|
||
t("aiAssistant.aiChat.analyzingMessages.analyzing"),
|
||
t("aiAssistant.aiChat.analyzingMessages.thinking"),
|
||
t("aiAssistant.aiChat.analyzingMessages.processing"),
|
||
t("aiAssistant.aiChat.analyzingMessages.examiningData"),
|
||
t("aiAssistant.aiChat.analyzingMessages.reviewingContext"),
|
||
t("aiAssistant.aiChat.analyzingMessages.formulatingResponse"),
|
||
t("aiAssistant.aiChat.analyzingMessages.checkingDetails"),
|
||
t("aiAssistant.aiChat.analyzingMessages.gatheringInsights"),
|
||
t("aiAssistant.aiChat.analyzingMessages.evaluatingOptions"),
|
||
t("aiAssistant.aiChat.analyzingMessages.synthesizingInformation"),
|
||
t("aiAssistant.aiChat.analyzingMessages.workingOnIt"),
|
||
t("aiAssistant.aiChat.analyzingMessages.almostThere"),
|
||
t("aiAssistant.aiChat.analyzingMessages.divingDeeper"),
|
||
t("aiAssistant.aiChat.analyzingMessages.connectingTheDots"),
|
||
t("aiAssistant.aiChat.analyzingMessages.crunchingNumbers"),
|
||
t("aiAssistant.aiChat.analyzingMessages.exploringPossibilities"),
|
||
t("aiAssistant.aiChat.analyzingMessages.refiningAnswer"),
|
||
t("aiAssistant.aiChat.analyzingMessages.stillThinking"),
|
||
t("aiAssistant.aiChat.analyzingMessages.makingProgress"),
|
||
t("aiAssistant.aiChat.analyzingMessages.piecingTogether"),
|
||
];
|
||
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;
|
||
}
|
||
};
|
||
|
||
// 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 per tick when caught up, but
|
||
// catch up faster when a backlog has built up (e.g. the backend
|
||
// delivered a large chunk in one burst). Without this, a fixed
|
||
// 1-char-per-tick reveal can lag the actual stream by many seconds
|
||
// on bursty responses, then "snap" to the full text once the stream
|
||
// ends and the remaining backlog is force-flushed.
|
||
const backlog = remaining.length;
|
||
const revealCount = backlog > 200 ? Math.ceil(backlog / 20) : 1;
|
||
displayedStreamingContent.value = target.slice(0, current.length + revealCount);
|
||
}
|
||
|
||
// 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 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: t("toastMessages.components.responseGenerationStopped"),
|
||
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 = raw(
|
||
lastMessage.content + "\n\n_[" + t("aiAssistant.responseStoppedByUser") + "]_",
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 = `_[${t("aiAssistant.responseStoppedByUser")}]_`;
|
||
chatMessages.value.push({
|
||
role: "assistant",
|
||
content: raw(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()}`,
|
||
// Not translated: this filename is spliced verbatim into the
|
||
// `--- Log Entry ---` delimiter of the prompt sent to the LLM, so the
|
||
// delimiter must stay stable across locales.
|
||
filename: raw("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: t("aiAssistant.backendConnectionError"),
|
||
},
|
||
];
|
||
console.error("Error fetching initial message:", error);
|
||
}
|
||
isLoading.value = false;
|
||
stopAnalyzingRotation();
|
||
scrollToBottom();
|
||
};
|
||
|
||
/**
|
||
* Extract streamed assistant text from an SSE event.
|
||
*
|
||
* The o2-ai (opencode) backend emits streamed text as
|
||
* {"type":"message_delta","content":"<plain string>"}
|
||
* and non-streamed notices as {"type":"message","content":"..."} — the text
|
||
* is ALWAYS the plain-string `content` field. We also defensively accept the
|
||
* handful of OpenAI-compatible shapes the enterprise RCA proxy can surface
|
||
* (`response`, `delta.content`, `choices[].delta.content`, `text`) so an
|
||
* agent/proxy variant doesn't silently render nothing. Returns the text, or
|
||
* null when the event carries no assistant text.
|
||
*/
|
||
const extractStreamText = (data: any): string | null => {
|
||
if (data == null || typeof data !== "object") return null;
|
||
|
||
// Canonical o2-ai chat shape.
|
||
if (typeof data.content === "string") return data.content;
|
||
|
||
// Defensive fallbacks (OpenAI-style / RCA proxy formats).
|
||
if (typeof data.response === "string") return data.response;
|
||
if (data.delta && typeof data.delta.content === "string") {
|
||
return data.delta.content;
|
||
}
|
||
const firstChoice = Array.isArray(data.choices) ? data.choices[0] : null;
|
||
if (firstChoice && typeof firstChoice.delta?.content === "string") {
|
||
return firstChoice.delta.content;
|
||
}
|
||
if (typeof data.text === "string") return data.text;
|
||
|
||
return null;
|
||
};
|
||
|
||
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;
|
||
const res = 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 }),
|
||
},
|
||
);
|
||
// Detached stream, so there is nothing to show the user —
|
||
// but a silent failure leaves the agent paused.
|
||
if (!res.ok) {
|
||
console.error(
|
||
`Auto-deny not registered (HTTP ${res.status}) for background stream ${ctxSessionId}`,
|
||
);
|
||
}
|
||
} 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;
|
||
const res = 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 }),
|
||
},
|
||
);
|
||
if (!res.ok) {
|
||
console.error(
|
||
`Auto-approval not registered (HTTP ${res.status}) for session ${ctxSessionId}`,
|
||
);
|
||
}
|
||
} 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: raw(""),
|
||
contentBlocks: [...pendingToolCalls.value, confirmBlock],
|
||
});
|
||
pendingToolCalls.value = [];
|
||
}
|
||
pendingConfirmation.value = {
|
||
tool: data.tool,
|
||
args: data.args || {},
|
||
message:
|
||
data.message ||
|
||
t("aiAssistant.aiChat.confirmToolExecution", { tool: 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") {
|
||
// Owning replica is gone — flag and stop; sendMessage
|
||
// restores the conversation once the stream ends.
|
||
if (data.code === "session_owner_unavailable") {
|
||
streamOwnerUnavailable.value = true;
|
||
continue;
|
||
}
|
||
// 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 ?? t("aiAssistant.aiChat.unexpectedError");
|
||
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);
|
||
// Widened to `string`: the branded `I18nText` from t() does not survive
|
||
// the `+=` below, and `data.suggestion` is server prose appended as a
|
||
// separate paragraph, not a spliced sentence fragment.
|
||
let errorMessage: string = authErr
|
||
? t(UNAUTHORIZED_MESSAGE_KEY)
|
||
: t("common.errorPrefix", { message: 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: raw(errorMessage),
|
||
contentBlocks: [
|
||
...pendingToolCalls.value,
|
||
{ type: "text", text: errorMessage },
|
||
],
|
||
});
|
||
pendingToolCalls.value = [];
|
||
} else {
|
||
// Append error to existing message
|
||
if (lastMessage.content) {
|
||
lastMessage.content = raw(lastMessage.content + "\n\n" + errorMessage);
|
||
} else {
|
||
lastMessage.content = raw(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: raw(""),
|
||
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 || t("aiAssistant.aiChat.navigateConfirmQuestion"),
|
||
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: raw(""),
|
||
contentBlocks: [...pendingToolCalls.value, confirmBlock],
|
||
});
|
||
pendingToolCalls.value = [];
|
||
}
|
||
|
||
// Set pending confirmation with navigation action data
|
||
pendingConfirmation.value = {
|
||
tool: "navigation_action",
|
||
args: data.target || {},
|
||
message: data.label || t("aiAssistant.aiChat.navigateConfirmQuestion"),
|
||
};
|
||
|
||
// 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") {
|
||
// Owning replica is gone — flag and stop; sendMessage restores
|
||
// the conversation once the stream ends. Rendering the raw
|
||
// error too would dead-end above the restored conversation.
|
||
if (data.code === "session_owner_unavailable") {
|
||
streamOwnerUnavailable.value = true;
|
||
continue;
|
||
}
|
||
// 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 || t("aiAssistant.aiChat.toolExecutionFailed"),
|
||
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 || t("aiAssistant.aiChat.errorOccurred");
|
||
const authErr = isAuthError(rawErrorMessage, data.error_type);
|
||
const errorBlock: ContentBlock = {
|
||
type: "error",
|
||
message: authErr ? t(UNAUTHORIZED_MESSAGE_KEY) : 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: raw(""),
|
||
contentBlocks: [...pendingToolCalls.value, errorBlock],
|
||
});
|
||
pendingToolCalls.value = [];
|
||
}
|
||
messageComplete = true;
|
||
if (isActive()) await scrollToBottom();
|
||
continue;
|
||
}
|
||
|
||
// Handle streamed deltas and full/legacy message content.
|
||
// Tolerant of multiple shapes (content/text/delta/nested/OpenAI)
|
||
// so an agent schema change doesn't silently drop text.
|
||
const streamText = extractStreamText(data);
|
||
if (typeof streamText === "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;
|
||
}
|
||
|
||
// Deltas (append verbatim) vs full/legacy messages (reformat
|
||
// code fences). o2-ai streams text as type "message_delta";
|
||
// any content arriving via a non-`content` fallback field is
|
||
// also an incremental delta.
|
||
const isMessageDelta =
|
||
data.type === "message_delta" || typeof data.content !== "string";
|
||
|
||
// Format code blocks with proper line breaks for full/legacy
|
||
// messages. Deltas must be appended exactly as received.
|
||
let content = streamText;
|
||
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: raw(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 = raw(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") {
|
||
// Owning replica is gone — flag and stop; sendMessage
|
||
// restores the conversation once the stream ends.
|
||
if (data.code === "session_owner_unavailable") {
|
||
streamOwnerUnavailable.value = true;
|
||
continue;
|
||
}
|
||
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 ?? t("aiAssistant.aiChat.unexpectedError");
|
||
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);
|
||
// Widened to `string` — see the note on the sibling handler above.
|
||
let errorMessage: string = authErr
|
||
? t(UNAUTHORIZED_MESSAGE_KEY)
|
||
: t("common.errorPrefix", { message: 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: raw(errorMessage),
|
||
contentBlocks: [
|
||
...pendingToolCalls.value,
|
||
{ type: "text", text: errorMessage },
|
||
],
|
||
});
|
||
pendingToolCalls.value = [];
|
||
} else {
|
||
if (lastMessage.content) {
|
||
lastMessage.content = raw(lastMessage.content + "\n\n" + errorMessage);
|
||
} else {
|
||
lastMessage.content = raw(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") {
|
||
// Owning replica is gone — flag and stop; sendMessage
|
||
// restores the conversation once the stream ends.
|
||
if (data.code === "session_owner_unavailable") {
|
||
streamOwnerUnavailable.value = true;
|
||
continue;
|
||
}
|
||
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 || t("aiAssistant.aiChat.toolExecutionFailed"),
|
||
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 || t("aiAssistant.aiChat.errorOccurred");
|
||
const authErr = isAuthError(rawErrorMessage, data.error_type);
|
||
const errorBlock: ContentBlock = {
|
||
type: "error",
|
||
message: authErr ? t(UNAUTHORIZED_MESSAGE_KEY) : 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: raw(""),
|
||
contentBlocks: [...pendingToolCalls.value, errorBlock],
|
||
});
|
||
pendingToolCalls.value = [];
|
||
}
|
||
messageComplete = true;
|
||
continue;
|
||
}
|
||
|
||
// Handle streamed deltas and full/legacy message content.
|
||
// Tolerant of multiple event shapes (see extractStreamText).
|
||
const streamText = extractStreamText(data);
|
||
if (typeof streamText === "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" || typeof data.content !== "string";
|
||
|
||
let content = streamText;
|
||
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: raw(streamingMsg),
|
||
contentBlocks: [
|
||
...pendingToolCalls.value,
|
||
{ type: "text", text: textSegment },
|
||
],
|
||
});
|
||
pendingToolCalls.value = [];
|
||
await throttledSaveCtx(true);
|
||
} else {
|
||
lastMessage.content = raw(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;
|
||
}
|
||
};
|
||
|
||
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 = "";
|
||
};
|
||
|
||
// Logout has to kill the foreground turn too, not just the detached ones.
|
||
// MainLayout.signout() fires this as a window event because it lives in the
|
||
// Options API half of that file and can't reach setup scope directly (same
|
||
// pattern as o2:home-switch-tab).
|
||
const abortAllStreams = () => {
|
||
abortBackgroundStreams();
|
||
if (currentAbortController.value) {
|
||
currentAbortController.value.abort();
|
||
currentAbortController.value = null;
|
||
}
|
||
};
|
||
|
||
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([
|
||
{
|
||
id: "aiChatClose",
|
||
key: "escape",
|
||
description: t("shortcuts.actions.aiChatClose"),
|
||
// Escape must close the chat even while typing a message in its input.
|
||
allowInInput: true,
|
||
handler: () => {
|
||
if (store.state.isAiChatEnabled) {
|
||
store.dispatch("setIsAiChatEnabled", false);
|
||
store.dispatch("setIsAiChatExpanded", false);
|
||
window.dispatchEvent(new Event("resize"));
|
||
}
|
||
},
|
||
},
|
||
]);
|
||
|
||
const addNewChat = () => {
|
||
detachCurrentStream();
|
||
|
||
chatMessages.value = [];
|
||
currentChatId.value = null;
|
||
currentSessionId.value = null; // Will be generated on first save
|
||
lastTraceId.value = null; // 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 = t("aiAssistant.aiChat.actionCancelledByUser");
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// Set by processStream when a session's owning replica is gone. The stream has
|
||
// already returned 200 by then, so sendMessage reads this once it ends.
|
||
const streamOwnerUnavailable = ref(false);
|
||
|
||
// Shown after a successful restore: only the dialogue came back, not the tool
|
||
// results, files or permission decisions from before the interruption.
|
||
const RESTORED_NOTICE =
|
||
"This conversation was interrupted and has been restored. Earlier messages are preserved, but any files, queries or other actions from before the interruption were not carried over.";
|
||
|
||
// Keyed on the explicit server code, never guessed from a generic failure:
|
||
// restoring means abandoning the current session.
|
||
const isSessionOwnerUnavailable = (errorBody: unknown): boolean => {
|
||
// `unknown`, not `any` — narrow before reading, or a non-object body throws.
|
||
if (typeof errorBody !== "object" || errorBody === null) return false;
|
||
const body = errorBody as { code?: unknown; detail?: { code?: unknown } };
|
||
const code = body.detail?.code ?? body.code;
|
||
return code === "session_owner_unavailable";
|
||
};
|
||
|
||
/** Surface a message inline in the transcript, as stream errors are shown. */
|
||
const appendErrorBlock = (message: string, recoverable = false) => {
|
||
const block: ContentBlock = { type: "error", message: raw(message), recoverable };
|
||
const msgs = chatMessages.value;
|
||
const last = msgs[msgs.length - 1];
|
||
if (last && last.role === "assistant") {
|
||
if (!last.contentBlocks) last.contentBlocks = [];
|
||
last.contentBlocks.push(block);
|
||
} else {
|
||
msgs.push({ role: "assistant", content: raw(""), contentBlocks: [block] });
|
||
}
|
||
};
|
||
|
||
/**
|
||
* POST a confirmation answer and report whether it landed. The response used
|
||
* to be discarded, so an answer reaching a replica with no record of the
|
||
* pending confirmation 404'd invisibly while the agent auto-denied on timeout.
|
||
*/
|
||
const sendConfirmation = async (sessionId: string, approved: boolean): Promise<boolean> => {
|
||
try {
|
||
const orgId = store.state.selectedOrganization.identifier;
|
||
const res = await fetch(
|
||
`${store.state.API_ENDPOINT}/api/${orgId}/ai/confirm/${sessionId}`,
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify({ approved }),
|
||
},
|
||
);
|
||
|
||
if (!res.ok) {
|
||
console.error(
|
||
`Confirmation not registered (HTTP ${res.status}) for session ${sessionId}`,
|
||
);
|
||
appendErrorBlock(
|
||
approved
|
||
? "Your approval could not be delivered — the assistant may have already cancelled this action. Please check the result before retrying."
|
||
: "Your response could not be delivered — the assistant may have already cancelled this action.",
|
||
);
|
||
return false;
|
||
}
|
||
return true;
|
||
} catch (error) {
|
||
console.error("Error sending confirmation:", error);
|
||
appendErrorBlock(
|
||
"Your response could not be delivered. Please check your connection and try again.",
|
||
);
|
||
return false;
|
||
}
|
||
};
|
||
|
||
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;
|
||
|
||
await sendConfirmation(currentSessionId.value, true);
|
||
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;
|
||
|
||
await sendConfirmation(currentSessionId.value, false);
|
||
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;
|
||
|
||
await sendConfirmation(currentSessionId.value, true);
|
||
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;
|
||
}
|
||
|
||
const streamLabel =
|
||
{ logs: t("common.logs"), metrics: t("common.metrics"), traces: t("common.traces") }[
|
||
streamType as string
|
||
] ?? raw(streamType.charAt(0).toUpperCase() + streamType.slice(1));
|
||
return {
|
||
resource_type: streamType,
|
||
action: "load_query",
|
||
label: t("aiAssistant.viewInTarget", { target: streamLabel }),
|
||
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: t("aiAssistant.viewTarget", {
|
||
target: resourceType.charAt(0).toUpperCase() + resourceType.slice(1),
|
||
}),
|
||
target,
|
||
};
|
||
};
|
||
|
||
const handleNavigationAction = async (action: NavigationAction) => {
|
||
// Detach the stream before navigating: the route change can unmount/
|
||
// recreate this component, and onUnmounted aborts currentAbortController
|
||
// to avoid leaking requests. Without detaching first, that abort races
|
||
// an in-flight opencode turn and kills any tool calls still queued
|
||
// after this navigation (e.g. create dashboard -> create alert -> nav).
|
||
// detachCurrentStream() moves the controller to backgroundStreams so
|
||
// processStream keeps running and the turn finishes in the background.
|
||
detachCurrentStream();
|
||
|
||
// 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(
|
||
typeof target.query === "string" ? target.query : JSON.stringify(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}`;
|
||
// navigate_direct always carries the record form of `query`
|
||
const targetQuery = action.target.query as Record<string, any> | undefined;
|
||
const queryParams: Record<string, string> = {
|
||
org_identifier: store.state.selectedOrganization.identifier,
|
||
...targetQuery,
|
||
};
|
||
|
||
// Resource-type-specific URL handling
|
||
if (action.resource_type === "alert") {
|
||
path = "/alerts";
|
||
const alertId = action.target.alert_id || targetQuery?.alert_id;
|
||
if (alertId) {
|
||
// Navigate to specific alert with update action
|
||
queryParams.action = "update";
|
||
queryParams.alert_id = alertId;
|
||
queryParams.name = action.target.name || targetQuery?.name;
|
||
}
|
||
queryParams.folder = action.target.folder || targetQuery?.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 || targetQuery?.dashboardId;
|
||
queryParams.folder = action.target.folder || targetQuery?.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 || targetQuery?.id;
|
||
queryParams.name = action.target.name || targetQuery?.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 = t("aiAssistant.navigatedTo", { page: pageName });
|
||
let lastMessage = chatMessages.value[chatMessages.value.length - 1];
|
||
|
||
if (!lastMessage || lastMessage.role !== "assistant") {
|
||
// Create new assistant message
|
||
chatMessages.value.push({
|
||
role: "assistant",
|
||
content: raw(successMessage),
|
||
contentBlocks: [{ type: "text", text: successMessage }],
|
||
});
|
||
} else {
|
||
// Append to existing assistant message
|
||
if (lastMessage.content) {
|
||
lastMessage.content = raw(lastMessage.content + "\n\n" + successMessage);
|
||
} else {
|
||
lastMessage.content = raw(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();
|
||
|
||
// Prime the typewriter from whatever the stream has accumulated so
|
||
// far, in THIS fresh instance. processStream only syncs the segment
|
||
// refs and (re)starts the animation on the NEXT chunk that arrives
|
||
// while isActive(); text already streamed before we re-attached
|
||
// would otherwise sit invisible until the next delta. Reveal the
|
||
// existing text instantly, then let the ongoing stream continue.
|
||
const lastMsg = chatMessages.value[chatMessages.value.length - 1];
|
||
if (lastMsg?.role === "assistant" && lastMsg.contentBlocks?.length) {
|
||
const lastBlock = lastMsg.contentBlocks[lastMsg.contentBlocks.length - 1];
|
||
if (lastBlock?.type === "text" && lastBlock.text) {
|
||
currentStreamingMessage.value = lastMsg.content || lastBlock.text;
|
||
currentTextSegment.value = lastBlock.text;
|
||
// Instant reveal (no per-char catch-up) for the backlog.
|
||
displayedStreamingContent.value = lastBlock.text;
|
||
}
|
||
}
|
||
} 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: raw(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
|
||
|
||
// Mint the session id here rather than inside the try below. A new chat
|
||
// has none yet, and the cleanup on every exit path has to clear the flag
|
||
// for the SAME id we set it on — otherwise an instance that re-attached
|
||
// never sees the streaming->done transition and spins forever.
|
||
if (!currentSessionId.value) {
|
||
currentSessionId.value = getUUIDv7();
|
||
}
|
||
const streamSessionId = currentSessionId.value;
|
||
|
||
// Mark this session as actively streaming in the cross-instance registry
|
||
// so that if another instance re-attaches, it knows when to stop showing
|
||
// its own loading indicator (see sessionStreamingState declaration).
|
||
sessionStreamingState[streamSessionId] = true;
|
||
|
||
// Create new AbortController for this request - enables cancellation via Stop button
|
||
currentAbortController.value = new AbortController();
|
||
|
||
// Reseed state for this turn: at most one restore attempt, and a pending
|
||
// notice to show once the replacement request succeeds.
|
||
let hasReseeded = false;
|
||
let reseedNotice = false;
|
||
|
||
// Clear any flag left by a previous turn that threw or was aborted before
|
||
// the clear at the end of the try block — a stale `true` abandons a healthy
|
||
// session.
|
||
streamOwnerUnavailable.value = false;
|
||
|
||
try {
|
||
// Don't add empty assistant message here - wait for actual content
|
||
await scrollToLoadingIndicator(); // Scroll directly to loading indicator
|
||
|
||
let response: any;
|
||
try {
|
||
// Pass abort signal, 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
|
||
}
|
||
|
||
// The session is gone but the transcript is still here, so resend under
|
||
// a fresh session and let the server seed it from those messages.
|
||
// Deliberately narrow — this code only, once only.
|
||
if (isSessionOwnerUnavailable(errorBody) && !hasReseeded) {
|
||
hasReseeded = true;
|
||
console.warn(
|
||
`Session ${currentSessionId.value} is no longer available; restoring the conversation in a new session.`,
|
||
);
|
||
|
||
// A NEW id — reusing the old one would be refused again. streamSessionId
|
||
// (captured above) stays pinned to the original, and cleanup keys off it.
|
||
currentSessionId.value = getUUIDv7();
|
||
reseedNotice = true;
|
||
|
||
response = await fetchAiChat(
|
||
chatMessages.value,
|
||
"",
|
||
store.state.selectedOrganization.identifier,
|
||
currentAbortController.value?.signal,
|
||
undefined,
|
||
currentSessionId.value,
|
||
hasImages ? messagesToSend : undefined,
|
||
);
|
||
}
|
||
}
|
||
|
||
// Re-check: the reseed above may have produced a fresh response.
|
||
if (!response.ok) {
|
||
let errorBody = null;
|
||
try {
|
||
errorBody = await response.json();
|
||
} catch (_) {
|
||
// body may not be JSON
|
||
}
|
||
const err: any = new Error(
|
||
errorBody?.message ||
|
||
t("aiAssistant.aiChat.serverErrorStatus", { status: response.status }),
|
||
);
|
||
err.status = response.status;
|
||
err.errorBody = errorBody;
|
||
throw err;
|
||
}
|
||
|
||
// Tell the user before the content arrives — continuing silently hides
|
||
// that the assistant lost the earlier tool results and file state.
|
||
if (reseedNotice) {
|
||
reseedNotice = false;
|
||
appendErrorBlock(RESTORED_NOTICE, true);
|
||
}
|
||
|
||
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;
|
||
|
||
await processStream(reader);
|
||
|
||
// The streaming counterpart of the pre-stream 409 above: once the stream
|
||
// has opened the failure arrives as an SSE event inside a 200, so
|
||
// response.ok can no longer be branched on. Same recovery.
|
||
//
|
||
// Only while this turn is still on screen — if the user switched chats
|
||
// mid-stream, restoring would clobber THAT conversation's session id and
|
||
// transcript instead. They can resend from the affected chat.
|
||
const stillOnScreen = chatMessages.value === streamMsgs;
|
||
if (streamOwnerUnavailable.value && !hasReseeded && stillOnScreen) {
|
||
streamOwnerUnavailable.value = false;
|
||
hasReseeded = true;
|
||
|
||
if (streamController) backgroundStreams.delete(streamController);
|
||
if (streamSessionId) backgroundStreamMap.delete(streamSessionId);
|
||
|
||
// The cross-instance streaming registry has to follow the new id, or
|
||
// another instance re-attaching never sees this stream finish.
|
||
const restoredSessionId = getUUIDv7();
|
||
currentSessionId.value = restoredSessionId;
|
||
sessionStreamingState[restoredSessionId] = true;
|
||
|
||
const retry: any = await fetchAiChat(
|
||
chatMessages.value,
|
||
"",
|
||
store.state.selectedOrganization.identifier,
|
||
currentAbortController.value?.signal,
|
||
undefined,
|
||
currentSessionId.value,
|
||
hasImages ? messagesToSend : undefined,
|
||
);
|
||
|
||
if (retry && !retry.cancelled && retry.ok && retry.body) {
|
||
// Announced only once the replacement request is accepted, as on the
|
||
// pre-stream path — otherwise the claim can turn out to be false.
|
||
appendErrorBlock(RESTORED_NOTICE, true);
|
||
await processStream(retry.body.getReader());
|
||
} else if (!(retry && retry.cancelled)) {
|
||
// The retry failed — non-OK, no body, or null (a network error).
|
||
// hasReseeded blocks any further attempt, so staying quiet here would
|
||
// end the turn with no answer and no explanation. A cancel is silent.
|
||
appendErrorBlock(
|
||
"This conversation was interrupted and could not be restored. Please try sending your message again.",
|
||
);
|
||
}
|
||
|
||
// The restored turn is done either way; clear its entry, or a
|
||
// re-attaching instance shows a loading indicator forever.
|
||
sessionStreamingState[restoredSessionId] = false;
|
||
backgroundStreamMap.delete(restoredSessionId);
|
||
}
|
||
streamOwnerUnavailable.value = false;
|
||
|
||
// 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 = t(UNAUTHORIZED_MESSAGE_KEY);
|
||
} else if (error.message && error.message !== "No response body") {
|
||
errorMessage = error.message;
|
||
} else {
|
||
errorMessage = t("aiAssistant.aiChat.serverResponseError");
|
||
}
|
||
chatMessages.value.push({
|
||
role: "assistant",
|
||
content: raw(errorMessage),
|
||
});
|
||
await saveToHistory(); // Save after error
|
||
}
|
||
|
||
isLoading.value = false;
|
||
activeToolCall.value = null;
|
||
stopAnalyzingRotation();
|
||
|
||
// Mark the session's stream as finished in the cross-instance registry so
|
||
// any OTHER instance that re-attached to it (e.g. the sidebar) can clear
|
||
// its own loading indicator. Runs on all exit paths (success/abort/error).
|
||
// Uses the id captured before the request, not currentSessionId — by the
|
||
// time an early failure lands here the user may have switched chats, and
|
||
// clearing the wrong session leaves the real one flagged as streaming.
|
||
sessionStreamingState[streamSessionId] = false;
|
||
|
||
// 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: t("toastMessages.components.imageExceeds2mbLimitMb", {
|
||
size: (file.size / 1024 / 1024).toFixed(1),
|
||
}),
|
||
});
|
||
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: t("toastMessages.components.onlyPngAndJpegImagesAre"),
|
||
});
|
||
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";
|
||
// eslint-disable-next-line local/no-hardcoded-px -- hairline: a 1-device-pixel rule must not scale with text or it smears at fractional zoom
|
||
imageRefSpan.style.cssText = `display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.125rem 0.375rem; margin: 0 0.125rem; background: ${chartColor("--color-status-success-bg")}; border: 1px solid ${chartColor("--color-success-200")}; border-radius: 0.25rem; font-size: var(--text-compact); color: ${chartColor("--color-status-success-text")}; user-select: none;`;
|
||
|
||
// Add image icon
|
||
const imageIcon = document.createElement("span");
|
||
imageIcon.textContent = "🖼️";
|
||
imageIcon.style.cssText = "font-size: var(--text-xs);";
|
||
|
||
// 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: 0.875rem; height: 0.875rem; padding: 0; margin-left: 0.125rem; background: transparent; border: none; border-radius: 0.1875rem; font-size: var(--text-base); line-height: 1; cursor: pointer; color: ${chartColor("--color-status-success-text")}; transition: all 0.15s ease;`;
|
||
removeBtn.onmouseover = () => {
|
||
removeBtn.style.background = chartColor("--color-status-negative");
|
||
removeBtn.style.color = chartColor("--color-white");
|
||
};
|
||
removeBtn.onmouseout = () => {
|
||
removeBtn.style.background = "transparent";
|
||
removeBtn.style.color = chartColor("--color-status-success-text");
|
||
};
|
||
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: t("toastMessages.components.failedToReadImage", { error: 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;
|
||
};
|
||
|
||
// 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) {
|
||
// A turn started under the old org must not keep streaming and
|
||
// writing chat history after the switch.
|
||
abortBackgroundStreams();
|
||
addNewChat();
|
||
if (props.isOpen) {
|
||
loadHistory();
|
||
}
|
||
}
|
||
},
|
||
);
|
||
|
||
// When this instance has re-attached to a stream that another instance
|
||
// started (its processStream completion runs in the OTHER instance's
|
||
// closure and can't reset our isLoading), watch the shared streaming
|
||
// registry and clear our own streaming UI once that session finishes.
|
||
watch(
|
||
() => (currentSessionId.value ? sessionStreamingState[currentSessionId.value] : undefined),
|
||
(isStreaming) => {
|
||
// React to the session going false, not to a true->false transition: an
|
||
// instance that re-attached mid-stream never observed the `true`, so it
|
||
// would see undefined->false and skip the cleanup, spinning forever.
|
||
// isLoading guards against acting when we aren't showing a stream.
|
||
if (isStreaming === false && isLoading.value) {
|
||
isLoading.value = false;
|
||
activeToolCall.value = null;
|
||
stopAnalyzingRotation();
|
||
// The owning instance's processStream already wrote the final text
|
||
// into the message blocks, so this instance only clears its own
|
||
// typewriter UI.
|
||
resetTypewriterState();
|
||
// Reflect the completed turn into the sync handshake + history so a
|
||
// later mount reads the finished chat, not a mid-stream snapshot.
|
||
store.dispatch("setCurrentChatTimestamp", currentChatId.value);
|
||
nextTick(() => scrollToBottom());
|
||
}
|
||
},
|
||
);
|
||
|
||
// 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();
|
||
focusInput();
|
||
}, 100);
|
||
});
|
||
}
|
||
|
||
// Load query history from localStorage
|
||
loadQueryHistory();
|
||
|
||
// Load auto navigation preferences from localStorage
|
||
loadAutoNavigationPreferences();
|
||
|
||
window.addEventListener("o2:abort-ai-streams", abortAllStreams);
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener("o2:abort-ai-streams", abortAllStreams);
|
||
// Mark unmounting FIRST so any reactive watcher that fires during teardown
|
||
// (e.g. our own chatUpdated watch, triggered by the dispatch below) does
|
||
// not re-attach the just-detached stream back to this dying instance.
|
||
isUnmounting.value = true;
|
||
|
||
// Detach (not abort) any in-flight request: every mount site except
|
||
// MainLayout's sidebar is behind a v-if (Home's AI tab, the query-editor
|
||
// panels), so ordinary navigation tears this instance down and used to
|
||
// kill the answer mid-word. Unmount is the only hook that knows the
|
||
// component is actually going away — a route watcher can't tell the
|
||
// difference between "leaving" and "the page updated its query string".
|
||
const wasStreaming = !!currentAbortController.value;
|
||
detachCurrentStream();
|
||
// detachCurrentStream early-returns when no controller is set, so clear the
|
||
// rotation interval directly rather than relying on that path.
|
||
stopAnalyzingRotation();
|
||
|
||
// Home runs the chat in its own inline tab with the sidebar closed. If we
|
||
// leave Home mid-stream, open the sidebar so its instance can re-attach
|
||
// and keep rendering. Skip when we're still on Home (the user only
|
||
// switched Home tabs) — MainLayout keeps the sidebar closed there, so
|
||
// opening it would show the panel and the Home AI tab at once.
|
||
if (
|
||
wasStreaming &&
|
||
props.centeredStart &&
|
||
route.name !== "home" &&
|
||
!store.state.isAiChatEnabled
|
||
) {
|
||
store.dispatch("setIsAiChatEnabled", true);
|
||
}
|
||
|
||
// Note: background streams are intentionally NOT aborted here.
|
||
// detachCurrentStream() moves a turn's controller into backgroundStreams
|
||
// specifically so it keeps running after this component instance goes
|
||
// away (e.g. navigation, logout); aborting them on unmount would defeat
|
||
// that guarantee in exactly the scenario it exists for.
|
||
|
||
// 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;
|
||
}
|
||
|
||
// Clean up the trailing-edge streaming render timer. This matters more
|
||
// here than a typical unmount cleanup: we intentionally let the stream
|
||
// keep running (see detachCurrentStream above), so displayedStreamingContent
|
||
// may still be ticking as this instance dies and a flush is often pending.
|
||
// Left alone it fires after unmount and writes into this dead instance's
|
||
// chatMessages, keeping the whole setup closure alive across the routine
|
||
// home <-> sidebar hand-off.
|
||
if (streamingRenderFlushTimer) {
|
||
clearTimeout(streamingRenderFlushTimer);
|
||
streamingRenderFlushTimer = null;
|
||
}
|
||
pendingStreamingRenderContent = null;
|
||
|
||
// We use separate O2AIChat instances (home inline tab + sidebar) and sync
|
||
// them via the store: publish which chat is current + a "chatUpdated" pulse
|
||
// so the SURVIVING instance loads it (its chatUpdated watch calls loadChat).
|
||
//
|
||
// CRITICAL: this dying instance must NOT call loadChat()/addNewChat() on
|
||
// itself here. detachCurrentStream() (above) just registered the in-flight
|
||
// turn in backgroundStreamMap for the survivor to re-attach to; calling
|
||
// loadChat() on ourselves would immediately re-attach it back to THIS
|
||
// instance and delete the map entry, so the survivor then finds nothing
|
||
// and falls back to the stale IndexedDB snapshot — the exact reason the
|
||
// streamed text stopped rendering after navigating away mid-stream.
|
||
// Only publish the handoff state; let the survivor act on it.
|
||
store.dispatch("setCurrentChatTimestamp", currentChatId.value);
|
||
store.dispatch("setChatUpdated", true);
|
||
});
|
||
//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) => {
|
||
// A dying instance must not react to the handoff pulse it just published —
|
||
// otherwise it re-attaches its own detached stream and steals it from the
|
||
// surviving instance. Let the survivor handle it.
|
||
if (isUnmounting.value) return;
|
||
if (newChatUpdated && store.state.currentChatTimestamp) {
|
||
loadChat(store.state.currentChatTimestamp);
|
||
}
|
||
if (newChatUpdated && !store.state.currentChatTimestamp) {
|
||
addNewChat();
|
||
}
|
||
store.dispatch("setChatUpdated", false);
|
||
});
|
||
|
||
// Writing displayedStreamingContent into chatMessages triggers a
|
||
// re-render of the message list, which re-runs formatMessage() ->
|
||
// marked.parse() (incl. hljs.highlight for code blocks) over the WHOLE
|
||
// accumulated text, not just the new characters. The typewriter ticks
|
||
// every ~8ms; re-parsing/re-highlighting full markdown at that rate is
|
||
// O(n^2) over a response and eventually can't keep up with
|
||
// requestAnimationFrame, so the page appears to hang with data already
|
||
// in memory but not painted, then "snaps" to the final text once the
|
||
// stream ends and the last write goes through. Throttle how often the
|
||
// expensive reactive write happens, independent of how often the cheap
|
||
// per-character animation ref ticks; the animation itself stays smooth.
|
||
const STREAMING_RENDER_INTERVAL = 80; // ms between reactive markdown re-renders
|
||
let lastStreamingRenderTime = 0;
|
||
let pendingStreamingRenderContent: string | null = null;
|
||
let streamingRenderFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
const flushStreamingRenderNow = (content: string) => {
|
||
lastStreamingRenderTime = Date.now();
|
||
pendingStreamingRenderContent = null;
|
||
if (streamingRenderFlushTimer) {
|
||
clearTimeout(streamingRenderFlushTimer);
|
||
streamingRenderFlushTimer = null;
|
||
}
|
||
|
||
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") {
|
||
// Additive-only: the stream handler writes the FULL accumulated
|
||
// textSegment into this block as it arrives; the typewriter only
|
||
// reveals a prefix of it. Never let a lagging/stalled typewriter
|
||
// reveal (or an empty reset) shorten what's already rendered — that
|
||
// was a source of "text arrived but nothing/less showed". Only grow.
|
||
if ((content?.length || 0) >= (lastBlock.text?.length || 0)) {
|
||
lastBlock.text = content;
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// 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 now = Date.now();
|
||
if (now - lastStreamingRenderTime >= STREAMING_RENDER_INTERVAL) {
|
||
flushStreamingRenderNow(newContent);
|
||
return;
|
||
}
|
||
|
||
// Trailing edge: make sure the latest content always lands even if
|
||
// ticks keep arriving faster than the interval.
|
||
pendingStreamingRenderContent = newContent;
|
||
if (!streamingRenderFlushTimer) {
|
||
const delay = STREAMING_RENDER_INTERVAL - (now - lastStreamingRenderTime);
|
||
streamingRenderFlushTimer = setTimeout(() => {
|
||
streamingRenderFlushTimer = null;
|
||
if (pendingStreamingRenderContent !== null) {
|
||
flushStreamingRenderNow(pendingStreamingRenderContent);
|
||
}
|
||
}, delay);
|
||
}
|
||
});
|
||
|
||
// 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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'")
|
||
.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 || [],
|
||
};
|
||
});
|
||
});
|
||
|
||
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;
|
||
};
|
||
|
||
// `response` is stamped onto blocks by the stream handler but is not part
|
||
// of the shared ContentBlock interface.
|
||
const hasToolCallDetails = (block: ContentBlock & { response?: Record<string, any> }) => {
|
||
// 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.summary) return true;
|
||
if (block.response) return true;
|
||
return getToolCallDisplayData(block.context) !== null;
|
||
};
|
||
|
||
// Splits an already-interpolated sentence around the value that renders bold,
|
||
// so the header stays ONE translatable sentence instead of two fragments.
|
||
const splitAroundHighlight = (full: string, highlight: string) => {
|
||
const at = full.indexOf(highlight);
|
||
if (at < 0) return { text: full, highlight: null as string | null, suffix: "" };
|
||
return {
|
||
text: full.slice(0, at),
|
||
highlight: highlight as string | null,
|
||
suffix: full.slice(at + highlight.length),
|
||
};
|
||
};
|
||
|
||
const formatToolCallMessage = (block: ContentBlock & { response?: Record<string, any> }) => {
|
||
// Show error message for failed tools
|
||
// Tool-specific messages (both success and error)
|
||
if (block.tool === "testFunction") {
|
||
if (block.success === false) {
|
||
return {
|
||
text: t("aiAssistant.aiChat.toolVrlValidationFailed"),
|
||
highlight: null,
|
||
suffix: "",
|
||
};
|
||
}
|
||
return { text: t("aiAssistant.aiChat.toolVrlValidated"), highlight: null, suffix: "" };
|
||
}
|
||
if (block.tool === "SearchSQL") {
|
||
if (block.success === false) {
|
||
return {
|
||
text: t("aiAssistant.aiChat.toolQueryFailed"),
|
||
highlight: null,
|
||
suffix: "",
|
||
};
|
||
}
|
||
if (block.response?.total !== undefined) {
|
||
const streamType = block.context?.type || "logs";
|
||
return {
|
||
text: t("aiAssistant.aiChat.toolQueriedStream", { type: streamType }),
|
||
highlight: t("aiAssistant.aiChat.toolResultsCount", {
|
||
count: block.response.total,
|
||
}),
|
||
suffix: "",
|
||
};
|
||
}
|
||
}
|
||
if (block.tool === "StreamSchema" && block.context?.stream_name) {
|
||
const streamName = block.context.stream_name;
|
||
return splitAroundHighlight(
|
||
t("aiAssistant.aiChat.toolStreamSchema", { name: streamName }),
|
||
streamName,
|
||
);
|
||
}
|
||
if (block.tool === "GetIncident" && block.context?.incident_id) {
|
||
const incidentId = block.context.incident_id;
|
||
return splitAroundHighlight(
|
||
t("aiAssistant.aiChat.toolGetIncident", { id: incidentId }),
|
||
incidentId,
|
||
);
|
||
}
|
||
if (block.tool === "GetAlert" && block.context?.alert_id) {
|
||
const alertId = block.context.alert_id;
|
||
return splitAroundHighlight(t("aiAssistant.aiChat.toolGetAlert", { id: alertId }), alertId);
|
||
}
|
||
if (block.tool === "GetDashboard" && block.context?.dashboard_id) {
|
||
const dashboardId = block.context.dashboard_id;
|
||
return splitAroundHighlight(
|
||
t("aiAssistant.aiChat.toolGetDashboard", { id: dashboardId }),
|
||
dashboardId,
|
||
);
|
||
}
|
||
// List tools: show count from normalized { total, items } response
|
||
if (block.response?.total !== undefined && block.success !== false) {
|
||
const base = block.message || block.tool || t("aiAssistant.aiChat.toolListedFallback");
|
||
return {
|
||
text: base + " ",
|
||
highlight: t("aiAssistant.aiChat.toolFoundCount", { count: 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 || t("aiAssistant.aiChat.toolFallback");
|
||
return {
|
||
text: base + " ",
|
||
highlight: t("aiAssistant.aiChat.toolResultsCount", { count: block.summary.count }),
|
||
suffix: "",
|
||
};
|
||
}
|
||
return { text: block.message, highlight: null, suffix: "" };
|
||
};
|
||
|
||
const formatTimestamp = (timestamp: number) => {
|
||
if (!timestamp || timestamp === 0) return t("aiAssistant.aiChat.notSpecified");
|
||
// 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: t("toastMessages.components.thanksForYourFeedback"),
|
||
});
|
||
}
|
||
};
|
||
|
||
const dislikeCodeBlock = async (messageIndex: number) => {
|
||
const message = chatMessages.value[messageIndex];
|
||
if (!message || message.feedback === "thumbs_down") return;
|
||
const orgId = store.state.selectedOrganization?.identifier;
|
||
if (!orgId) return;
|
||
const queryIndex = Math.floor(messageIndex / 2);
|
||
const success = await submitFeedback(
|
||
"thumbs_down",
|
||
orgId,
|
||
currentSessionId.value || undefined,
|
||
queryIndex,
|
||
lastTraceId.value || undefined,
|
||
);
|
||
if (success) {
|
||
message.feedback = "thumbs_down";
|
||
await saveToHistory();
|
||
toast({
|
||
variant: "success",
|
||
message: t("toastMessages.components.thanksForYourFeedback"),
|
||
});
|
||
}
|
||
};
|
||
const o2AiTitleLogo = computed(() => {
|
||
return isDark.value
|
||
? getImageURL("images/common/o2_ai_logo_dark.svg")
|
||
: getImageURL("images/common/o2_ai_logo.svg");
|
||
});
|
||
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 {
|
||
raw,
|
||
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,
|
||
sendConfirmation,
|
||
// Session restore
|
||
isSessionOwnerUnavailable,
|
||
appendErrorBlock,
|
||
streamOwnerUnavailable,
|
||
currentSessionId,
|
||
// 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 scoped>
|
||
/* keep(generated-content): markdown/log/code markup is injected with v-html, so
|
||
it carries no scope attribute and cannot take utility classes — it can only be
|
||
reached from here through :deep().
|
||
keep(lib-override:hljs): highlight.js emits its own .hljs-* class names; the
|
||
token mapping below mirrors lib/core/Code/OCodeBlock.vue exactly (D6).
|
||
keep(keyframes): @keyframes and the `animation:` that consumes it must live in
|
||
the same block — the scoped compiler renames both together.
|
||
keep(complex-state): .tool-call-item's status x has-details x hover matrix and
|
||
.send-button's :not(.disabled):not([disabled]):not(:disabled) guard have no
|
||
utility equivalent (`enabled:` covers :disabled, not the .disabled class). */
|
||
|
||
/* ============================================================
|
||
keep(keyframes) — each consumer sits next to its @keyframes
|
||
============================================================ */
|
||
.tool-call-indicator {
|
||
animation: fadeIn 0.3s ease;
|
||
}
|
||
|
||
@keyframes fadeIn {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(-0.625rem);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
.fixed-analyzing-indicator {
|
||
animation: fadeInSlide 0.3s ease;
|
||
}
|
||
|
||
@keyframes fadeInSlide {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(-0.625rem);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
/* Scroll-to-bottom button entrance. Rises from below with a slight scale-up, so
|
||
it is not the same curve as fadeIn/fadeInSlide above (those drop from above). */
|
||
.scroll-to-bottom-btn {
|
||
animation: fadeInUp 0.3s ease;
|
||
}
|
||
|
||
@keyframes fadeInUp {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(0.625rem) scale(0.9);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0) scale(1);
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
keep(complex-state) — tool call status matrix.
|
||
Status tint x (has-details) hover x light/dark. Each status maps to its
|
||
semantic token; the light/dark pairs differ only in mix strength, so dark
|
||
overrides just the percentage.
|
||
============================================================ */
|
||
.tool-call-item {
|
||
background: color-mix(in srgb, var(--color-status-positive) 8%, transparent);
|
||
}
|
||
.dark .tool-call-item {
|
||
background: color-mix(in srgb, var(--color-status-positive) 12%, transparent);
|
||
}
|
||
.tool-call-item.has-details {
|
||
cursor: pointer;
|
||
}
|
||
.tool-call-item.has-details:hover {
|
||
background: color-mix(in srgb, var(--color-status-positive) 12%, transparent);
|
||
}
|
||
.dark .tool-call-item.has-details:hover {
|
||
background: color-mix(in srgb, var(--color-status-positive) 18%, transparent);
|
||
}
|
||
|
||
.tool-call-item.error {
|
||
background: color-mix(in srgb, var(--color-status-negative) 8%, transparent);
|
||
}
|
||
.dark .tool-call-item.error {
|
||
background: color-mix(in srgb, var(--color-status-negative) 12%, transparent);
|
||
}
|
||
.tool-call-item.error.has-details:hover {
|
||
background: color-mix(in srgb, var(--color-status-negative) 15%, transparent);
|
||
}
|
||
.dark .tool-call-item.error.has-details:hover {
|
||
background: color-mix(in srgb, var(--color-status-negative) 22%, transparent);
|
||
}
|
||
|
||
.tool-call-item.timeout {
|
||
background: color-mix(in srgb, var(--color-warning) 8%, transparent);
|
||
}
|
||
.dark .tool-call-item.timeout {
|
||
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
||
}
|
||
.tool-call-item.timeout.has-details:hover {
|
||
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
||
}
|
||
.dark .tool-call-item.timeout.has-details:hover {
|
||
background: color-mix(in srgb, var(--color-warning) 22%, transparent);
|
||
}
|
||
|
||
.tool-call-item.pending-confirmation {
|
||
cursor: default;
|
||
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
||
/* eslint-disable-next-line local/no-hardcoded-px -- hairline: a 1-device-pixel border must not scale with text or it smears at fractional zoom */
|
||
border: 1px solid color-mix(in srgb, var(--color-warning) 30%, transparent);
|
||
}
|
||
.dark .tool-call-item.pending-confirmation {
|
||
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
||
border-color: color-mix(in srgb, var(--color-warning) 25%, transparent);
|
||
}
|
||
|
||
.tool-call-item.pending-navigation {
|
||
cursor: default;
|
||
background: color-mix(in srgb, var(--color-info) 8%, transparent);
|
||
/* eslint-disable-next-line local/no-hardcoded-px -- hairline: a 1-device-pixel border must not scale with text or it smears at fractional zoom */
|
||
border: 1px solid color-mix(in srgb, var(--color-info) 30%, transparent);
|
||
}
|
||
.dark .tool-call-item.pending-navigation {
|
||
background: color-mix(in srgb, var(--color-info) 12%, transparent);
|
||
border-color: color-mix(in srgb, var(--color-info) 25%, transparent);
|
||
}
|
||
|
||
/* ============================================================
|
||
keep(complex-state) — send button.
|
||
The enabled guard is a .disabled CLASS plus [disabled] plus :disabled;
|
||
Tailwind's `enabled:` variant only covers the last two.
|
||
============================================================ */
|
||
.send-button:hover:not(.disabled):not([disabled]):not(:disabled) {
|
||
/* The hover gradient is `hover:bg-gradient-ai!` on the button itself. It used to
|
||
be restated here, then dropped when the template briefly carried the gradient
|
||
unconditionally — and main later moved the button back to variant="primary",
|
||
so between the two changes the gradient stopped painting at all. */
|
||
box-shadow: var(--shadow-glow-xl-geom) color-mix(in srgb, var(--color-ai-accent) 40%, transparent) !important;
|
||
transform: translateY(-0.0625rem) !important;
|
||
}
|
||
.send-button:active:not(.disabled):not([disabled]):not(:disabled) {
|
||
transform: translateY(0) !important;
|
||
/* Pressed keeps the accent, dimmer than hover — NOT --shadow-glow, which is a
|
||
neutral black ring and turns the press state grey. */
|
||
box-shadow: var(--shadow-glow-press-geom)
|
||
color-mix(in srgb, var(--color-ai-accent) 30%, transparent) !important;
|
||
}
|
||
|
||
/* ============================================================
|
||
keep(generated-content) — RichTextInput is a child component, so its
|
||
internals carry no scope attribute of ours.
|
||
============================================================ */
|
||
.unified-input-box :deep(.rich-text-input-wrapper) {
|
||
width: 100%;
|
||
min-height: 2.5rem;
|
||
}
|
||
.unified-input-box :deep(.rich-text-input) {
|
||
padding: 0.25rem 0;
|
||
}
|
||
|
||
/* ============================================================
|
||
keep(generated-content) — markdown rendered from v-html inside .text-block.
|
||
`!important` retained: these fight the global base-elements typography layer.
|
||
============================================================ */
|
||
.text-block :deep(h1) {
|
||
font-size: var(--text-2xl) !important;
|
||
font-weight: 600 !important;
|
||
margin: 1rem 0 0.5rem 0 !important;
|
||
line-height: 1.3 !important;
|
||
}
|
||
.text-block :deep(h2) {
|
||
font-size: var(--text-xl) !important;
|
||
font-weight: 600 !important;
|
||
margin: 0.875rem 0 0.4375rem 0 !important;
|
||
line-height: 1.3 !important;
|
||
}
|
||
.text-block :deep(h3) {
|
||
font-size: var(--text-lg) !important;
|
||
font-weight: 600 !important;
|
||
margin: 0.75rem 0 0.375rem 0 !important;
|
||
line-height: 1.3 !important;
|
||
}
|
||
.text-block :deep(h4) {
|
||
font-size: var(--text-base) !important;
|
||
font-weight: 600 !important;
|
||
margin: 0.625rem 0 0.3125rem 0 !important;
|
||
line-height: 1.3 !important;
|
||
}
|
||
.text-block :deep(h5) {
|
||
font-size: var(--text-sm) !important;
|
||
font-weight: 600 !important;
|
||
margin: 0.5rem 0 0.25rem 0 !important;
|
||
line-height: 1.3 !important;
|
||
}
|
||
.text-block :deep(h6) {
|
||
font-size: var(--text-xs) !important;
|
||
font-weight: 600 !important;
|
||
margin: 0.5rem 0 0.25rem 0 !important;
|
||
line-height: 1.3 !important;
|
||
}
|
||
|
||
.text-block :deep(table) {
|
||
max-width: 100%;
|
||
width: 100%;
|
||
table-layout: fixed;
|
||
border-collapse: collapse;
|
||
overflow-x: auto;
|
||
display: block;
|
||
white-space: nowrap;
|
||
}
|
||
.text-block :deep(th),
|
||
.text-block :deep(td) {
|
||
padding: 0.5rem 0.75rem;
|
||
/* eslint-disable-next-line local/no-hardcoded-px -- hairline: a 1-device-pixel border must not scale with text or it smears at fractional zoom */
|
||
border: 1px solid var(--color-border-default);
|
||
word-wrap: break-word;
|
||
overflow-wrap: break-word;
|
||
text-overflow: ellipsis;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.text-block :deep(p),
|
||
.text-block :deep(div),
|
||
.text-block :deep(span) {
|
||
word-wrap: break-word;
|
||
overflow-wrap: break-word;
|
||
word-break: break-word;
|
||
max-width: 100%;
|
||
}
|
||
|
||
.text-block :deep(ol) {
|
||
list-style-type: decimal;
|
||
padding-left: 1.5em;
|
||
margin: 0.5em 0;
|
||
}
|
||
.text-block :deep(ul) {
|
||
list-style-type: disc;
|
||
padding-left: 1.5em;
|
||
margin: 0.5em 0;
|
||
}
|
||
.text-block :deep(li) {
|
||
margin: 0.25em 0;
|
||
}
|
||
|
||
/* ============================================================
|
||
keep(generated-content) — code blocks.
|
||
.generated-code-block is emitted BOTH from the template and by the markdown
|
||
renderer (which rewrites <pre> into <span class="generated-code-block">), so
|
||
it must be reachable through :deep() either way. The background/border are
|
||
set here rather than as utilities because they have to beat .hljs below,
|
||
which is unlayered and would otherwise win over @layer utilities.
|
||
============================================================ */
|
||
.message-blocks :deep(.generated-code-block),
|
||
.text-block :deep(pre) {
|
||
display: block;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
overflow-wrap: break-word;
|
||
margin: 0;
|
||
padding: 0;
|
||
line-height: 1.4;
|
||
max-width: 100%;
|
||
overflow-x: auto;
|
||
}
|
||
.message-blocks :deep(.generated-code-block code),
|
||
.text-block :deep(pre code) {
|
||
display: block;
|
||
padding: 0.5rem;
|
||
margin: 0;
|
||
max-width: 100%;
|
||
background-color: var(--color-surface-base);
|
||
/* eslint-disable-next-line local/no-hardcoded-px -- hairline: a 1-device-pixel border must not scale with text or it smears at fractional zoom */
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-top: none;
|
||
}
|
||
|
||
/* Markdown lists can nest a fenced block; hljs sets the palette, these two
|
||
only need the reset. */
|
||
.text-block :deep(ul pre),
|
||
.text-block :deep(ol pre) {
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
overflow-wrap: break-word;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
/* ============================================================
|
||
keep(generated-content) — formatLogEntryContent() emits these json spans.
|
||
============================================================ */
|
||
.log-entry-code :deep(.json-key) {
|
||
color: var(--color-json-key);
|
||
font-weight: 600;
|
||
}
|
||
.log-entry-code :deep(.json-string) {
|
||
color: var(--color-json-string);
|
||
}
|
||
.log-entry-code :deep(.json-number) {
|
||
color: var(--color-json-number);
|
||
}
|
||
.log-entry-code :deep(.json-boolean) {
|
||
color: var(--color-json-boolean);
|
||
font-weight: 600;
|
||
}
|
||
.log-entry-code :deep(.json-null) {
|
||
color: var(--color-json-null);
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* ============================================================
|
||
keep(lib-override:hljs) — highlight.js output. Token mapping mirrors
|
||
lib/core/Code/OCodeBlock.vue (D6); tokens flip via dark.css, so one rule set
|
||
covers both themes.
|
||
============================================================ */
|
||
.message-blocks :deep(.hljs) {
|
||
display: block;
|
||
overflow-x: auto;
|
||
padding: 0.5em;
|
||
color: var(--color-syntax-text);
|
||
background: var(--color-syntax-bg);
|
||
}
|
||
.message-blocks :deep(.hljs-doctag),
|
||
.message-blocks :deep(.hljs-keyword),
|
||
.message-blocks :deep(.hljs-meta .hljs-keyword),
|
||
.message-blocks :deep(.hljs-template-tag),
|
||
.message-blocks :deep(.hljs-template-variable),
|
||
.message-blocks :deep(.hljs-type),
|
||
.message-blocks :deep(.hljs-variable.language_) {
|
||
color: var(--color-syntax-keyword);
|
||
}
|
||
.message-blocks :deep(.hljs-title),
|
||
.message-blocks :deep(.hljs-title.class_),
|
||
.message-blocks :deep(.hljs-title.class_.inherited__),
|
||
.message-blocks :deep(.hljs-title.function_) {
|
||
color: var(--color-syntax-function);
|
||
}
|
||
.message-blocks :deep(.hljs-attr),
|
||
.message-blocks :deep(.hljs-attribute),
|
||
.message-blocks :deep(.hljs-literal),
|
||
.message-blocks :deep(.hljs-meta),
|
||
.message-blocks :deep(.hljs-number),
|
||
.message-blocks :deep(.hljs-operator),
|
||
.message-blocks :deep(.hljs-variable),
|
||
.message-blocks :deep(.hljs-selector-attr),
|
||
.message-blocks :deep(.hljs-selector-class),
|
||
.message-blocks :deep(.hljs-selector-id) {
|
||
color: var(--color-syntax-number);
|
||
}
|
||
.message-blocks :deep(.hljs-regexp),
|
||
.message-blocks :deep(.hljs-string),
|
||
.message-blocks :deep(.hljs-meta .hljs-string) {
|
||
color: var(--color-syntax-string);
|
||
}
|
||
.message-blocks :deep(.hljs-built_in),
|
||
.message-blocks :deep(.hljs-symbol) {
|
||
color: var(--color-syntax-builtin);
|
||
}
|
||
.message-blocks :deep(.hljs-comment),
|
||
.message-blocks :deep(.hljs-code),
|
||
.message-blocks :deep(.hljs-formula) {
|
||
color: var(--color-syntax-comment);
|
||
}
|
||
.message-blocks :deep(.hljs-name),
|
||
.message-blocks :deep(.hljs-quote),
|
||
.message-blocks :deep(.hljs-selector-tag),
|
||
.message-blocks :deep(.hljs-selector-pseudo) {
|
||
color: var(--color-syntax-tag);
|
||
}
|
||
.message-blocks :deep(.hljs-subst) {
|
||
color: var(--color-syntax-text);
|
||
}
|
||
.message-blocks :deep(.hljs-section) {
|
||
color: var(--color-syntax-number);
|
||
font-weight: 600;
|
||
}
|
||
.message-blocks :deep(.hljs-bullet) {
|
||
color: var(--color-syntax-bullet);
|
||
}
|
||
.message-blocks :deep(.hljs-emphasis) {
|
||
color: var(--color-syntax-text);
|
||
font-style: italic;
|
||
}
|
||
.message-blocks :deep(.hljs-strong) {
|
||
color: var(--color-syntax-text);
|
||
font-weight: 600;
|
||
}
|
||
.message-blocks :deep(.hljs-addition) {
|
||
color: var(--color-syntax-addition-fg);
|
||
background-color: var(--color-syntax-addition-bg);
|
||
}
|
||
.message-blocks :deep(.hljs-deletion) {
|
||
color: var(--color-syntax-deletion-fg);
|
||
background-color: var(--color-syntax-deletion-bg);
|
||
}
|
||
</style>
|