django-chat-ui 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. django_chat_ui/__init__.py +2 -0
  2. django_chat_ui/admin.py +86 -0
  3. django_chat_ui/agui.py +180 -0
  4. django_chat_ui/apps.py +19 -0
  5. django_chat_ui/backend.py +269 -0
  6. django_chat_ui/content.py +215 -0
  7. django_chat_ui/locale/es/LC_MESSAGES/django.mo +0 -0
  8. django_chat_ui/locale/es/LC_MESSAGES/django.po +571 -0
  9. django_chat_ui/management/__init__.py +0 -0
  10. django_chat_ui/management/commands/__init__.py +0 -0
  11. django_chat_ui/management/commands/chat_ui_prune.py +22 -0
  12. django_chat_ui/matrix.py +275 -0
  13. django_chat_ui/migrations/0001_initial.py +225 -0
  14. django_chat_ui/migrations/__init__.py +0 -0
  15. django_chat_ui/models.py +480 -0
  16. django_chat_ui/py.typed +0 -0
  17. django_chat_ui/rules.py +345 -0
  18. django_chat_ui/services.py +644 -0
  19. django_chat_ui/static/django_chat_ui/boot.js +61 -0
  20. django_chat_ui/static/django_chat_ui/chat-inbox.js +270 -0
  21. django_chat_ui/static/django_chat_ui/chat-thread.js +886 -0
  22. django_chat_ui/static/django_chat_ui/chat-widget.js +229 -0
  23. django_chat_ui/static/django_chat_ui/chat.css +894 -0
  24. django_chat_ui/static/django_chat_ui/client.js +370 -0
  25. django_chat_ui/static/django_chat_ui/content.js +422 -0
  26. django_chat_ui/static/django_chat_ui/dom.js +13 -0
  27. django_chat_ui/static/django_chat_ui/homeserver.js +493 -0
  28. django_chat_ui/static/django_chat_ui/matrix.js +75 -0
  29. django_chat_ui/static/django_chat_ui/preview.js +47 -0
  30. django_chat_ui/static/django_chat_ui/store.js +358 -0
  31. django_chat_ui/static/django_chat_ui/theme.js +123 -0
  32. django_chat_ui/sync.py +438 -0
  33. django_chat_ui/templates/django_chat_ui/chat_ui.html +4 -0
  34. django_chat_ui/templatetags/__init__.py +0 -0
  35. django_chat_ui/templatetags/chat_ui.py +38 -0
  36. django_chat_ui/texts.py +76 -0
  37. django_chat_ui/urls.py +74 -0
  38. django_chat_ui/views.py +699 -0
  39. django_chat_ui-0.1.0.dist-info/METADATA +470 -0
  40. django_chat_ui-0.1.0.dist-info/RECORD +42 -0
  41. django_chat_ui-0.1.0.dist-info/WHEEL +4 -0
  42. django_chat_ui-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,2 @@
1
+ """A reusable chat interface over one protocol (docs/protocol.md), and a
2
+ Django backend for it."""
@@ -0,0 +1,86 @@
1
+ """The conversations in the admin: to look at them, not to chat (changes
2
+ made here reach no open interface; use the services or the chat).
3
+
4
+ With models of a site's own (``CHAT_UI_CONVERSATION_MODEL``,
5
+ ``CHAT_UI_PARTICIPANT_MODEL``), register them with these classes, as one
6
+ does with ``UserAdmin``:
7
+
8
+ @admin.register(Ticket)
9
+ class TicketAdmin(ConversationAdmin):
10
+ list_display = [*ConversationAdmin.list_display, "priority"]
11
+ """
12
+
13
+ from django.contrib import admin
14
+
15
+ from .models import (
16
+ Conversation,
17
+ Media,
18
+ Membership,
19
+ Participant,
20
+ RoomEvent,
21
+ StreamEntry,
22
+ )
23
+
24
+
25
+ class ParticipantAdmin(admin.ModelAdmin):
26
+ list_display = ["__str__", "kind", "handle", "user", "created"]
27
+ list_filter = ["kind"]
28
+ search_fields = ["name", "handle"]
29
+ raw_id_fields = ["user"]
30
+
31
+
32
+ class MembershipInline(admin.TabularInline):
33
+ model = Membership
34
+ extra = 0
35
+ raw_id_fields = ["participant", "last_read"]
36
+
37
+
38
+ class RoomEventInline(admin.TabularInline):
39
+ model = RoomEvent
40
+ fk_name = "conversation"
41
+ extra = 0
42
+ fields = ["created", "sender", "type", "content"]
43
+ readonly_fields = ["created", "sender", "type", "content"]
44
+ can_delete = False
45
+ show_change_link = True
46
+
47
+ def has_add_permission(self, request, obj=None):
48
+ return False
49
+
50
+
51
+ class ConversationAdmin(admin.ModelAdmin):
52
+ list_display = ["__str__", "channel", "state", "updated"]
53
+ list_filter = ["channel", "state"]
54
+ search_fields = ["title"]
55
+ inlines = [MembershipInline, RoomEventInline]
56
+
57
+
58
+ def register_models(site: admin.AdminSite) -> None:
59
+ """This package's models, unless a site swapped them for its own."""
60
+ if not Participant._meta.swapped:
61
+ site.register(Participant, ParticipantAdmin)
62
+ if not Conversation._meta.swapped:
63
+ site.register(Conversation, ConversationAdmin)
64
+
65
+
66
+ register_models(admin.site)
67
+
68
+
69
+ @admin.register(RoomEvent)
70
+ class RoomEventAdmin(admin.ModelAdmin):
71
+ list_display = ["__str__", "type", "conversation", "sender", "created"]
72
+ list_filter = ["type", "conversation__channel"]
73
+ raw_id_fields = ["conversation", "sender", "relates_to", "redacted_by"]
74
+
75
+
76
+ @admin.register(Media)
77
+ class MediaAdmin(admin.ModelAdmin):
78
+ list_display = ["name", "conversation", "mime", "size", "created"]
79
+ raw_id_fields = ["conversation", "uploader"]
80
+
81
+
82
+ @admin.register(StreamEntry)
83
+ class StreamEntryAdmin(admin.ModelAdmin):
84
+ list_display = ["__str__", "kind", "type", "conversation", "recipient", "created"]
85
+ list_filter = ["kind"]
86
+ raw_id_fields = ["conversation", "recipient", "event"]
django_chat_ui/agui.py ADDED
@@ -0,0 +1,180 @@
1
+ """An AI agent of any framework that speaks AG-UI (https://docs.ag-ui.com:
2
+ LangGraph, CrewAI, Mastra, Pydantic AI, Agno…) answering in a
3
+ conversation, as it writes.
4
+
5
+ from django_chat_ui.agui import AgUiAgent
6
+
7
+ agent = AgUiAgent("https://agents.example.com/shop")
8
+
9
+ class Backend(ChatBackend):
10
+ hooks = "thread"
11
+
12
+ def on_message(self, request, message):
13
+ if message.sender and message.sender.kind != "bot":
14
+ agent.answer(message.conversation, bot)
15
+
16
+ The conversation goes to the agent as a run (``RunAgentInput``: its
17
+ messages, ``threadId`` the room's ID); each text message it streams back
18
+ (``TEXT_MESSAGE_START`` / ``_CONTENT`` / ``_END``, or ``_CHUNK``) is a
19
+ message of ``bot``, through ``services.stream_text()``. Other events reach
20
+ ``handle()``, to override (tool calls, state…).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import itertools
26
+ import json
27
+ import urllib.request
28
+ import uuid
29
+ from typing import TYPE_CHECKING, Any
30
+
31
+ from . import services
32
+ from .matrix import room_id
33
+
34
+ if TYPE_CHECKING:
35
+ from collections.abc import Callable, Iterable, Iterator
36
+
37
+ from .models import AbstractConversation, AbstractParticipant, RoomEvent
38
+
39
+ TEXT = {"TEXT_MESSAGE_CONTENT", "TEXT_MESSAGE_CHUNK"}
40
+
41
+
42
+ class AgUiError(Exception):
43
+ """The agent's ``RUN_ERROR``."""
44
+
45
+ def __init__(self, message: str, code: str | None = None):
46
+ super().__init__(message)
47
+ self.code = code
48
+
49
+
50
+ def sse(lines: Iterable[bytes]) -> Iterator[dict[str, Any]]:
51
+ """The events of a Server-Sent Events stream, as JSON objects."""
52
+ data: list[str] = []
53
+ for raw in lines:
54
+ line = raw.decode("utf-8").rstrip("\r\n")
55
+ if line.startswith("data:"):
56
+ data.append(line[5:].removeprefix(" "))
57
+ elif not line and data:
58
+ try:
59
+ event = json.loads("\n".join(data))
60
+ except ValueError:
61
+ event = None
62
+ data = []
63
+ if isinstance(event, dict):
64
+ yield event
65
+ if data:
66
+ try:
67
+ event = json.loads("\n".join(data))
68
+ except ValueError:
69
+ return
70
+ if isinstance(event, dict):
71
+ yield event
72
+
73
+
74
+ class AgUiAgent:
75
+ """An AG-UI agent at ``url`` (``headers``: its authentication)."""
76
+
77
+ def __init__(
78
+ self,
79
+ url: str,
80
+ *,
81
+ headers: dict[str, str] | None = None,
82
+ timeout: float = 120,
83
+ opener: Callable[..., Any] = urllib.request.urlopen,
84
+ ):
85
+ self.url = url
86
+ self.headers = headers or {}
87
+ self.timeout = timeout
88
+ self.opener = opener
89
+
90
+ def messages(
91
+ self, conversation: AbstractConversation, bot: AbstractParticipant
92
+ ) -> list[dict[str, Any]]:
93
+ """The conversation as the agent takes it: the bot's messages are
94
+ the assistant's; anyone else's, the user's (system notices left out).
95
+ Edited messages, as they are now."""
96
+ edits = {
97
+ edit.relates_to_id: edit.content.get("m.new_content") or {}
98
+ for edit in conversation.messages.filter(rel_type="m.replace")
99
+ }
100
+ result = []
101
+ for message in conversation.messages.exclude(rel_type="m.replace").filter(
102
+ redacted_by=None
103
+ ):
104
+ if message.sender_id is None:
105
+ continue
106
+ body = str(edits.get(message.pk, message.content).get("body", ""))
107
+ if not body:
108
+ continue
109
+ role = "assistant" if message.sender_id == bot.pk else "user"
110
+ result.append({"id": message.event_id, "role": role, "content": body})
111
+ return result
112
+
113
+ def run_input(
114
+ self, conversation: AbstractConversation, bot: AbstractParticipant
115
+ ) -> dict[str, Any]:
116
+ """``RunAgentInput``: override it to give the agent tools, context or
117
+ state."""
118
+ return {
119
+ "threadId": room_id(conversation),
120
+ "runId": uuid.uuid4().hex,
121
+ "state": {},
122
+ "messages": self.messages(conversation, bot),
123
+ "tools": [],
124
+ "context": [],
125
+ "forwardedProps": {},
126
+ }
127
+
128
+ def events(self, payload: dict[str, Any]) -> Iterator[dict[str, Any]]:
129
+ """Run the agent: its events, as they come."""
130
+ request = urllib.request.Request(
131
+ self.url,
132
+ data=json.dumps(payload).encode(),
133
+ headers={
134
+ "Content-Type": "application/json",
135
+ "Accept": "text/event-stream",
136
+ **self.headers,
137
+ },
138
+ method="POST",
139
+ )
140
+ with self.opener(request, timeout=self.timeout) as response:
141
+ yield from sse(response)
142
+
143
+ def handle(self, event: dict[str, Any], conversation: AbstractConversation) -> None:
144
+ """An event that isn't text: ``RUN_STARTED``, tool calls, state…"""
145
+
146
+ def answer(
147
+ self, conversation: AbstractConversation, bot: AbstractParticipant
148
+ ) -> list[RoomEvent]:
149
+ """Run the agent on the conversation; its text messages, sent as
150
+ ``bot`` as they're written. A ``RUN_ERROR`` raises ``AgUiError``,
151
+ after sending what came."""
152
+ error: list[AgUiError] = []
153
+
154
+ def texts() -> Iterator[tuple[str, str]]:
155
+ current = ""
156
+ for event in self.events(self.run_input(conversation, bot)):
157
+ kind = event.get("type")
158
+ if kind == "TEXT_MESSAGE_START":
159
+ current = str(event.get("messageId") or current)
160
+ elif kind in TEXT and isinstance(event.get("delta"), str):
161
+ current = str(event.get("messageId") or current)
162
+ yield current, event["delta"]
163
+ elif kind == "RUN_ERROR":
164
+ error.append(
165
+ AgUiError(str(event.get("message", "")), event.get("code"))
166
+ )
167
+ return
168
+ elif kind not in {"TEXT_MESSAGE_END"}:
169
+ self.handle(event, conversation)
170
+
171
+ sent = []
172
+ for _message, chunks in itertools.groupby(texts(), key=lambda item: item[0]):
173
+ message = services.stream_text(
174
+ conversation, bot, (delta for _id, delta in chunks)
175
+ )
176
+ if message is not None:
177
+ sent.append(message)
178
+ if error:
179
+ raise error[0]
180
+ return sent
django_chat_ui/apps.py ADDED
@@ -0,0 +1,19 @@
1
+ from django.apps import AppConfig
2
+ from django.utils.translation import gettext_lazy as _
3
+
4
+
5
+ class ChatUiConfig(AppConfig):
6
+ name = "django_chat_ui"
7
+ verbose_name = _("Chat")
8
+ default_auto_field = "django.db.models.BigAutoField"
9
+
10
+ def ready(self) -> None:
11
+ from django.conf import settings
12
+
13
+ from .models import DEFAULTS
14
+
15
+ # Always there, as AUTH_USER_MODEL is: migrations (this package's,
16
+ # and a site's pointing to its models) name them.
17
+ for name, default in DEFAULTS.items():
18
+ if not hasattr(settings, name):
19
+ setattr(settings, name, default)
@@ -0,0 +1,269 @@
1
+ """What a site decides: who is chatting, what they see, what conversations
2
+ they can start and what happens when someone writes. Subclass
3
+ ``ChatBackend`` and name it in the settings:
4
+
5
+ CHAT_UI = {"BACKEND": "myapp.chat.Backend"}
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import threading
12
+ from functools import cache
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from django.conf import settings
16
+ from django.contrib.auth import authenticate, get_user_model
17
+ from django.db import connections, transaction
18
+ from django.utils.module_loading import import_string
19
+ from django.utils.translation import gettext_lazy as _
20
+
21
+ from . import services
22
+ from .models import (
23
+ AbstractConversation,
24
+ AbstractParticipant,
25
+ RoomEvent,
26
+ get_conversation_model,
27
+ get_participant_model,
28
+ )
29
+
30
+ if TYPE_CHECKING:
31
+ from collections.abc import Callable
32
+
33
+ from django.db.models import QuerySet
34
+ from django.http import HttpRequest
35
+
36
+ SESSION_KEY = "django_chat_ui_participant"
37
+
38
+ logger = logging.getLogger("django_chat_ui")
39
+
40
+
41
+ class ChatBackend:
42
+ #: Whether visitors who aren't logged in can chat (the "contact us"
43
+ #: widget of a public site). Each gets a participant kept in their
44
+ #: session.
45
+ allow_anonymous = False
46
+
47
+ #: Where the hooks that react (``on_start``, ``on_message``,
48
+ #: ``on_action``, ``on_event``) run: ``"inline"``, inside the request
49
+ #: that caused them, or ``"thread"``, in a thread of their own once its
50
+ #: changes are committed, so it's answered at once (a bot that takes
51
+ #: seconds doesn't hold the sender's message). For a task queue,
52
+ #: override ``run_hook()``.
53
+ hooks = "inline"
54
+
55
+ #: The types of events of extensions that clients may send, besides
56
+ #: messages and reactions: namespaced (``com.acme.order.cancel``). Each
57
+ #: sent reaches ``on_event()``, after ``validate_content()``.
58
+ event_types: frozenset[str] | set[str] = frozenset()
59
+
60
+ #: Largest upload, in bytes, when a conversation's ``attachments`` don't
61
+ #: say.
62
+ max_upload_size = 5 * 1024 * 1024
63
+
64
+ # Who -------------------------------------------------------------------
65
+
66
+ def get_participant(self, request: HttpRequest) -> AbstractParticipant | None:
67
+ """The participant of this request, or None (403). Users get one on
68
+ their first request; staff are agents."""
69
+ user = request.user
70
+ if user.is_authenticated:
71
+ return self.participant_for_user(user)
72
+ if not self.allow_anonymous:
73
+ return None
74
+ pk = request.session.get(SESSION_KEY)
75
+ participant = get_participant_model().objects.filter(pk=pk, user=None).first()
76
+ if participant is None:
77
+ participant = get_participant_model().objects.create(
78
+ kind="contact", name=str(_("Visitor"))
79
+ )
80
+ request.session[SESSION_KEY] = participant.pk
81
+ return participant
82
+
83
+ def login(
84
+ self, request: HttpRequest, username: str, password: str
85
+ ) -> AbstractParticipant | None:
86
+ """The participant of a Matrix client logging in with a password
87
+ (``POST /login``): a user of the site's, with theirs."""
88
+ user = authenticate(request, username=username, password=password)
89
+ if user is None or not user.is_active:
90
+ return None
91
+ return self.participant_for_user(user)
92
+
93
+ def participant_for_user(self, user: Any) -> AbstractParticipant:
94
+ participant, _ = get_participant_model().objects.get_or_create(
95
+ user=user,
96
+ defaults={
97
+ "kind": "agent" if user.is_staff else "user",
98
+ "name": user.get_full_name() or user.get_username(),
99
+ },
100
+ )
101
+ return participant
102
+
103
+ # What they see ---------------------------------------------------------
104
+
105
+ def conversations(
106
+ self, participant: AbstractParticipant
107
+ ) -> QuerySet[AbstractConversation]:
108
+ """The conversations ``participant`` sees: those they take part in.
109
+ The stream only carries events of these."""
110
+ return get_conversation_model().objects.filter(
111
+ memberships__participant=participant
112
+ )
113
+
114
+ def serialize_participant(
115
+ self, participant: AbstractParticipant, data: dict[str, Any]
116
+ ) -> dict[str, Any]:
117
+ """This package's block of a participant's ``m.room.member`` (its
118
+ ``kind``, ``handle``, ``avatar`` and ``ext``): add what a site's
119
+ model has, under ``ext`` with your namespace (``{"com.acme": …}``)."""
120
+ return data
121
+
122
+ def serialize_conversation(
123
+ self, conversation: AbstractConversation, data: dict[str, Any]
124
+ ) -> dict[str, Any]:
125
+ """This package's state event of what a conversation is (its
126
+ ``channel``, ``state`` and ``ext``): add what a site's model has (its
127
+ priority, who it's assigned to…), under ``ext``."""
128
+ return data
129
+
130
+ # Starting conversations ------------------------------------------------
131
+
132
+ def can_start(
133
+ self,
134
+ participant: AbstractParticipant,
135
+ others: list[AbstractParticipant],
136
+ channel: str,
137
+ ) -> bool:
138
+ """Whether ``participant`` may start a conversation with ``others``.
139
+ Agents, with anyone; everybody else, only with the support team
140
+ (``others`` empty)."""
141
+ return participant.kind == "agent" or not others
142
+
143
+ def start(
144
+ self,
145
+ request: HttpRequest,
146
+ participant: AbstractParticipant,
147
+ others: list[AbstractParticipant],
148
+ channel: str,
149
+ ) -> AbstractConversation:
150
+ """A new conversation (``POST /createRoom``). Without ``others`` (the
151
+ "contact us" widget) it goes to ``support_team()`` and waits,
152
+ ``pending``, for one of them to answer."""
153
+ team = [] if others else self.support_team(channel)
154
+ conversation = services.create_conversation(
155
+ [participant, *others, *team],
156
+ channel=channel,
157
+ # The team's inbox names it after whoever wrote, not the team.
158
+ title="" if others else participant.name,
159
+ state="open" if others else "pending",
160
+ capabilities=self.new_capabilities(channel),
161
+ creator=participant,
162
+ )
163
+ self.run_hook(self.on_start, request, conversation, participant)
164
+ return conversation
165
+
166
+ def support_team(self, channel: str) -> list[AbstractParticipant]:
167
+ """Who receives conversations started without anyone: every active
168
+ staff user. Override it to route them (a queue, a bot first…)."""
169
+ users = get_user_model().objects.filter(is_staff=True, is_active=True)
170
+ return [self.participant_for_user(user) for user in users]
171
+
172
+ def new_capabilities(self, channel: str) -> dict[str, Any]:
173
+ """The capabilities of the conversations it starts (section 6.1)."""
174
+ return {
175
+ "typing": True,
176
+ "receipts": True,
177
+ "history": True,
178
+ "replies": True,
179
+ "attachments": {
180
+ "types": ["image", "file"],
181
+ "maxSize": self.max_upload_size,
182
+ },
183
+ "composer": {"mode": "free", "maxLength": 4096},
184
+ }
185
+
186
+ # What happens ----------------------------------------------------------
187
+
188
+ def run_hook(self, hook: Callable[..., Any], *args: Any) -> None:
189
+ """Run a hook that reacts to a command, as ``hooks`` says. Override
190
+ it to send them to a task queue (Celery, ``django.tasks``…)."""
191
+ if self.hooks == "thread":
192
+ transaction.on_commit(
193
+ lambda: threading.Thread(
194
+ target=self.run_hook_now, args=(hook, *args), daemon=True
195
+ ).start()
196
+ )
197
+ else:
198
+ hook(*args)
199
+
200
+ def run_hook_now(self, hook: Callable[..., Any], *args: Any) -> None:
201
+ """Run a hook away from its request: its failure is logged, and its
202
+ thread's database connection closed."""
203
+ try:
204
+ hook(*args)
205
+ except Exception:
206
+ logger.exception("django-chat-ui: %s failed", hook.__name__)
207
+ finally:
208
+ connections.close_all()
209
+
210
+ def before_send(
211
+ self,
212
+ request: HttpRequest,
213
+ conversation: AbstractConversation,
214
+ participant: AbstractParticipant,
215
+ event_type: str,
216
+ content: dict[str, Any],
217
+ ) -> None:
218
+ """Just before a client's event is added (checked against what the
219
+ conversation allows): what must come first in the thread ("Ana
220
+ joined"), or a last refusal (raise ``MatrixError``). Always inline."""
221
+
222
+ def on_start(
223
+ self,
224
+ request: HttpRequest,
225
+ conversation: AbstractConversation,
226
+ participant: AbstractParticipant,
227
+ ) -> None:
228
+ """After a conversation is started: a greeting, a bot…"""
229
+
230
+ def on_message(self, request: HttpRequest, message: RoomEvent) -> None:
231
+ """After someone sent a message (not an edit, nor a button's
232
+ answer): a bot's answer, sending it on to WhatsApp… It runs as
233
+ ``hooks`` says."""
234
+
235
+ def on_action(
236
+ self,
237
+ request: HttpRequest,
238
+ participant: AbstractParticipant,
239
+ message: RoomEvent,
240
+ button: dict[str, Any],
241
+ ) -> None:
242
+ """After someone used a button of ``message``: their answer (the
243
+ button's label, as WhatsApp writes it) is in the thread already."""
244
+
245
+ def on_event(self, request: HttpRequest, event: RoomEvent) -> None:
246
+ """After a client sent an event of ``event_types``."""
247
+
248
+ def validate_content(
249
+ self,
250
+ conversation: AbstractConversation,
251
+ participant: AbstractParticipant,
252
+ event_type: str,
253
+ content: dict[str, Any],
254
+ ) -> None:
255
+ """Raise ``MatrixError`` for an event ``participant`` can't send.
256
+ What the conversation allows is checked already; messages of
257
+ extensions' types (namespaced ``msgtype``, with a ``body``) and
258
+ events of ``event_types`` get here, allowed."""
259
+
260
+
261
+ @cache
262
+ def _backend_class(path: str) -> type[ChatBackend]:
263
+ return import_string(path)
264
+
265
+
266
+ def get_backend() -> ChatBackend:
267
+ config = getattr(settings, "CHAT_UI", {})
268
+ path = config.get("BACKEND", "django_chat_ui.backend.ChatBackend")
269
+ return _backend_class(path)()