This step adds the runtime behavior behind the chat UI.
The module has two submit paths:
chat.start_threadruns from/chat/index, creates a conversation, starts the first turn, then navigates to/chat/thread/<id>;chat.submit_messageruns inside an existing thread and patches the currentMessageListincrementally.
Both paths share the same persistence helper.
User Turn Persistence¶
create_user_turn(module_sdk, ctx) reads the composer payload from
ctx["chat_composer"], creates the conversation when needed, persists the user
message, creates attachment rows, and creates task messages for uploads that
started background extraction.
The helper returns:
{
"conversation": conversation,
"message": message,
"attachments": attachments,
"task_messages": task_messages,
"created": created,
}Important details:
- empty text with no attachments raises
ValueError("chat_message_empty"); - attachments keep
storage_path,file_id,extraction_request_id, andbackground_task_id; last_message_atis updated on the conversation;- a new untitled thread receives
@t/chat.thread.untitled.
Messages and component messages share one sequence, so next_sequence(...)
checks both Message and ChatComponent.
Stream Helpers¶
The thread action publishes directly to the current stream:
await module_sdk.effects.publish_collection_append(
stream_id,
"chat_message_list",
"messages",
row,
)The same collection is updated with:
publish_collection_append(...)for user, task, component, and first stream rows;publish_collection_replace(...)for agent status and streaming assistant text;publish_collection_remove(...)for transient status rows;publish_property_update(..., "scroll", "bottom")to keep the timeline pinned;publish_property_update(..., "current_request", request_id)so the composer can cancel the current provider request.
The returned action response stays small because most UI changes have already been published to the stream.
Submit Existing Thread¶
chat.submit_message requires a conversation_id. It publishes an error toast
when the context is missing.
The successful flow is:
- call
create_user_turn(...); - append the user row and any background task rows;
- set
chat_composer.current_requestand clear composer value; - append one transient agent status row;
- call
run_chat_orchestration(...); - replace the streaming assistant row with the persisted assistant message;
- remove the transient status row;
- refresh the sidebar page data and active row.
The action passes callbacks into orchestration:
on_step(row)replaces the status row with the latest important runtime step;on_message(event)persists successful A2UI component tool responses asChatComponentrows and appends component messages;on_stream_delta(text, reasoning)creates or replaces the temporary assistant streaming row.
Start First Thread¶
chat.start_thread reuses the same submit helper, then navigates:
@action("start_thread")
@permission_required(["chat.write"])
async def start_thread(ctx: dict, session: dict, module_sdk):
effects, conversation = await _submit_thread_message(ctx, module_sdk)
return module_sdk.effects.respond(
module_sdk.effects.navigate(f"/chat/thread/{conversation.id}", render=True)
)The first turn can stream into the home page briefly, but the final visible state is the newly created thread route.
Stop Generation¶
chat.stop_generation reads the current request id from ctx["chat_composer"]
and calls the AI runtime cancellation API:
request_id = (ctx.get("chat_composer") or {}).get("current_request")
if request_id:
module_sdk.ai.cancel_request(request_id)
await module_sdk.effects.publish_property_update(
ctx.get("stream_id"),
"chat_composer",
"current_request",
"",
)It does not delete messages. The running submit action receives cancellation and updates the transient status row.
Navigation And Sidebar Actions¶
The current action set includes:
| Action | Purpose |
|---|---|
chat.open_thread |
Navigate to /chat/thread/<item_id>. Accepts item_id and legacy threadId. |
chat.new_thread |
Navigate to /chat. |
chat.thread_page_prev |
Ask the client for current sidebar page and publish previous page data. |
chat.thread_page_next |
Ask the client for current sidebar page and publish next page data. |
chat.rename_thread |
Update title, close drawer, refresh sidebar, show toast. |
chat.delete_thread |
Delete knowledge metadata, media, attachments, components, messages, and conversation. |
chat.load_older_messages |
Prepend an older timeline window and update /chat/current/oldest_sequence. |
Pagination and rename use module_sdk.effects.ask_current_data_value(...) to
read current sidebar state from the client. That keeps drawer and main-content
updates pointed at the correct surface.
Deletion cleans both module-owned data and SDK-owned projections:
module_sdk.knowledge.delete_by_metadata({"conversation_id": conversation_id}, force=True)
module_sdk.media.delete(storage_path)
module_sdk.database.delete(Conversation, conversation_id)Do not delete knowledge repository rows directly from the module.