Files
openobserve/web/src/components/ai-assistant/chat/O2AIChatMessage.vue
T
70a89a7c34 feat(license): gate features by license in the UI (#16233)
Co-authored-by: sai nikhil kethe <nikhil@openobserve.ai>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Shrinath Rao <shnath@openobserve.ai>
2026-11-04 10:12:58 +00:00

645 lines
25 KiB
Vue

<script setup lang="ts">
import { computed } from "vue";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import type { ChatMessage, ContentBlock, NavigationAction } from "@/ts/interfaces/chat";
import {
formatLogEntryContent,
getLanguageDisplay,
processHtmlBlock,
processTextBlock,
type RenderedBlock,
} from "@/components/O2AIChat.content";
import { copyToClipboard } from "@/utils/clipboard";
import O2AIChatToolCallBlock from "./O2AIChatToolCallBlock.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { navigationFeature } from "@/components/O2AIChat.navigation";
import { useLicenseLocks } from "@/composables/useFeatureLock";
// ContentBlock has no log_entry member, but parseLogEntries puts them into user messages.
type LogEntryBlock = { type: "log_entry"; preview: string; content: string };
type ProcessedMessage = Omit<ChatMessage, "contentBlocks"> & {
blocks: RenderedBlock[];
contentBlocks: Array<ContentBlock | LogEntryBlock>;
};
type ImageAttachment = NonNullable<ChatMessage["images"]>[number];
const props = defineProps<{
message: ProcessedMessage;
index: number;
isLoading: boolean;
currentAnalyzingMessage: string;
expandedToolCalls: Set<string>;
expandedLogEntries: Set<string>;
/** Shared-chat view: no retry, feedback or navigation actions. */
readonly?: boolean;
}>();
const emit = defineEmits<{
(e: "toggle-tool-call", blockIndex: number): void;
(e: "toggle-log-entry", blockIndex: number): void;
(e: "navigate", action: NavigationAction): void;
(e: "retry", message: ProcessedMessage): void;
(e: "like"): void;
(e: "dislike"): void;
(e: "preview-image", img: ImageAttachment): void;
(e: "stop-turn"): void;
}>();
const { t } = useI18nTyped();
const { isLocked, lockedReason } = useLicenseLocks();
const navLock = (action: NavigationAction): I18nText | null => {
const key = navigationFeature(action);
return key && isLocked(key) ? lockedReason(key) : null;
};
// Positional key shape must match the shell's toggleLogEntryExpanded.
const isLogEntryExpanded = (blockIndex: number) =>
props.expandedLogEntries.has(`${props.index}-${blockIndex}`);
// A stopped turn is not an answer to vote on; it is retried instead.
const isStopped = computed(() =>
props.message.contentBlocks.some(
(block) => block.type === "status" && block.turnStatus === "stopped",
),
);
</script>
<template>
<div
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]"
>
<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>
<template v-for="(block, blockIndex) in message.contentBlocks" :key="'cb-' + blockIndex">
<O2AIChatToolCallBlock
v-if="block.type === 'tool_call'"
:block="block"
:message-index="index"
:block-index="blockIndex"
:expanded-keys="expandedToolCalls"
:readonly="readonly"
@toggle="emit('toggle-tool-call', blockIndex)"
@navigate="(action: NavigationAction) => emit('navigate', action)"
/>
<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="emit('toggle-log-entry', blockIndex)"
>
<div class="log-entry-header flex items-center gap-1.5">
<OIcon name="description" size="xs" />
<OTruncatedText :tooltip="false" class="log-entry-info flex-1 text-xs font-medium">
{{ block.preview }}
</OTruncatedText>
<OIcon
:name="isLogEntryExpanded(blockIndex) ? 'expand-less' : 'expand-more'"
size="sm"
class="expand-icon opacity-60 transition-transform duration-200"
/>
</div>
<div v-if="isLogEntryExpanded(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)]"
:aria-label="t('aiAssistant.copyContent')"
@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, readonly)"
></code>
</div>
</div>
</div>
<div
v-else-if="block.type === 'error'"
data-test="o2-ai-chat-stream-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>
<OTooltip
v-if="block.navigationAction && navLock(block.navigationAction)"
:content="navLock(block.navigationAction) ?? undefined"
>
<span class="ms-6 mt-2 inline-flex self-start">
<OButton
variant="primary"
size="xs"
class="stream-error-action text-compact"
disabled
data-test="o2-ai-chat-error-navigate-locked"
>
<template #icon-left
><OIcon name="lock" class="text-icon-locked" size="sm"
/></template>
{{ block.navigationAction.label }}
</OButton>
</span>
</OTooltip>
<OButton
v-else-if="block.navigationAction"
variant="primary"
size="xs"
class="stream-error-action text-compact ms-6 mt-2 self-start"
@click="emit('navigate', block.navigationAction)"
>
{{ block.navigationAction.label }}
</OButton>
</div>
<div
v-else-if="block.type === 'status' && block.turnStatus === 'stopped'"
class="rounded-default border-border-default text-compact text-text-secondary bg-surface-subtle mb-2 flex flex-wrap items-center gap-2 border-s-3 px-3 py-2"
data-test="o2-ai-chat-turn-status-stopped"
>
<OIcon name="stop-circle" size="sm" />
<span class="min-w-0 flex-1">{{ block.message }}</span>
<OButton
v-if="!readonly"
variant="ghost"
size="xs"
data-test="o2-ai-chat-stopped-retry"
@click="emit('retry', message)"
>
<template #icon-left><OIcon name="refresh" size="sm" /></template>
{{ t("common.retry") }}
</OButton>
</div>
<div
v-else-if="block.type === 'status'"
class="text-text-secondary mb-2 flex flex-wrap items-center gap-2 text-xs"
:data-test="`o2-ai-chat-turn-status-${block.turnStatus}`"
>
<OIcon name="hourglass-empty" size="sm" />
<span class="min-w-0 flex-1">{{ block.message }}</span>
<OButton
v-if="!readonly && block.turnStatus === 'running'"
variant="outline"
size="xs"
data-test="o2-ai-chat-running-stop"
@click="emit('stop-turn')"
>
<template #icon-left><OIcon name="stop" size="sm" /></template>
{{ t("aiAssistant.stopTurn") }}
</OButton>
</div>
<div
v-else-if="block.type === 'navigation' && block.navigationAction && !readonly"
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)]"
>
<OTooltip
v-if="navLock(block.navigationAction)"
:content="navLock(block.navigationAction) ?? undefined"
>
<span class="inline-flex">
<OButton
variant="primary"
size="xs"
class="navigation-block-btn text-compact"
disabled
data-test="o2-ai-chat-navigate-locked"
>
<template #icon-left
><OIcon name="lock" class="text-icon-locked" size="sm"
/></template>
{{ block.navigationAction.label }}
</OButton>
</span>
</OTooltip>
<OButton
v-else
variant="primary"
size="xs"
class="navigation-block-btn text-compact"
@click="emit('navigate', block.navigationAction)"
>
<template #icon-left><OIcon :name="'open-in-new'" size="sm" /></template>
{{ block.navigationAction.label }}
</OButton>
</div>
<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
v-if="!readonly"
class="code-block-footer flex w-full items-center justify-between px-2 py-1"
>
<OButton
variant="ghost"
size="xs"
class="retry-button"
@click="emit('retry', 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, readonly)"
></div>
</template>
</template>
</template>
<template v-if="!message.contentBlocks || message.contentBlocks.length === 0">
<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="emit('preview-image', 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, readonly)"
></div>
</template>
</template>
<div
v-if="
!readonly &&
!isStopped &&
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"
:aria-label="t('aiAssistant.helpful')"
@click="emit('like')"
>
<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"
:aria-label="t('aiAssistant.notHelpful')"
@click="emit('dislike')"
>
<OIcon name="thumb-down-off-alt" size="xs" />
<OTooltip :content="t('aiAssistant.notHelpful')" />
</OButton>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* keep(generated-content): v-html markup has no scope attribute, so only :deep() reaches it; !important beats the base 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): the renderer also emits .generated-code-block, and its colours must beat the unlayered .hljs rules. */
.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, so these only reset layout. */
.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 class names, mapped like lib/core/Code/OCodeBlock.vue; dark.css flips the tokens. */
.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>