diff --git a/backend/druks/contrib/chat/app.py b/backend/druks/contrib/chat/app.py index 9d3731d1..49606825 100644 --- a/backend/druks/contrib/chat/app.py +++ b/backend/druks/contrib/chat/app.py @@ -10,6 +10,7 @@ class Chat(App): # Every table this app owns carries the ``chat_`` prefix, so the thread's # schema can never collide with core's or another app's. prefix_tables = True + navigation = ["list"] reply = Agent( description="replies to the operator on one conversation turn", diff --git a/backend/druks/contrib/chat/models.py b/backend/druks/contrib/chat/models.py index 52575e19..37056562 100644 --- a/backend/druks/contrib/chat/models.py +++ b/backend/druks/contrib/chat/models.py @@ -38,6 +38,17 @@ async def create( async def get(cls, conversation_id: int) -> "Conversation | None": return await db_session().get(cls, conversation_id) + @classmethod + async def get_for_account( + cls, conversation_id: int, account_id: str | None + ) -> "Conversation | None": + """This account's thread, or nothing. Another operator's id is a miss, + not a leak.""" + conversation = await cls.get(conversation_id) + if conversation and conversation.account_id == account_id: + return conversation + return + @classmethod async def list_for_account(cls, account_id: str) -> list["Conversation"]: """This account's threads, newest first. A conversation belongs to one @@ -52,6 +63,10 @@ async def list_for_account(cls, account_id: str) -> list["Conversation"]: async def add_message(self, *, role: Role, body: str) -> "Message": return await Message.create(conversation_id=self.id, role=role, body=body) + async def save_autonomy(self, autonomy: Autonomy) -> None: + self.autonomy = autonomy + await db_session().flush() + async def list_messages(self) -> list["Message"]: return await Message.list_for_conversation(self.id) diff --git a/backend/druks/contrib/chat/pages.py b/backend/druks/contrib/chat/pages.py new file mode 100644 index 00000000..33e27628 --- /dev/null +++ b/backend/druks/contrib/chat/pages.py @@ -0,0 +1,143 @@ +from druks import ui +from druks.accounts import current_account_id +from druks.contrib.chat.enums import Autonomy, Role +from druks.contrib.chat.models import Conversation + + +@ui.page("/", label="Conversations") +async def list(): + threads = await Conversation.list_for_account(current_account_id.get()) + return ui.Page( + "Conversations", + controls=[ui.Link("New", page="new")], + blocks=[ + ui.Cards( + title="Threads", + cards=[ + ui.Card( + title=thread.title or thread.label, + controls=[ + ui.Link( + "Open", + page="thread", + arguments={"conversation_id": str(thread.id)}, + ) + ], + ) + for thread in threads + ], + empty=ui.EmptyState( + "No conversations yet", + description="Start one with a first message.", + controls=[ui.Link("New", page="new")], + ), + ) + ], + ) + + +@ui.page("/new") +async def new(): + return ui.Page( + "New conversation", + blocks=[ + ui.Form( + title="New conversation", + description="Title is optional. The first message starts Talk.", + fields=[ + ui.TextField(name="title", label="Title"), + ui.TextAreaField( + name="body", + label="Message", + is_required=True, + rows=4, + ), + ], + action=ui.Action( + label="Start", + operation="create_conversation", + tone="primary", + link=ui.Link("Conversations", page="list"), + ), + ) + ], + ) + + +@ui.page("/conversations/{conversation_id}") +async def thread(conversation_id: int): + conversation = await Conversation.get_for_account(conversation_id, current_account_id.get()) + if conversation: + status = await conversation.get_status() + if status.is_parked: + turn = [ui.GateControls(status.run)] + elif status.is_running: + turn = [ui.Text(status.agent or "The agent is running.")] + else: + turn = [] + cards = [] + for message in await conversation.list_messages(): + if message.role == Role.USER: + speaker = "You" + elif message.role == Role.ASSISTANT: + speaker = "Assistant" + else: + speaker = "System" + cards.append(ui.Card(title=speaker, blocks=[ui.Quote(message.body)])) + return ui.Page( + conversation.title or conversation.label, + controls=[ + ui.Link( + "Settings", + page="settings", + arguments={"conversation_id": str(conversation.id)}, + ), + ui.Link("This conversation", subject=conversation), + ], + blocks=[ + ui.Section( + name="thread", + follows=conversation, + blocks=[ + ui.Cards( + cards=cards, + empty=ui.EmptyState("No messages yet"), + ), + *turn, + ], + ) + ], + ) + return ui.Page( + f"Conversation {conversation_id}", + blocks=[ui.EmptyState("No such conversation")], + ) + + +@thread.child("/settings") +async def settings(conversation_id: int): + conversation = await Conversation.get_for_account(conversation_id, current_account_id.get()) + if conversation: + return ui.Page( + "Settings", + blocks=[ + ui.Form( + title="Autonomy", + fields=[ + ui.SelectField( + name="autonomy", + label="Autonomy", + value=conversation.autonomy, + options=[ui.Option(mode.capitalize(), value=mode) for mode in Autonomy], + is_required=True, + ) + ], + action=ui.Action( + label="Save", + operation="set_autonomy", + arguments={"conversation_id": conversation.id}, + ), + ) + ], + ) + return ui.Page("Settings", blocks=[ui.EmptyState("No such conversation")]) diff --git a/backend/druks/contrib/chat/routes.py b/backend/druks/contrib/chat/routes.py index 4e177e26..56df31c9 100644 --- a/backend/druks/contrib/chat/routes.py +++ b/backend/druks/contrib/chat/routes.py @@ -1,10 +1,10 @@ from typing import Annotated -from fastapi import APIRouter, Body, Depends, status +from fastapi import APIRouter, Body, Depends, HTTPException, status from druks.accounts.dependencies import current_account from druks.accounts.models import Account -from druks.contrib.chat.enums import Role +from druks.contrib.chat.enums import Autonomy, Role from druks.contrib.chat.models import Conversation from druks.contrib.chat.workflows import Talk @@ -15,8 +15,22 @@ async def create_conversation( body: Annotated[str, Body(embed=True)], account: Account = Depends(current_account), + title: Annotated[str, Body(embed=True)] = "", ) -> dict[str, int]: - conversation = await Conversation.create(account_id=account.id, title="") + conversation = await Conversation.create(account_id=account.id, title=title) await conversation.add_message(role=Role.USER, body=body) await Talk.dispatch(conversation=conversation) return {"id": conversation.id} + + +@router.post("/{conversation_id}/autonomy", operation_id="set_autonomy") +async def set_autonomy( + conversation_id: int, + autonomy: Annotated[Autonomy, Body(embed=True)], + account: Account = Depends(current_account), +) -> dict[str, str]: + conversation = await Conversation.get_for_account(conversation_id, account.id) + if conversation: + await conversation.save_autonomy(autonomy) + return {"autonomy": conversation.autonomy} + raise HTTPException(status.HTTP_404_NOT_FOUND, f"No conversation {conversation_id}.") diff --git a/backend/tests/chat/test_models.py b/backend/tests/chat/test_models.py index fd2aae29..a051e9a6 100644 --- a/backend/tests/chat/test_models.py +++ b/backend/tests/chat/test_models.py @@ -64,3 +64,14 @@ async def test_list_summaries_is_this_accounts_threads(): assert listed[0].autonomy == Autonomy.PROPOSE assert listed[0].label == mine.label assert await Conversation.list_summaries(None) == [] + + +async def test_get_for_account_misses_another_operators_thread(): + owner = await Account.get_or_create("op@example.com") + other = await Account.get_or_create("dev@example.com") + mine = await Conversation.create(account_id=owner.id, title="mine") + theirs = await Conversation.create(account_id=other.id, title="theirs") + + assert (await Conversation.get_for_account(mine.id, owner.id)).id == mine.id + assert await Conversation.get_for_account(theirs.id, owner.id) is None + assert await Conversation.get_for_account(mine.id, None) is None diff --git a/backend/tests/chat/test_pages.py b/backend/tests/chat/test_pages.py new file mode 100644 index 00000000..97376166 --- /dev/null +++ b/backend/tests/chat/test_pages.py @@ -0,0 +1,154 @@ +from datetime import UTC, datetime + +from druks.accounts.models import Account +from druks.contrib.chat.enums import Role +from druks.contrib.chat.models import Conversation +from druks.contrib.chat.workflows import ChatTurn, Talk +from druks.testing import seed_run + + +async def test_the_roster_names_chat_pages(druks_client): + roster = {entry["name"]: entry for entry in (await druks_client.get("/api/apps")).json()} + pages = roster["chat"]["pages"] + by_name = {page["name"]: page for page in pages} + assert set(by_name) == {"list", "new", "thread", "settings"} + assert by_name["list"]["path"] == "/chat" + assert by_name["new"]["path"] == "/chat/new" + assert by_name["thread"]["path"] == "/chat/conversations/{conversation_id}" + assert by_name["settings"]["path"] == "/chat/conversations/{conversation_id}/settings" + assert by_name["settings"]["parent"] == "thread" + assert by_name["list"]["parent"] == "" + assert by_name["list"]["label"] == "Conversations" + + +async def test_the_list_page_shows_this_accounts_threads(druks_client): + owner = await Account.get_or_create("op@example.com") + other = await Account.get_or_create("dev@example.com") + mine = await Conversation.create(account_id=owner.id, title="Pump") + await Conversation.create(account_id=other.id, title="theirs") + + page = (await druks_client.get("/api/chat/pages")).json() + + assert page["title"] == "Conversations" + assert page["controls"][0]["page"] == "new" + (cards,) = page["blocks"] + (card,) = cards["cards"] + assert card["title"] == "Pump" + assert card["controls"][0] == { + "block": "link", + "label": "Open", + "page": "thread", + "arguments": {"conversation_id": str(mine.id)}, + "url": "", + "subject": None, + } + + +async def test_the_list_page_empty_state_points_at_new(druks_client): + page = (await druks_client.get("/api/chat/pages")).json() + + (cards,) = page["blocks"] + assert cards["cards"] == [] + assert cards["empty"]["controls"][0]["page"] == "new" + + +async def test_the_new_page_collects_an_optional_title_and_a_required_message(druks_client): + page = (await druks_client.get("/api/chat/pages/new")).json() + + (form,) = page["blocks"] + assert form["block"] == "form" + assert [field["name"] for field in form["fields"]] == ["title", "body"] + assert form["fields"][0]["isRequired"] is False + assert form["fields"][1]["isRequired"] is True + assert form["action"]["operation"] == "create_conversation" + + +async def test_the_thread_shows_messages_and_follows_the_conversation(druks_client): + account = await Account.get_or_create("op@example.com") + conversation = await Conversation.create(account_id=account.id, title="Pump") + await conversation.add_message(role=Role.USER, body="hello") + + page = (await druks_client.get(f"/api/chat/pages/conversations/{conversation.id}")).json() + + assert page["title"] == "Pump" + region = page["blocks"][0] + assert region["follows"] == { + "subjectType": "conversation", + "subjectId": str(conversation.id), + } + (cards, *turn) = region["blocks"] + assert turn == [] + (card,) = cards["cards"] + assert card["title"] == "You" + assert card["blocks"][0] == {"block": "quote", "text": "hello"} + assert page["controls"][1]["subject"] == { + "subjectType": "conversation", + "subjectId": str(conversation.id), + } + + +async def test_a_parked_turn_puts_gate_controls_on_the_thread(druks_client, druks_db): + account = await Account.get_or_create("op@example.com") + conversation = await Conversation.create(account_id=account.id, title="") + run = await seed_run( + druks_db, + kind=Talk.kind, + subject=conversation, + state="parked", + input_gate=ChatTurn.name, + input_request={"presentation": "in_app", "label": "Chat turn"}, + ) + run.input_requested_at = datetime.now(UTC) + await druks_db.flush() + + page = (await druks_client.get(f"/api/chat/pages/conversations/{conversation.id}")).json() + + region = page["blocks"][0] + assert region["blocks"][-1] == {"block": "gate_controls", "run": run.id} + + +async def test_a_running_turn_shows_status_not_a_gate(druks_client, druks_db): + account = await Account.get_or_create("op@example.com") + conversation = await Conversation.create(account_id=account.id, title="") + await seed_run(druks_db, kind=Talk.kind, subject=conversation, state="running") + + page = (await druks_client.get(f"/api/chat/pages/conversations/{conversation.id}")).json() + + region = page["blocks"][0] + assert region["blocks"][-1]["block"] == "text" + assert all(block["block"] != "gate_controls" for block in region["blocks"]) + + +async def test_another_operators_thread_is_an_empty_state(druks_client): + other = await Account.get_or_create("dev@example.com") + conversation = await Conversation.create(account_id=other.id, title="secret") + await conversation.add_message(role=Role.USER, body="nope") + + page = (await druks_client.get(f"/api/chat/pages/conversations/{conversation.id}")).json() + + assert page["blocks"][0]["block"] == "empty_state" + assert "secret" not in str(page) + assert "nope" not in str(page) + + settings = ( + await druks_client.get(f"/api/chat/pages/conversations/{conversation.id}/settings") + ).json() + assert settings["blocks"][0]["block"] == "empty_state" + + +async def test_settings_offers_the_autonomy_modes(druks_client): + account = await Account.get_or_create("op@example.com") + conversation = await Conversation.create(account_id=account.id, title="Pump") + + page = ( + await druks_client.get(f"/api/chat/pages/conversations/{conversation.id}/settings") + ).json() + + (form,) = page["blocks"] + assert form["action"]["operation"] == "set_autonomy" + assert form["fields"][0]["name"] == "autonomy" + assert [option["value"] for option in form["fields"][0]["options"]] == [ + "propose", + "confirm", + "full", + ] diff --git a/backend/tests/chat/test_routes.py b/backend/tests/chat/test_routes.py index f47b6cf3..471497a5 100644 --- a/backend/tests/chat/test_routes.py +++ b/backend/tests/chat/test_routes.py @@ -1,5 +1,5 @@ from druks.accounts.models import Account -from druks.contrib.chat.enums import Role +from druks.contrib.chat.enums import Autonomy, Role from druks.contrib.chat.models import Conversation from druks.contrib.chat.workflows import Talk @@ -29,6 +29,49 @@ async def dispatch(*, conversation): assert [message.role for message in messages] == [Role.USER] +async def test_create_conversation_stores_an_optional_title(druks_client, monkeypatch): + async def dispatch(**kwargs): + return "run" + + monkeypatch.setattr(Talk, "dispatch", staticmethod(dispatch)) + + created = await druks_client.post( + "/api/chat/conversations", + json={"body": "hello", "title": "Pump"}, + ) + + assert created.status_code == 201 + conversation = await Conversation.get(created.json()["id"]) + assert conversation.title == "Pump" + + +async def test_set_autonomy_updates_this_accounts_thread(druks_client): + account = await Account.get_or_create("op@example.com") + conversation_id = (await Conversation.create(account_id=account.id, title="mine")).id + + response = await druks_client.post( + f"/api/chat/conversations/{conversation_id}/autonomy", + json={"autonomy": "full"}, + ) + + assert response.status_code == 200 + assert response.json()["autonomy"] == "full" + assert (await Conversation.get(conversation_id)).autonomy == Autonomy.FULL + + +async def test_set_autonomy_misses_another_operators_thread(druks_client): + other = await Account.get_or_create("dev@example.com") + conversation = await Conversation.create(account_id=other.id, title="theirs") + + response = await druks_client.post( + f"/api/chat/conversations/{conversation.id}/autonomy", + json={"autonomy": "full"}, + ) + + assert response.status_code == 404 + assert (await Conversation.get(conversation.id)).autonomy == "propose" + + async def test_a_later_post_starts_another_conversation_not_a_new_talk_on_the_first( druks_client, monkeypatch ): diff --git a/backend/tests/test_app_roster.py b/backend/tests/test_app_roster.py index e9038420..b21c9cdb 100644 --- a/backend/tests/test_app_roster.py +++ b/backend/tests/test_app_roster.py @@ -20,6 +20,7 @@ def test_roster_lists_installed_apps_with_subject_types(tmp_path: Path): assert chat["hasFrontend"] is False assert chat["icon"] == "message-square" assert chat["subjectTypes"] == ["conversation"] + assert chat["navigation"] == [["/chat", "Conversations"]] field_notes = roster["field_notes"] assert field_notes["subjectTypes"] == ["note", "repository"]