Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/druks/contrib/chat/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions backend/druks/contrib/chat/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down
143 changes: 143 additions & 0 deletions backend/druks/contrib/chat/pages.py
Original file line number Diff line number Diff line change
@@ -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")])
20 changes: 17 additions & 3 deletions backend/druks/contrib/chat/routes.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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}.")
11 changes: 11 additions & 0 deletions backend/tests/chat/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
154 changes: 154 additions & 0 deletions backend/tests/chat/test_pages.py
Original file line number Diff line number Diff line change
@@ -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",
]
Loading