Appearance
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
| Condition | Action 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)
| Field | Type | Required | Notes |
|---|---|---|---|
regenerate | boolean | No | Default false |
targetMessageId | uuid7 | null | Conditional | Required 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
| Code | HTTP | Meaning |
|---|---|---|
AI.REGENERATE_MISSING_TARGET | 422 | regenerate: true sent without targetMessageId |
AI.REGENERATE_ROLE_MISMATCH | 400 | The referenced message is not an assistant message |
AI.MESSAGE_NOT_FOUND | 404 | Message not found or belongs to a different tenant |
AI.CHAT_BUSY | 409 | Another 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 value | Incoming value | Result |
|---|---|---|
null | like | like |
like | like | null (toggle off) |
dislike | like | like (value switch) |
like | null | null (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
| Code | HTTP | Meaning |
|---|---|---|
AI.MESSAGE_NOT_FOUND | 404 | Message 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):
| Header | Value |
|---|---|
Content-Type | audio/mpeg |
Transfer-Encoding | chunked |
Cache-Control | no-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)
| Condition | HTTP | Error code |
|---|---|---|
| Message text is empty or whitespace-only | 422 | AI.TTS_CONTENT_EMPTY |
| Concatenated text exceeds 5 000 characters | 422 | AI.TTS_CONTENT_TOO_LONG |
| Message belongs to a different tenant | 404 | AI.MESSAGE_NOT_FOUND |
| ElevenLabs returns non-2xx before streaming starts | 502 | AI.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)
→ errorOnly 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:
| State | Icon | aria-label | aria-pressed | aria-busy |
|---|---|---|---|---|
| idle | Volume2 | "Reproducir" | false | false |
| loading (this msg) | Loader2 (spin) | "Reproducir" | false | true |
| playing (this msg) | Square | "Detener" | true | false |
| loading/playing (other msg) | Volume2 | "Reproducir" | false | false (disabled) |
Error codes
| Code | HTTP | Meaning |
|---|---|---|
AI.TTS_CONTENT_EMPTY | 422 | Message has no text content |
AI.TTS_CONTENT_TOO_LONG | 422 | Text exceeds 5 000 character limit |
AI.TTS_UPSTREAM_FAILED | 502 | ElevenLabs returned a non-2xx response |
AI.MESSAGE_NOT_FOUND | 404 | Cross-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.
| Variable | Purpose | Required | Default |
|---|---|---|---|
ELEVENLABS_API_KEY | ElevenLabs API credential | yes | — |
ELEVENLABS_VOICE_ID | Default voice for TTS streams | yes | — |
ELEVENLABS_MODEL_ID | Speech model | no | eleven_flash_v2_5 |
Config loader: apps/api/src/shared/infrastructure/envs/app-config.service.ts → get 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 aiThe migration is generated and applied by the user — it is not committed by the implementation agent.
File Manifest
| File | Description |
|---|---|
apps/api/src/modules/ai/application/commands/message/update-message-feedback.command.ts | CQRS command + handler for the feedback PATCH — owns the server-side toggle logic |
apps/api/src/modules/ai/application/commands/message/stream-message-tts.command.ts | CQRS command + handler for the TTS stream — validates content before calling the gateway |
apps/api/src/modules/ai/application/services/chat-lock.service.ts | Per-chat Redis lock (SET NX, 30s TTL) used exclusively by the regenerate branch |
apps/api/src/modules/ai/application/ports/tts-gateway.port.ts | ITtsGateway port — TtsGatewayKey, ITtsGatewayInput, ITtsGatewayResponse |
apps/api/src/modules/ai/infrastructure/adapters/elevenlabs-tts-gateway.adapter.ts | ElevenLabs adapter — POSTs to /v1/text-to-speech/:voiceId/stream, forwards AbortSignal |
apps/api/src/modules/ai/presentation/controllers/ai-message-actions.controller.ts | New controller at @Controller('ai/messages') — hosts PATCH :id/feedback and GET :id/tts |
apps/panel/src/features/ai-chat/hooks/use-message-feedback.ts | TanStack Query mutation with optimistic update and rollback |
apps/panel/src/features/ai-chat/hooks/use-tts-player.ts | MSE + blob-fallback player, four-state machine, AbortController lifecycle |
apps/panel/src/features/ai-chat/hooks/use-regenerate-intent.ts | Zustand slice — setIntent / consumeIntent for regenerate confirmation flow |
apps/panel/src/features/ai-chat/services/ai-message-actions.service.ts | submitFeedback and streamTts — raw HTTP calls for the two new endpoints |
apps/panel/src/features/ai-chat/lib/chat-transport.ts | Forwards regenerate + targetMessageId to the existing chat POST body |
apps/panel/src/features/ai-chat/components/chat-view.tsx | Action bar rendering, visibility gate, button wiring for all 5 actions |