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,
}
}