Newer
Older
navi-1 / webclient / src / components / messages / AssistantMessage.vue
<template>
  <div
    v-if="msg.thinking || msg.tools?.length || msg.text || !msg.done"
    class="msg-assistant"
  >
    <!-- Thinking block -->
    <ThinkingCard
      v-if="msg.thinking"
      :msg="msg"
    />

    <!-- Tool / plan cards -->
    <template v-for="(item, i) in msg.tools" :key="i">
      <ThinkingCard v-if="item.kind === 'turn_thinking'" :msg="item" />
      <ContentCard
        v-else-if="item.kind === 'tool' && item.name === 'content_publish'"
        :tool="item"
        :default-open="contentToolKey(msg, i) === latestContentToolKey"
        :collapse-token="latestContentToolKey"
      />
      <details v-else-if="item.kind === 'plan'" class="plan-card">
        <summary>
          <i class="ph ph-map-trifold"></i>
          Plan
          <i class="ph ph-caret-down plan-chevron"></i>
        </summary>
        <div class="plan-body" v-html="renderMarkdown(item.text)" />
      </details>
      <ToolCard v-else :tool="item" />
    </template>

    <!-- Text response -->
    <div
      v-if="msg.text"
      ref="contentEl"
      class="msg-assistant-content"
      v-html="renderedText"
    />

    <!-- Planning / waiting indicator -->
    <div
      v-if="turnActivityLabel"
      class="planning-indicator"
      :class="{ 'is-executing': activeToolLabel }"
    >
      <span class="spinner"></span>
      <span class="planning-label">{{ turnActivityLabel }}</span>
    </div>

    <!-- Streaming cursor -->
    <span v-else-if="!msg.done && msg.text" class="stream-cursor" />

    <!-- Stats + timestamp row + copy + rating -->
    <div v-if="msg.done" class="msg-meta-row">
      <span v-if="msg.elapsed_seconds != null" class="msg-meta-item">
        <i class="ph ph-timer"></i>{{ msg.elapsed_seconds }}s
      </span>
      <span v-if="msg.tool_call_count" class="msg-meta-item">
        <i class="ph ph-wrench"></i>{{ msg.tool_call_count }}
      </span>
      <span v-if="msg.token_count" class="msg-meta-item">
        <i class="ph ph-coins"></i>{{ compactNum(msg.token_count) }}
      </span>
      <span v-if="msgTime" class="msg-meta-item msg-meta-time">{{ msgTime }}</span>
      <GnIconButton
        v-if="msg.text"
        :icon="copied ? 'ph-check' : 'ph-copy'"
        :label="copied ? 'Copied!' : 'Copy'"
        class="msg-copy-btn"
        @click="copy(msg.text)"
      />
      <GnIconButton
        v-if="canRate"
        icon="ph-thumbs-up"
        label="Useful"
        class="msg-rate-btn"
        :class="{ active: msg.rating === 1 }"
        @click="rate(1)"
      />
      <GnIconButton
        v-if="canRate"
        icon="ph-thumbs-down"
        label="Not useful"
        class="msg-rate-btn"
        :class="{ active: msg.rating === -1 }"
        @click="rate(-1)"
      />
    </div>
  </div>
</template>

<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import ThinkingCard from './ThinkingCard.vue'
import ToolCard from './ToolCard.vue'
import ContentCard from './ContentCard.vue'
import { renderMarkdown, attachCopyButtons } from '@/composables/useMarkdown.js'
import { useTimeLabel } from '@/composables/useTime.js'
import { useCopy } from '@/composables/useCopy.js'
import { useChatStore } from '@/stores/chat.js'

const props = defineProps({ msg: { type: Object, required: true } })

const contentEl = ref(null)
const chat = useChatStore()

const renderedText = computed(() => renderMarkdown(props.msg.text))
const tsRef = computed(() => props.msg.done ? props.msg.time : null)
const msgTime = useTimeLabel(tsRef)
const hasMeta = computed(() =>
  props.msg.elapsed_seconds != null || props.msg.tool_call_count != null || props.msg.token_count != null
)
const latestContentToolKey = computed(() => {
  let latest = null
  for (const message of chat.messages) {
    if (!message?.tools?.length) continue
    message.tools.forEach((tool, index) => {
      if (tool.kind === 'tool' && tool.name === 'content_publish') {
        latest = contentToolKey(message, index)
      }
    })
  }
  return latest
})
const activeToolLabel = computed(() => {
  const pending = findPendingTool(props.msg.tools || [])
  if (!pending) return ''
  return `Running ${toolDisplayName(pending)}...`
})
const turnActivityLabel = computed(() => {
  if (props.msg.done) return ''
  if (props.msg.statusLabel) return props.msg.statusLabel
  if (activeToolLabel.value) return activeToolLabel.value
  if (props.msg.text) return ''
  if (hasVisibleToolProgress(props.msg.tools || [])) return 'Preparing next action...'
  return 'Thinking...'
})
// Only history blocks (id `h_<index>`) are persisted and can be rated.
// Streaming / summary / error placeholders skip the rating UI.
const canRate = computed(() => typeof props.msg.id === 'string' && props.msg.id.startsWith('h_'))

const { copied, copy } = useCopy()

async function rate(value) {
  try {
    await chat.rateMessage(props.msg, value)
  } catch (err) {
    console.error('rate failed', err)
  }
}

function compactNum(n) {
  if (!n) return ''
  if (n >= 1000) return (n / 1000).toFixed(1).replace('.0', '') + 'k'
  return String(n)
}

function contentToolKey(message, index) {
  return `${message.id}:content:${index}`
}

function findPendingTool(items) {
  for (let i = items.length - 1; i >= 0; i--) {
    const item = items[i]
    if (item.kind === 'tool' && item.pending) return item
    if (item.steps?.length) {
      const nested = findPendingTool(item.steps)
      if (nested) return nested
    }
  }
  return null
}

function hasVisibleToolProgress(items) {
  return items.some(item => {
    if (item.kind === 'tool' || item.kind === 'plan' || item.kind === 'turn_thinking') return true
    return item.steps?.length ? hasVisibleToolProgress(item.steps) : false
  })
}

function toolDisplayName(tool) {
  if (tool.name === 'filesystem' && tool.args?.action) return `filesystem ${tool.args.action}`
  return tool.name
}

// Attach copy buttons after render — only when message is done to avoid
// repeated DOM queries on every streaming delta.
watch(
  () => props.msg.done,
  (done) => {
    if (done) nextTick(() => attachCopyButtons(contentEl.value))
  },
  { immediate: true }
)
</script>

<style scoped>
.stream-cursor {
  display: inline-block;
  width: 2px;
  height: 1em;
  background: currentColor;
  margin-left: 2px;
  vertical-align: text-bottom;
  animation: blink 1s step-end infinite;
}

@keyframes blink {
  0%, 100% { opacity: 1; }
  50%       { opacity: 0; }
}

.msg-rate-btn {
  opacity: 0.55;
  transition: opacity 0.15s, color 0.15s;
}
.msg-rate-btn:hover { opacity: 1; }
.msg-rate-btn.active {
  opacity: 1;
  color: var(--accent, #4ec9b0);
}
</style>