Skip to content

Message Actions

The assistant message action bar now renders on all assistant messages. It exposes five actions: Copy, Regenerate, Like, Dislike, and Speaker (text-to-speech). This document describes the API contracts, semantics, and frontend architecture for each action.

Action Bar Visibility Rules

ConditionAction bar visible?
message.role === 'assistant' and not streaming✅ Yes — all 5 buttons
message.role === 'assistant' and currently streaming❌ No — hidden during active stream
message.role === 'user'❌ No

Previously the action bar only appeared on the last assistant message. That isLastMessage gate has been removed. The new rule is: show on every assistant message except the one that is actively streaming (only the last message can ever be streaming).

See apps/panel/src/features/ai-chat/components/chat-view.tsx for the implementation of the visibility gate.


Regenerate — Tail-Delete Semantics

Clicking Regenerate on an assistant message deletes that message and every message that follows it in the chat, then streams a new assistant reply from the preserved user prompt.

What the frontend sends

The chat transport (apps/panel/src/features/ai-chat/lib/chat-transport.ts) enriches the existing POST /ai/agent/:id/chat body with two additional fields:

json
{
  "chatId": "<uuid7>",
  "parts": [...],
  "regenerate": true,
  "targetMessageId": "<assistant-message-uuid7>"
}

When regenerate === false (normal send) both fields default to false / null and the handler is unaffected.

API contract

Endpoint: POST /ai/agent/:id/chat (modified — existing route)

FieldTypeRequiredNotes
regeneratebooleanNoDefault false
targetMessageIduuid7 | nullConditionalRequired when regenerate === true

Schema enforces the conditional requirement via a .refine() check in packages/schemas/src/ai/agents/create-message.schema.ts.

Tail-delete semantics

Given a chat with messages [User-A, Assistant-B, User-C, Assistant-D] and a regenerate request targeting Assistant-B:

Before: User-A → Assistant-B → User-C → Assistant-D
After:  User-A → Assistant-E  (new stream)

The SQL shape is: DELETE FROM ai.messages WHERE chat_id = $chatId AND org_id = $orgId AND created_at >= $anchor.createdAt.

Critical-section protection

A per-chat Redis lock (ai:chat:lock:<chatId>) with a 30-second TTL is acquired before the tail delete. The lock covers only the delete + cache invalidation critical section. It is released before the LLM stream starts — holding it through the stream would serialize all regenerates and destroy time-to-first-token (TTFT).

Implementation: apps/api/src/modules/ai/application/services/chat-lock.service.ts

Error codes

CodeHTTPMeaning
AI.REGENERATE_MISSING_TARGET422regenerate: true sent without targetMessageId
AI.REGENERATE_ROLE_MISMATCH400The referenced message is not an assistant message
AI.MESSAGE_NOT_FOUND404Message not found or belongs to a different tenant
AI.CHAT_BUSY409Another regenerate is already in progress for this chat (Redis lock held)

UX — Confirmation dialog for non-last-message regenerate

Clicking Regenerate on a message that is not the last assistant message opens an AlertDialog (via @base-ui/react/alert-dialog) with a destructive warning before proceeding. Regenerating the last assistant message triggers immediately without a dialog.

Implementation: apps/panel/src/features/ai-chat/components/chat-view.tsx + apps/panel/src/features/ai-chat/hooks/use-regenerate-intent.ts

Edge case: delete succeeds, stream fails

If the tail delete commits successfully but the LLM call subsequently fails, the deleted messages are not restored. The user loses the tail and receives an error. This is an accepted tradeoff — clicking Regenerate again is always available.


Feedback — Like / Dislike Toggle

Clicking the thumbs-up or thumbs-down button on any assistant message sends a feedback signal. Clicking the same button again toggles it off (sets to null).

API contract

Endpoint: PATCH /ai/messages/:id/feedback
Auth: JWT session cookie → TrackingContext

Request body:

json
{ "feedback": "like" | "dislike" | null }

Response (200):

json
{ "id": "<uuid7>", "feedback": "like" | "dislike" | null }

Schema: packages/schemas/src/ai/messages/feedback.schema.ts

Server-side toggle logic

The toggle is resolved server-side by the UpdateMessageFeedbackHandler:

Stored valueIncoming valueResult
nulllikelike
likelikenull (toggle off)
dislikelikelike (value switch)
likenullnull (explicit clear)

The client sends its intent (the value of the button clicked), and the server computes the effective value. This keeps toggle logic in one authoritative place.

Error codes

CodeHTTPMeaning
AI.MESSAGE_NOT_FOUND404Message not found or cross-tenant request

Frontend — optimistic update

The useMessageFeedback hook (apps/panel/src/features/ai-chat/hooks/use-message-feedback.ts) applies an optimistic update via TanStack Query before the network call completes. On error, it rolls back automatically.

Known limitation (MVP): The optimistic update is written to the TanStack Query cache for the ['ai', 'chat', chatId, 'messages'] key. The rendered messages in the chat view come from the AI SDK useChat state, not directly from this cache key. The visual state will not update until the next query invalidation or page navigation. This is tracked as a follow-up.

Storage

The feedback column lives on ai.messages:

  • Column: feedback
  • Type: Postgres enum ai_message_feedback (values: like, dislike)
  • Nullable: yes, default null
  • TypeORM entity: apps/api/src/modules/ai/infrastructure/persistence/message.persistence.ts

Speaker — Text-to-Speech via ElevenLabs

Clicking the Speaker button on any assistant message streams an MP3 audio response synthesized by ElevenLabs in real time.

API contract

Endpoint: GET /ai/messages/:id/tts
Auth: JWT session cookie → TrackingContext
No request body.

Response (200):

HeaderValue
Content-Typeaudio/mpeg
Transfer-Encodingchunked
Cache-Controlno-store

The body is a raw MP3 byte stream piped directly from ElevenLabs — no server-side buffering.

Rate limit

20 requests per hour per user (@RateLimit({ windowMs: '60m', limit: 20, identifierType: 'userId' })). Exceeding this returns HTTP 429 before any upstream call is made.

Content validation (pre-stream)

ConditionHTTPError code
Message text is empty or whitespace-only422AI.TTS_CONTENT_EMPTY
Concatenated text exceeds 5 000 characters422AI.TTS_CONTENT_TOO_LONG
Message belongs to a different tenant404AI.MESSAGE_NOT_FOUND
ElevenLabs returns non-2xx before streaming starts502AI.TTS_UPSTREAM_FAILED

Validation happens before any call to ElevenLabs. The text is the concatenation of all text-type parts on the message entity.

Abort semantics

When the client disconnects (tab close, navigation, or pressing Stop), req.on('close') fires and AbortController.abort() is called. This cancels the upstream fetch() to ElevenLabs immediately — no orphan connections are left open.

Implementation: apps/api/src/modules/ai/infrastructure/adapters/elevenlabs-tts-gateway.adapter.ts

Frontend — useTtsPlayer hook

The useTtsPlayer hook (apps/panel/src/features/ai-chat/hooks/use-tts-player.ts) manages playback state with a four-state machine:

idle → loading → playing → idle (stop/end)
                         → error

Only one message can play at a time. Clicking Play on a second message while one is loading or playing automatically aborts the first.

Primary path — MediaSource Extensions (MSE): Streams chunks into a SourceBuffer, starting audio playback as soon as the first chunk is appended. This enables near-real-time playback.

Fallback — Blob URL: For browsers without audio/mpeg MSE support (some iOS Safari versions), the hook downloads the full response as a Blob, creates an object URL, and plays it via new Audio(url). Playback starts only after the full download completes.

The Speaker button in chat-view.tsx reflects the active state:

StateIconaria-labelaria-pressedaria-busy
idleVolume2"Reproducir"falsefalse
loading (this msg)Loader2 (spin)"Reproducir"falsetrue
playing (this msg)Square"Detener"truefalse
loading/playing (other msg)Volume2"Reproducir"falsefalse (disabled)

Error codes

CodeHTTPMeaning
AI.TTS_CONTENT_EMPTY422Message has no text content
AI.TTS_CONTENT_TOO_LONG422Text exceeds 5 000 character limit
AI.TTS_UPSTREAM_FAILED502ElevenLabs returned a non-2xx response
AI.MESSAGE_NOT_FOUND404Cross-tenant or missing message

ElevenLabs Environment Variables

The following variables must be set for the TTS feature to function. Add them to your .env (or secret manager) before deploying.

VariablePurposeRequiredDefault
ELEVENLABS_API_KEYElevenLabs API credentialyes
ELEVENLABS_VOICE_IDDefault voice for TTS streamsyes
ELEVENLABS_MODEL_IDSpeech modelnoeleven_flash_v2_5

Config loader: apps/api/src/shared/infrastructure/envs/app-config.service.tsget elevenlabs().


Migration Note

The feedback column on ai.messages requires a database migration. Run the following commands after deploying this change:

sh
pnpm db:migration:generate apps/api/src/modules/ai/infrastructure/migrations/add-message-feedback -d ai
pnpm db:migration:run -d ai

The migration is generated and applied by the user — it is not committed by the implementation agent.


File Manifest

FileDescription
apps/api/src/modules/ai/application/commands/message/update-message-feedback.command.tsCQRS command + handler for the feedback PATCH — owns the server-side toggle logic
apps/api/src/modules/ai/application/commands/message/stream-message-tts.command.tsCQRS command + handler for the TTS stream — validates content before calling the gateway
apps/api/src/modules/ai/application/services/chat-lock.service.tsPer-chat Redis lock (SET NX, 30s TTL) used exclusively by the regenerate branch
apps/api/src/modules/ai/application/ports/tts-gateway.port.tsITtsGateway port — TtsGatewayKey, ITtsGatewayInput, ITtsGatewayResponse
apps/api/src/modules/ai/infrastructure/adapters/elevenlabs-tts-gateway.adapter.tsElevenLabs adapter — POSTs to /v1/text-to-speech/:voiceId/stream, forwards AbortSignal
apps/api/src/modules/ai/presentation/controllers/ai-message-actions.controller.tsNew controller at @Controller('ai/messages') — hosts PATCH :id/feedback and GET :id/tts
apps/panel/src/features/ai-chat/hooks/use-message-feedback.tsTanStack Query mutation with optimistic update and rollback
apps/panel/src/features/ai-chat/hooks/use-tts-player.tsMSE + blob-fallback player, four-state machine, AbortController lifecycle
apps/panel/src/features/ai-chat/hooks/use-regenerate-intent.tsZustand slice — setIntent / consumeIntent for regenerate confirmation flow
apps/panel/src/features/ai-chat/services/ai-message-actions.service.tssubmitFeedback and streamTts — raw HTTP calls for the two new endpoints
apps/panel/src/features/ai-chat/lib/chat-transport.tsForwards regenerate + targetMessageId to the existing chat POST body
apps/panel/src/features/ai-chat/components/chat-view.tsxAction bar rendering, visibility gate, button wiring for all 5 actions