Newer
Older
navi-1 / webclient / src / stores / chat / messages.js
/**
 * The message transcript: turning stored history into display messages,
 * appending what this tab sent, collecting the images Navi looked at, and
 * patching a protocol proposal once it is decided.
 */

export function createMessageHelpers(state) {
  const { messages, viewedImages, _viewedImageKeys } = state

  // Add viewed-by-Navi images (data URIs), skipping ones already collected —
  // live tool_call events and history reload both carry the same payload.
  function _addViewedImage(imgs) {
    for (const src of imgs) {
      if (!src || _viewedImageKeys.has(src)) continue
      _viewedImageKeys.add(src)
      viewedImages.value.push(src)
    }
  }

  // The server returns a flat array:
  //   { role:'assistant', tool_calls:[{id,name,arguments}] }  ← tool request (no content)
  //   { role:'tool', tool_call_id, name, content }            ← tool result
  //   { role:'assistant', content }                           ← final text response
  //
  // We group them into single AssistantMessage objects with tools[] + text.
  function buildMessageList(raw) {
    const result = []
    let i = 0

    // Global display position of raw item idx. Server pages (history endpoint)
    // stamp each item with metadata.display_index = rank in the FULL display
    // history; h_ ids and rawIndices must use that global rank so ids stay
    // stable across paged loads (feedback keys, message jumps).
    const gi = idx => {
      const m = raw[idx]
      return m?.metadata?.display_index ?? idx
    }

    while (i < raw.length) {
      const m = raw[i]
      const base = gi(i)

      // Compression notice
      if (m.is_compression) {
        result.push({ id: `h_${base}`, role: 'system', type: 'compression_notice', summary: m.content ?? '' })
        i++; continue
      }

      // Synapse reaction event — the service record of a platform event. It is a
      // system-role message, but it is addressed to the reader (and to the model)
      // rather than being infrastructure, so it gets its own card.
      if (m.role === 'system' && m.metadata?.source === 'synapse_event') {
        result.push({
          id: `h_${base}`,
          role: 'system',
          type: 'synapse_event',
          text: m.content ?? '',
          eventType: m.metadata?.event_type ?? '',
          time: m.created_at ?? null,
          rawIndices: [base],
        })
        i++; continue
      }

      // Skip bare system messages
      if (m.role === 'system') { i++; continue }

      if (m.is_summary) {
        result.push({ id: `h_${base}`, role: 'assistant', type: 'summary', text: m.content ?? '', rawIndices: [base] })
        i++; continue
      }

      if (m.role === 'user') {
        const imgs = (m.images ?? []).map(b =>
          (typeof b === 'string' && b.startsWith('data:')) ? b : `data:image/jpeg;base64,${String(b ?? '')}`
        )
        // Synthetic "load this image" messages created by the server when a
        // vision tool ran (tool_executor/subagent_runner). Not a real user
        // turn — strip from the chat feed; the images land in the artifacts
        // panel's viewed-images group instead.
        if (m.content?.startsWith?.('[Image loaded via') && imgs.length) {
          _addViewedImage(imgs)
          i++; continue
        }
        result.push({ id: `h_${base}`, role: 'user', text: m.content ?? '', images: imgs, files: m.files ?? [], time: m.created_at ?? null, rawIndices: [base], isRecall: m.is_recall ?? false })
        i++; continue
      }

      if (m.role === 'assistant') {
        // Collect all tool-call rounds + final content into one message
        const firstIdx = i
        const firstG = base
        const tools = []
        let thinking = null
        let text = ''
        let time = null

        while (i < raw.length && raw[i].role === 'assistant') {
          const am = raw[i]

          // Plan card — stored as a separate is_plan message, inject into tools array
          if (am.is_plan) {
            tools.push({ kind: 'plan', text: am.content ?? '' })
            i++
            continue
          }

          // Accumulate thinking from any assistant turn (first one wins for display)
          if (am.thinking && !thinking) {
            thinking = { text: am.thinking, done: true }
          }

          if (am.tool_calls?.length) {
            // Build a lookup map for this round's tool calls
            const callMap = {}
            for (const tc of am.tool_calls) {
              const card = {
                kind: 'tool',
                id: tc.id,
                name: tc.name,
                args: tc.arguments ?? {},
                result: null,
                success: true,
                pending: false,
                isSubagent: false,
                steps: []
              }
              tools.push(card)
              callMap[tc.id] = card
            }
            i++
            // Collect matching tool results
            while (i < raw.length && raw[i].role === 'tool') {
              const tr = raw[i]
              const card = callMap[tr.tool_call_id]
              if (card) {
                card.result = tr.content ?? ''
                card.success = !tr.content?.startsWith('Error:')
                card.metadata = tr.metadata ?? {}
                // UI components are rendered from the tool result metadata so they
                // stay inside the same assistant turn after page reload. Any tool
                // can carry one; the registry decides whether it is known.
                if (card.success && card.metadata?.ui_component) {
                  tools.push({
                    kind: 'ui_component',
                    component: card.metadata.ui_component.component ?? '',
                    payload: card.metadata.ui_component.payload ?? {},
                  })
                }
              }
              i++
            }
          } else {
            const content = am.content ?? ''
            if (!time && am.created_at) time = am.created_at
            i++
            if (content) {
              // Non-empty text → this is the final response
              text = content
              break
            }
            // Empty content: if no more assistant messages follow, we're done
            if (i >= raw.length || raw[i].role !== 'assistant') {
              break
            }
            // Otherwise skip this empty intermediate message and keep accumulating
          }
        }

        // Only add the message if there's something to show
        if (thinking || tools.length || text) {
          const msgId = `h_${firstG}`
          // Scan the group for metrics (saved on the final assistant turn)
          let elapsed_seconds = null, tool_call_count = null, token_count = null
          for (let j = firstIdx; j < i; j++) {
            const am = raw[j]
            if (!am || am.role !== 'assistant') continue
            if (am.elapsed_seconds != null) elapsed_seconds = am.elapsed_seconds
            if (am.tool_call_count != null) tool_call_count = am.tool_call_count
            if (am.token_count     != null) token_count     = am.token_count
          }
          result.push({ id: msgId, role: 'assistant', type: 'history', thinking, tools, text, done: true, time, elapsed_seconds, tool_call_count, token_count, rawIndices: Array.from({ length: i - firstIdx }, (_, j) => gi(firstIdx + j)) })
        }
        continue
      }

      // Orphan tool message (shouldn't happen)
      i++
    }

    return result
  }

  // `queued` marks a message the user sent while a turn was already running.
  // It rides in the socket until the server finishes that turn and reads it —
  // the same "write now, it is processed next" model navi-code uses. The mark
  // disappears on the stream_start of the turn that consumes it.
  //
  // `clientKey` is the id this message carries on the wire (`client_id`). The
  // server echoes it in its ack, and delivery events find the bubble by it.
  function appendUserMessage(text, images, files, { queued = false, clientKey = null } = {}) {
    const msg = {
      id: `user_${Date.now()}`,
      role: 'user',
      text,
      images: [...images],
      files: [...files],
      time: new Date().toISOString(),
      queued,
      clientKey,
      animate: true
    }
    messages.value.push(msg)
    // Read it back so the caller gets the reactive proxy: mutating the raw
    // object would not wake the bubble that has to show the failure.
    // The caller marks the bubble when the socket refuses it (queue full).
    return messages.value[messages.value.length - 1]
  }

  /**
   * Mark the bubble for `clientKey` as not delivered and say why.
   *
   * `reason` is one of 'unconfirmed' (written to the socket, no ack within the
   * deadline), 'dropped' (the server's queue overflowed and evicted it) or
   * 'queue_full' (the socket queue here refused it). Returns whether a bubble
   * was found — an ack for a message whose transcript was replaced is not an
   * error.
   */
  function markMessageFailed(clientKey, reason = 'queue_full') {
    if (!clientKey) return false
    const msg = messages.value.find(m => m.clientKey === clientKey)
    if (!msg) return false
    msg.failed = true
    msg.queued = false
    msg.failReason = msg.failReason ?? reason
    return true
  }

  // A protocol proposal the user accepted or rejected in the chat card. The
  // server performed the catalog write; patching every card that shows this
  // proposal is what turns the buttons into a decided state — including cards
  // duplicated across turns.
  function onProposalDecided(data) {
    if (!data?.proposal_id) return
    for (const msg of messages.value) {
      for (const item of msg.tools || []) {
        if (
          item.kind === 'ui_component'
          && item.component === 'skill_proposal'
          && item.payload?.proposal_id === data.proposal_id
        ) {
          item.payload.status = data.status
          item.payload.skill_id = data.skill_id ?? null
          item.payload.error = data.error || ''
        }
      }
    }
  }

  return { _addViewedImage, buildMessageList, appendUserMessage, markMessageFailed, onProposalDecided }
}