Newer
Older
navi-1 / webclient / src / stores / chat / turn.js
import { t } from '@/i18n/index.js'
import { notifyNativeTurn } from '@/composables/nativeBridge.js'
import { useSessionsStore } from '@/stores/sessions.js'

import { tryGenerateName } from './naming.js'

/**
 * The turn's own event stream: stream start/end, thinking, planning, the text
 * deltas, compression notices, errors and the model that answered. Tool cards
 * and tasks live in their own modules; a few of these handlers only need
 * `_lastSpawnCard` to route a sub-agent's thinking into its spawn card.
 */

export function createTurnEvents({ state, tools, messageHelpers }) {
  const {
    messages,
    streaming,
    awaitingStream,
    streamingMsg,
    replayMode,
    currentId,
    contextTokens,
    maxContextTokens,
    servedModel,
    servedTarget,
    servedFallback,
    currentProfileId,
    toolIndex,
  } = state
  const { _lastSpawnCard } = tools
  const { markMessageFailed } = messageHelpers

  // Id for the finished turn's assistant block: the server's global display
  // rank when it sent one, the old local count otherwise (older server).
  // The rank is what a reload recomputes for the same block, so ids, Vue keys
  // and feedback keys agree before and after a reload.
  function _assistantMsgId(data) {
    const index = data?.message_index
    if (Number.isInteger(index) && index >= 0) return `h_${index}`
    return `h_${messages.value.filter(m => m.role === 'assistant').length - 1}`
  }

  function onStreamStart() {
    // On reconnect: remove the incomplete frozen message so replay rebuilds it cleanly.
    // On a fresh start streamingMsg is null, so this branch is skipped.
    if (streamingMsg.value) {
      const idx = messages.value.indexOf(streamingMsg.value)
      if (idx !== -1) messages.value.splice(idx, 1)
      streamingMsg.value = null
    }
    streaming.value = true
    awaitingStream.value = false
    // A run (fresh or queued-drain) has started — the queued badge drains now.
    // Messages queued in this tab are consumed in order, one per turn, so the
    // oldest mark comes off here.
    const nextQueued = messages.value.find(m => m.queued)
    if (nextQueued) nextQueued.queued = false
    toolIndex.clear()
    const msg = {
      id: `stream_${Date.now()}`,
      role: 'assistant',
      type: 'stream',
      thinking: null,    // { text, done } | null
      tools: [],         // tool card objects
      text: '',
      done: false,
      time: new Date().toISOString(),
      animate: !replayMode.value,  // no cursor animation during replay
      statusLabel: null,
    }
    messages.value.push(msg)
    // Store the reactive proxy (from the array) so mutations trigger Vue updates
    streamingMsg.value = messages.value[messages.value.length - 1]
  }

  function onReplayStart() {
    replayMode.value = true
  }

  function onReplayEnd() {
    replayMode.value = false
    // Restore animate on the message now that live events will follow
    if (streamingMsg.value) streamingMsg.value.animate = true
  }

  function onThinkingDelta(delta) {
    const msg = streamingMsg.value
    if (!msg) return
    if (!msg.thinking) msg.thinking = { text: '', done: false }
    msg.thinking.text += delta
  }

  function onThinkingEnd() {
    const msg = streamingMsg.value
    if (msg?.thinking) msg.thinking.done = true
  }

  function onTurnThinking(data) {
    const msg = streamingMsg.value
    if (!msg) return
    // Shape item so ThinkingCard can receive it directly as :msg (stable reference, no new object each render)
    const item = { kind: 'turn_thinking', isSubagent: data.is_subagent ?? false, thinking: { text: data.thinking ?? '', done: true } }
    if (data.is_subagent) {
      const spawn = _lastSpawnCard(msg)
      if (spawn) { spawn.steps.push(item); return }
    }
    msg.tools.push(item)
  }

  function onPlanningStatus(data) {
    const msg = streamingMsg.value
    if (!msg) return
    if (data.is_subagent) {
      const spawn = _lastSpawnCard(msg)
      if (spawn) spawn.planningLabel = data.label ?? ''
      return  // never bleed subagent planning into parent UI
    }
    msg.statusLabel = data.label ?? ''
  }

  function onPlanReady(data) {
    const msg = streamingMsg.value
    if (!msg) return
    if (data.is_subagent) {
      const spawn = _lastSpawnCard(msg)
      if (spawn) {
        spawn.planningLabel = null
        spawn.steps.push({ kind: 'plan', text: data.plan ?? '' })
      }
      return  // never bleed subagent plan into parent UI
    }
    msg.statusLabel = null
    msg.tools.push({ kind: 'plan', text: data.plan ?? '' })
  }

  // The server took a message from another tab into the queue. Nothing is drawn
  // for it here: the queue shows on the message itself (msg.queued), and the
  // separate counter that used to live in the input area is gone — two places
  // describing one queue disagreed more than they helped. The only effect left
  // is dropping the "sending" hint: the run will pick the message up on its own.
  function onMessageQueued() {
    awaitingStream.value = false
  }

  // The server's queue overflowed and evicted messages that will never run.
  // Their bubbles must stop looking queued — silence here is what made a lost
  // message look simply slow. The ids are the senders' own `client_id`s; a tab
  // that does not own one finds no bubble and ignores it.
  function onMessageDropped(data) {
    const ids = data?.client_ids ?? (data?.client_id ? [data.client_id] : [])
    let dropped = 0
    for (const key of ids) {
      if (markMessageFailed(key, 'dropped')) dropped++
    }
    if (dropped && !streaming.value) awaitingStream.value = false
  }

  function onStreamDelta(delta) {
    const msg = streamingMsg.value
    if (!msg) return
    if (msg.statusLabel) msg.statusLabel = null
    msg.text += delta
  }

  // A run can end without every started tool reporting: Stop cancels an
  // in-flight tool, a frame is lost on a half-open socket, the server restarts
  // mid-run, or the backend dies with an `error` frame. A card is drawn from
  // tool_started and only its tool_call closes it, so whatever is still
  // pending here would spin for the rest of the session — until a reload
  // rebuilt the transcript from history (which always marks cards settled).
  // Close them explicitly, sub-agent steps included, with a visible note.
  function _settlePendingTools(msg) {
    if (!msg) return
    const settle = card => {
      if (!card || card.kind !== 'tool' || !card.pending) return
      card.pending = false
      card.success = false
      if (card.result == null) card.result = t('messages.toolInterrupted')
    }
    for (const item of msg.tools ?? []) {
      settle(item)
      for (const step of item.steps ?? []) settle(step)
    }
  }

  function onStreamEnd(data) {
    const msg = streamingMsg.value
    if (msg) {
      msg.done = true
      _settlePendingTools(msg)
      msg.elapsed_seconds = data?.elapsed_seconds ?? null
      msg.tool_call_count  = data?.tool_call_count  ?? null
      msg.token_count      = data?.token_count      ?? null
      // Assign the stable h_ id so the rating UI appears and feedback keys the
      // same block a reload would.
      msg.id = _assistantMsgId(data)
      streamingMsg.value = null
      // Purge if nothing was ever written (avoids empty msg-assistant divs)
      if (!msg.thinking && !msg.tools.length && !msg.text) {
        messages.value = messages.value.filter(m => m !== msg)
      }
    }
    streaming.value = false
    awaitingStream.value = false

    if (data?.context_tokens != null) contextTokens.value = data.context_tokens
    if (data?.max_context_tokens != null) maxContextTokens.value = data.max_context_tokens

    // Update session preview
    if (currentId.value && msg?.text) {
      useSessionsStore().updatePreview(currentId.value, msg.text.slice(0, 80))
    }

    // Android app: raise a native notification when the answer finished while
    // the window was hidden (bridge notify() re-checks visibility + the flag).
    if (msg?.text && currentId.value) {
      notifyNativeTurn('Navi ответила', msg.text.slice(0, 140), `/#${currentId.value}`)
    }

    // Try to generate session name in background (only if not named yet)
    if (currentId.value) {
      tryGenerateName(currentId.value)
    }
  }

  function onStreamStopped(data) {
    const msg = streamingMsg.value
    if (msg) {
      msg.done = true
      _settlePendingTools(msg)
      msg.id = _assistantMsgId(data)
      streamingMsg.value = null
      if (!msg.thinking && !msg.tools.length && !msg.text) {
        messages.value = messages.value.filter(m => m !== msg)
      }
    }
    streaming.value = false
    awaitingStream.value = false
  }

  function onProfileSwitched(data) {
    currentProfileId.value = data.profile_id
    // The new profile may route elsewhere entirely.
    servedModel.value = null
    servedTarget.value = null
    servedFallback.value = false
  }

  // Emitted once per turn by the backend after it resolved which entry of the
  // route answered. `target`/`fallback` are only present when the route engine
  // stamped them (a bare OllamaBackend sends just the model).
  function onModelInfo(data) {
    if (!data?.model) return
    servedModel.value = data.model
    servedTarget.value = data.target ?? null
    servedFallback.value = Boolean(data.fallback)
  }

  function onCompressionStarted(data) {
    if (data?.context_tokens != null) contextTokens.value = data.context_tokens
    if (data?.max_context_tokens != null) maxContextTokens.value = data.max_context_tokens
    // Insert a transient notice that will be replaced by the real compression card
    messages.value.push({
      id: `compress_pending_${Date.now()}`,
      role: 'system',
      type: 'compression_pending'
    })
  }

  function onContextCompressed(data) {
    if (data?.context_tokens != null) contextTokens.value = data.context_tokens
    if (data?.max_context_tokens != null) maxContextTokens.value = data.max_context_tokens
    // Remove any pending compression notices — the real one is here now
    messages.value = messages.value.filter(m => m.type !== 'compression_pending')
    messages.value.push({
      id: `compress_${Date.now()}`,
      role: 'system',
      type: 'compression_notice',
      before: data.messages_before,
      after: data.messages_after,
      summary: data.summary ?? ''
    })
  }

  function onError(data) {
    const msg = streamingMsg.value
    if (msg) {
      // The half-built turn stays in the transcript — its thinking and tool
      // cards are what the run actually did — but it must stop looking live:
      // mark it done and close anything the error left mid-flight.
      msg.done = true
      _settlePendingTools(msg)
      if (!msg.thinking && !msg.tools.length && !msg.text) {
        messages.value = messages.value.filter(m => m !== msg)
      }
    }
    streaming.value = false
    awaitingStream.value = false
    streamingMsg.value = null
    messages.value.push({
      id: `err_${Date.now()}`,
      role: 'system',
      type: 'error',
      text: data.message ?? 'An error occurred'
    })
  }

  return {
    onStreamStart,
    onReplayStart,
    onReplayEnd,
    onThinkingDelta,
    onThinkingEnd,
    onTurnThinking,
    onPlanningStatus,
    onPlanReady,
    onMessageQueued,
    onMessageDropped,
    onStreamDelta,
    onStreamEnd,
    onStreamStopped,
    onProfileSwitched,
    onModelInfo,
    onCompressionStarted,
    onContextCompressed,
    onError,
  }
}