PhoenixKitAgent.Web.Dock (PhoenixKitAgent v0.1.0)

Copy Markdown View Source

The assistant in the admin header (spec §10): a button before the notifications bell, the list of the user's chats under it, and the chat panel on the right. A sticky LiveView, rendered by core's admin layout as the module's header widget (PhoenixKitAgent.header_widgets/0), so it keeps its state across admin navigation.

Whose panel it is

The widget's session (PhoenixKitAgent.header_widget_session/1) holds only the user's uuid and locale. Who acts is decided here, in the connected mount, from the session LiveView hands the panel — the cookie session merged in (spec §6.5): the session reference (SessionRef.from_session/1) and whether the account is borrowed (MultiSession.impersonated?/1). The panel then works only when the session is not impersonated, belongs to the uuid of the widget's session, and may use the assistant (PhoenixKitAgent.Conversations.authorize_session/2). Otherwise — and in the static render, before any of it is known — it renders nothing and every event is ignored: no list, no chat, no call (spec §8, В13).

Every event goes through PhoenixKitAgent.Conversations, PhoenixKitAgent.Turns, PhoenixKitAgent.Attachments and PhoenixKitAgent.UserConfig with the reference and the flag, so each one is checked against the session's scope as it is at that moment; a uuid from the browser is only ever a chat or a file to look up for that user. A chat's topic is subscribed to only through Conversations.subscribe/3, after its ownership is checked.

The feed

Opening a chat reads its latest messages (Conversations.list_messages/3, latest: true) into a stream; "Earlier messages" brings the page before (before: the first one shown). A new message is announced on the chat's topic and read with list_messages/3 (after: the last one shown) or get_message/4 — the chat is never read again whole; a message of the user moves the chat to the top of the list with its title. Only the chat shown is listened to. A tool call is shown folded, by its summary (tool_summary), never with what the tool returned; a model answer through PhoenixKitAgent.Web.Markdown (no outside pictures, no raw HTML, every link with its domain and rel="noopener noreferrer nofollow", spec §8.2).

A write the model asked for is a confirmation card in the feed (PhoenixKitAgent.Confirmations): every tool message waiting for one is shown with what Confirmations.cards/4 says of it — core's preview through core's operation_preview component, the arguments the model sent as plain text (never markdown or a link), and "Run" / "Cancel the change"; "Ask again" once it ran out; only "Cancel the change" once the user may no longer use its tool. Opening a chat first gives the cards core no longer holds their notice (Confirmations.settle_lapsed/3: a click lost with its node, a row that ran out), so none of them waits on the page or above it. Opening the chat, "Earlier messages" and every new message find the cards by their confirmation_uuid, so a card still waiting is waiting again after a reload; the notice that settles a card renders it again. A card that waits (no notice yet) further back than the page of the chat the panel loaded is found too (Confirmations.waiting_cards/3): it is shown above the feed (under an id of its own), until "Earlier messages" reaches it and it takes its place in the feed. A click runs off this process: a task under PhoenixKitAgent.Confirmations.TaskSupervisor, not linked to the panel, which the panel waits for (start_async/3), so a closed tab does not stop a write half-way. A click while the card is busy does nothing; a refusal is shown on the card, which keeps waiting.

A chat that is locked by a turn lost with its node gets the notice "the answer was interrupted" when it is opened (Turns.settle_interrupted/3). A notice is shown by its error code in the panel's language (PhoenixKitAgent.Web.Dock.Texts.notice/1), else as it was stored. "New chat" only empties the panel: the chat is made with its first message or file, and deleted again (Conversations.delete_conversation/3) when that message or file is refused while nothing else is in it.

Sending, stopping, files

"Send" starts a turn (Turns.start/6) with the files that are ready, the page the browser is on (a path, for the model's context, spec §7.1 p.5) and the user's locale; an exception there is caught, logged as its class only, and shown as a notice in the panel. A send while the panel shows a turn first asks the chat's lock (Turns.settle_interrupted/3): while the turn runs the send is let go; a turn whose end never reached the panel is let go of, and the message goes. A chat busy with a turn of another tab shows that turn, with its "Stop". "Stop" asks the turn to stop — on whichever node runs it (Turns.stop/4 passes the request on over PubSub while the chat's lock is within its time); nothing answers it but the turn's end, so when that has not come within the grace period and one more check of the lock, the panel says the answer has not stopped and offers "Stop" again (a turn lost with its node frees the chat when its lock expires). When Turns.stop/4 finds no such turn, the chat's lock says what to show. The indicator says whether the model thinks or a tool is called; in the browser it gives up after the deadline of a turn and its grace period (Monitor.stop_grace_ms/0), counted again for each turn and after a lost connection is found again. The indicator follows the server only: while a turn is shown as running, the panel reads the chat's lock again every :turn_check_ms (application environment, default 5 s), so a turn whose end never reached it — it came between the read of the chat and the subscription, or was lost between nodes — is let go of, with the messages it left.

Files come through allow_upload (the kinds and the size of §9, the number of the user's settings) and are handed to Attachments.add/4 with the path of LiveView's own temporary file (consume_uploaded_entry/3, kept until add/4 is done, which runs off this process); the row is read again after add/4 (it may be settled already) and shown only while its chat is the one open, and a failed one can be removed. An upload can be cancelled while it is on its way, not once add/4 has it; a cancel of an entry that is not there changes nothing. An exception in add/4 is caught in its task and logged as its class only.

In the browser

The panel opens with the class pk-dock-open on <body> (PhoenixKitWeb.Components.AdminDock), which survives a live redirect. Open or closed, the chosen chat and the width are kept in localStorage by the .DockState hook and restored (restore) when the panel mounts again (another live_session, a reload) and when it joins again after a lost connection, which mounts the LiveView afresh; the turn shown then is the one the chat's lock holds now, and every restore patches the whole panel, so nothing the browser kept from before the connection was lost stays. A panel kept closed reads nothing until a chat is opened; a remembered chat that is not the user's active one any more is forgotten. The list opens from a focusable div (Safari does not focus a clicked <button>, and the list is open while it has the focus); choosing a chat moves the focus to the panel, "New chat" to the input, so the list closes. The markup and the hooks are in PhoenixKitAgent.Web.Dock.Panel; the hooks are runtime colocated hooks, so the host bundles nothing for them, but they are inline scripts: under a Content-Security-Policy the page needs script-src 'unsafe-inline' (they carry no nonce). The text being written stays the browser's (phx-update="ignore"), so no re-render wipes it, and goes to the server only with the form (phx-debounce="blur").

The tools get the user's locale, the endpoint's URL and, when the host's socket passes :peer_data / :x_headers in its connect_info, the client address as their request context.

Nothing in the log

log: false: LiveView would log every event with its parameters — the user's text. No text of a message, an answer or a file name is logged here. An exception in an event, a message of the chat, an upload's progress or a finished task is caught (guarded/2), logged as its class only, and shown as a notice: a crash report would hold the event's parameters (the draft) and the whole state (the chats, the feed). Not covered: a crash in mount or in render still ends the LiveView with such a report.