django-db-chat-widget 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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,7 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DjangoDbChatWidgetConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "django_db_chat_widget"
7
+ verbose_name = "DB Chat Widget"
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from urllib.parse import quote_plus
5
+
6
+ from db_chat_widget import ChatEngine
7
+ from db_chat_widget import Settings as ChatSettings
8
+ from django.conf import settings as django_settings
9
+ from django.core.exceptions import ImproperlyConfigured
10
+
11
+ DEFAULTS: dict[str, Any] = {
12
+ # Which entry of Django's DATABASES to introspect/query, unless DB_URL is set.
13
+ "DB_ALIAS": "default",
14
+ # Explicit SQLAlchemy URL; overrides DB_ALIAS-based derivation when set.
15
+ "DB_URL": None,
16
+ "LLM_PROVIDER": "groq",
17
+ "LLM_MODEL": None,
18
+ "LLM_API_KEY": None,
19
+ "LLM_BASE_URL": None,
20
+ "READ_ONLY": True,
21
+ "MAX_ROWS": 200,
22
+ "ALLOWED_TABLES": None,
23
+ "TITLE": "Ask your database",
24
+ }
25
+
26
+ _ENGINE_TO_DIALECT = {
27
+ "django.db.backends.postgresql": "postgresql+psycopg2",
28
+ "django.db.backends.postgresql_psycopg2": "postgresql+psycopg2",
29
+ "django.contrib.gis.db.backends.postgis": "postgresql+psycopg2",
30
+ "django.db.backends.mysql": "mysql+pymysql",
31
+ "django.db.backends.sqlite3": "sqlite",
32
+ }
33
+
34
+ _engine_cache: ChatEngine | None = None
35
+
36
+
37
+ def get_config() -> dict[str, Any]:
38
+ user_config = getattr(django_settings, "DB_CHAT_WIDGET", {})
39
+ return {**DEFAULTS, **user_config}
40
+
41
+
42
+ def build_db_url_from_alias(alias: str) -> str:
43
+ """Translate a Django DATABASES[alias] entry into a SQLAlchemy connection URL.
44
+
45
+ Lets django-db-chat-widget reuse the host project's existing database
46
+ connection instead of duplicating credentials in a second place.
47
+ """
48
+ try:
49
+ db = django_settings.DATABASES[alias]
50
+ except KeyError as exc:
51
+ raise ImproperlyConfigured(
52
+ f"DB_CHAT_WIDGET['DB_ALIAS'] refers to unknown DATABASES alias '{alias}'."
53
+ ) from exc
54
+
55
+ engine = db.get("ENGINE", "")
56
+ dialect = _ENGINE_TO_DIALECT.get(engine)
57
+ if dialect is None:
58
+ raise ImproperlyConfigured(
59
+ f"db-chat-widget doesn't support the Django database engine '{engine}'. "
60
+ "Set DB_CHAT_WIDGET['DB_URL'] to an explicit SQLAlchemy URL instead."
61
+ )
62
+
63
+ if dialect == "sqlite":
64
+ return f"sqlite:///{db['NAME']}"
65
+
66
+ user = quote_plus(str(db.get("USER") or ""))
67
+ password = quote_plus(str(db.get("PASSWORD") or ""))
68
+ host = db.get("HOST") or "localhost"
69
+ port = db.get("PORT") or ""
70
+ name = db["NAME"]
71
+
72
+ auth = f"{user}:{password}@" if user else ""
73
+ port_part = f":{port}" if port else ""
74
+ return f"{dialect}://{auth}{host}{port_part}/{name}"
75
+
76
+
77
+ def get_chat_engine() -> ChatEngine:
78
+ """Return a process-wide cached ChatEngine built from Django settings."""
79
+ global _engine_cache
80
+ if _engine_cache is not None:
81
+ return _engine_cache
82
+
83
+ config = get_config()
84
+ db_url = config["DB_URL"] or build_db_url_from_alias(config["DB_ALIAS"])
85
+
86
+ settings = ChatSettings(
87
+ db_url=db_url,
88
+ llm_provider=config["LLM_PROVIDER"],
89
+ llm_model=config["LLM_MODEL"],
90
+ llm_api_key=config["LLM_API_KEY"],
91
+ llm_base_url=config["LLM_BASE_URL"],
92
+ read_only=config["READ_ONLY"],
93
+ max_rows=config["MAX_ROWS"],
94
+ allowed_tables=config["ALLOWED_TABLES"],
95
+ )
96
+ _engine_cache = ChatEngine(settings)
97
+ return _engine_cache
98
+
99
+
100
+ def reset_chat_engine_cache() -> None:
101
+ """Drop the cached ChatEngine, forcing it to be rebuilt on next use.
102
+
103
+ Mainly useful in tests, or after changing DB_CHAT_WIDGET settings at runtime.
104
+ """
105
+ global _engine_cache
106
+ _engine_cache = None
@@ -0,0 +1,253 @@
1
+ .dcw-widget {
2
+ --dcw-bg: #ffffff;
3
+ --dcw-fg: #1a1a1a;
4
+ --dcw-muted: #6b7280;
5
+ --dcw-border: #e5e7eb;
6
+ --dcw-accent: #2563eb;
7
+ --dcw-accent-fg: #ffffff;
8
+ --dcw-bubble-bot: #f3f4f6;
9
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
10
+ display: flex;
11
+ flex-direction: column;
12
+ width: 100%;
13
+ max-width: 480px;
14
+ height: 560px;
15
+ border: 1px solid var(--dcw-border);
16
+ border-radius: 12px;
17
+ background: var(--dcw-bg);
18
+ color: var(--dcw-fg);
19
+ overflow: hidden;
20
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
21
+ }
22
+
23
+ @media (prefers-color-scheme: dark) {
24
+ .dcw-widget {
25
+ --dcw-bg: #17181c;
26
+ --dcw-fg: #e8e9ec;
27
+ --dcw-muted: #9ca3af;
28
+ --dcw-border: #2b2d33;
29
+ --dcw-bubble-bot: #24262c;
30
+ }
31
+ }
32
+
33
+ .dcw-header {
34
+ display: flex;
35
+ align-items: center;
36
+ justify-content: space-between;
37
+ gap: 8px;
38
+ padding: 12px 16px;
39
+ border-bottom: 1px solid var(--dcw-border);
40
+ font-weight: 600;
41
+ font-size: 14px;
42
+ }
43
+
44
+ .dcw-header-title {
45
+ overflow: hidden;
46
+ text-overflow: ellipsis;
47
+ white-space: nowrap;
48
+ }
49
+
50
+ .dcw-panel-close {
51
+ display: none;
52
+ align-items: center;
53
+ justify-content: center;
54
+ flex: none;
55
+ width: 28px;
56
+ height: 28px;
57
+ border: none;
58
+ border-radius: 6px;
59
+ background: transparent;
60
+ color: var(--dcw-fg);
61
+ cursor: pointer;
62
+ }
63
+
64
+ .dcw-panel-close:hover {
65
+ background: var(--dcw-bubble-bot);
66
+ }
67
+
68
+ .dcw-messages {
69
+ flex: 1;
70
+ overflow-y: auto;
71
+ padding: 12px;
72
+ display: flex;
73
+ flex-direction: column;
74
+ gap: 10px;
75
+ }
76
+
77
+ .dcw-bubble {
78
+ max-width: 88%;
79
+ padding: 8px 12px;
80
+ border-radius: 10px;
81
+ font-size: 14px;
82
+ line-height: 1.4;
83
+ white-space: pre-wrap;
84
+ word-break: break-word;
85
+ }
86
+
87
+ .dcw-bubble.dcw-user {
88
+ align-self: flex-end;
89
+ background: var(--dcw-accent);
90
+ color: var(--dcw-accent-fg);
91
+ }
92
+
93
+ .dcw-bubble.dcw-bot {
94
+ align-self: flex-start;
95
+ background: var(--dcw-bubble-bot);
96
+ }
97
+
98
+ .dcw-bubble.dcw-error {
99
+ align-self: flex-start;
100
+ background: #fee2e2;
101
+ color: #991b1b;
102
+ }
103
+
104
+ .dcw-sql {
105
+ margin-top: 6px;
106
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
107
+ font-size: 12px;
108
+ color: var(--dcw-muted);
109
+ background: rgba(0, 0, 0, 0.04);
110
+ padding: 6px 8px;
111
+ border-radius: 6px;
112
+ overflow-x: auto;
113
+ }
114
+
115
+ .dcw-table-wrap {
116
+ margin-top: 8px;
117
+ overflow-x: auto;
118
+ max-height: 220px;
119
+ border: 1px solid var(--dcw-border);
120
+ border-radius: 6px;
121
+ }
122
+
123
+ .dcw-table {
124
+ border-collapse: collapse;
125
+ width: 100%;
126
+ font-size: 12px;
127
+ }
128
+
129
+ .dcw-table th, .dcw-table td {
130
+ padding: 6px 8px;
131
+ border-bottom: 1px solid var(--dcw-border);
132
+ text-align: left;
133
+ white-space: nowrap;
134
+ }
135
+
136
+ .dcw-table th {
137
+ position: sticky;
138
+ top: 0;
139
+ background: var(--dcw-bubble-bot);
140
+ }
141
+
142
+ .dcw-form {
143
+ display: flex;
144
+ gap: 8px;
145
+ padding: 12px;
146
+ border-top: 1px solid var(--dcw-border);
147
+ }
148
+
149
+ .dcw-input {
150
+ flex: 1;
151
+ padding: 8px 10px;
152
+ border: 1px solid var(--dcw-border);
153
+ border-radius: 8px;
154
+ background: var(--dcw-bg);
155
+ color: var(--dcw-fg);
156
+ font-size: 14px;
157
+ }
158
+
159
+ .dcw-send {
160
+ padding: 8px 14px;
161
+ border: none;
162
+ border-radius: 8px;
163
+ background: var(--dcw-accent);
164
+ color: var(--dcw-accent-fg);
165
+ font-size: 14px;
166
+ cursor: pointer;
167
+ }
168
+
169
+ .dcw-send:disabled {
170
+ opacity: 0.6;
171
+ cursor: default;
172
+ }
173
+
174
+ .dcw-typing {
175
+ align-self: flex-start;
176
+ color: var(--dcw-muted);
177
+ font-size: 13px;
178
+ font-style: italic;
179
+ }
180
+
181
+ /* Floating launcher (popup) mode */
182
+
183
+ .dcw-launcher-btn {
184
+ position: fixed;
185
+ right: 20px;
186
+ bottom: 20px;
187
+ width: 56px;
188
+ height: 56px;
189
+ padding: 0;
190
+ border: none;
191
+ border-radius: 50%;
192
+ background: var(--dcw-accent);
193
+ color: var(--dcw-accent-fg);
194
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
195
+ cursor: pointer;
196
+ display: flex;
197
+ align-items: center;
198
+ justify-content: center;
199
+ z-index: 2147483000;
200
+ transition: transform 0.15s ease;
201
+ }
202
+
203
+ .dcw-launcher-btn:hover {
204
+ transform: scale(1.06);
205
+ }
206
+
207
+ .dcw-launcher-btn.dcw-pos-left {
208
+ right: auto;
209
+ left: 20px;
210
+ }
211
+
212
+ .dcw-launcher-panel {
213
+ position: fixed;
214
+ right: 20px;
215
+ bottom: 88px;
216
+ z-index: 2147483000;
217
+ transform-origin: bottom right;
218
+ transition: opacity 0.15s ease, transform 0.15s ease;
219
+ opacity: 1;
220
+ transform: scale(1) translateY(0);
221
+ }
222
+
223
+ .dcw-launcher-panel.dcw-pos-left {
224
+ right: auto;
225
+ left: 20px;
226
+ transform-origin: bottom left;
227
+ }
228
+
229
+ .dcw-launcher-panel.dcw-hidden {
230
+ opacity: 0;
231
+ transform: scale(0.95) translateY(8px);
232
+ pointer-events: none;
233
+ visibility: hidden;
234
+ }
235
+
236
+ .dcw-launcher-panel .dcw-panel-close {
237
+ display: flex;
238
+ }
239
+
240
+ @media (max-width: 520px) {
241
+ .dcw-launcher-panel {
242
+ right: 12px;
243
+ left: 12px;
244
+ bottom: 88px;
245
+ width: auto;
246
+ max-width: none;
247
+ height: min(70vh, 560px);
248
+ }
249
+
250
+ .dcw-launcher-panel.dcw-pos-left {
251
+ left: 12px;
252
+ }
253
+ }
@@ -0,0 +1,233 @@
1
+ (function (global) {
2
+ "use strict";
3
+
4
+ // Captured synchronously while this <script> tag is executing — must not be
5
+ // read lazily inside an async callback, since document.currentScript is only
6
+ // valid during the script's own top-level execution.
7
+ const scriptEl = document.currentScript;
8
+
9
+ const CHAT_ICON =
10
+ '<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" ' +
11
+ 'stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
12
+ '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>' +
13
+ "</svg>";
14
+
15
+ const CLOSE_ICON =
16
+ '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" ' +
17
+ 'stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
18
+ '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>' +
19
+ "</svg>";
20
+
21
+ function el(tag, attrs, children) {
22
+ const node = document.createElement(tag);
23
+ if (attrs) {
24
+ Object.keys(attrs).forEach((key) => {
25
+ const value = attrs[key];
26
+ if (value === undefined || value === null) return;
27
+ if (key === "class") node.className = value;
28
+ else node.setAttribute(key, value);
29
+ });
30
+ }
31
+ (children || []).forEach((child) => {
32
+ if (child == null) return;
33
+ node.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
34
+ });
35
+ return node;
36
+ }
37
+
38
+ function iconButton(svgMarkup, attrs) {
39
+ const btn = el("button", attrs);
40
+ btn.innerHTML = svgMarkup; // static, trusted markup defined above — not user input
41
+ return btn;
42
+ }
43
+
44
+ function getCookie(name) {
45
+ const match = document.cookie.match("(?:^|; )" + name + "=([^;]*)");
46
+ return match ? decodeURIComponent(match[1]) : null;
47
+ }
48
+
49
+ function renderTable(columns, rows) {
50
+ const thead = el("thead", null, [
51
+ el("tr", null, columns.map((c) => el("th", null, [c]))),
52
+ ]);
53
+ const tbody = el(
54
+ "tbody",
55
+ null,
56
+ rows.map((row) =>
57
+ el(
58
+ "tr",
59
+ null,
60
+ columns.map((c) => el("td", null, [row[c] === null || row[c] === undefined ? "" : String(row[c])]))
61
+ )
62
+ )
63
+ );
64
+ return el("div", { class: "dcw-table-wrap" }, [el("table", { class: "dcw-table" }, [thead, tbody])]);
65
+ }
66
+
67
+ function buildChatPanel(opts, headerExtras) {
68
+ const messages = el("div", { class: "dcw-messages" });
69
+ const input = el("input", { class: "dcw-input", type: "text", placeholder: opts.placeholder });
70
+ const sendBtn = el("button", { class: "dcw-send", type: "submit" }, ["Send"]);
71
+ const form = el("form", { class: "dcw-form" }, [input, sendBtn]);
72
+
73
+ const header = el("div", { class: "dcw-header" }, [
74
+ el("span", { class: "dcw-header-title" }, [opts.title]),
75
+ ...(headerExtras || []),
76
+ ]);
77
+
78
+ const widget = el("div", { class: "dcw-widget" }, [header, messages, form]);
79
+
80
+ function addBubble(text, kind) {
81
+ const bubble = el("div", { class: "dcw-bubble dcw-" + kind }, [text]);
82
+ messages.appendChild(bubble);
83
+ messages.scrollTop = messages.scrollHeight;
84
+ return bubble;
85
+ }
86
+
87
+ async function ask(question) {
88
+ addBubble(question, "user");
89
+ const typing = el("div", { class: "dcw-typing" }, ["Thinking..."]);
90
+ messages.appendChild(typing);
91
+ messages.scrollTop = messages.scrollHeight;
92
+
93
+ input.disabled = true;
94
+ sendBtn.disabled = true;
95
+
96
+ try {
97
+ const csrfToken = getCookie(opts.csrfCookieName);
98
+ const headers = { "Content-Type": "application/json" };
99
+ if (csrfToken) headers["X-CSRFToken"] = csrfToken;
100
+
101
+ const response = await fetch(opts.apiUrl, {
102
+ method: "POST",
103
+ headers,
104
+ credentials: "same-origin",
105
+ body: JSON.stringify({ question }),
106
+ });
107
+ const data = await response.json();
108
+ typing.remove();
109
+
110
+ if (!response.ok) {
111
+ addBubble(data.detail || "Request failed.", "error");
112
+ return;
113
+ }
114
+
115
+ if (data.error) {
116
+ addBubble(data.answer || "Something went wrong.", "error");
117
+ return;
118
+ }
119
+
120
+ const botBubble = addBubble(data.answer || "", "bot");
121
+ if (opts.showSql && data.sql) {
122
+ botBubble.appendChild(el("div", { class: "dcw-sql" }, [data.sql]));
123
+ }
124
+ if (data.rows && data.rows.length) {
125
+ botBubble.appendChild(renderTable(data.columns, data.rows));
126
+ }
127
+ messages.scrollTop = messages.scrollHeight;
128
+ } catch (err) {
129
+ typing.remove();
130
+ addBubble("Network error: could not reach the chat API.", "error");
131
+ } finally {
132
+ input.disabled = false;
133
+ sendBtn.disabled = false;
134
+ input.focus();
135
+ }
136
+ }
137
+
138
+ form.addEventListener("submit", (event) => {
139
+ event.preventDefault();
140
+ const question = input.value.trim();
141
+ if (!question) return;
142
+ input.value = "";
143
+ ask(question);
144
+ });
145
+
146
+ return { ask, element: widget, focusInput: () => input.focus() };
147
+ }
148
+
149
+ function createInlineWidget(opts) {
150
+ const target = typeof opts.target === "string" ? document.querySelector(opts.target) : opts.target;
151
+ if (!target) {
152
+ console.error("db-chat-widget: target element not found:", opts.target);
153
+ return null;
154
+ }
155
+ const panel = buildChatPanel(opts);
156
+ target.innerHTML = "";
157
+ target.appendChild(panel.element);
158
+ return panel;
159
+ }
160
+
161
+ function createLauncherWidget(opts) {
162
+ let open = false;
163
+
164
+ const closeBtn = iconButton(CLOSE_ICON, { class: "dcw-panel-close", type: "button", "aria-label": "Close chat" });
165
+ const panel = buildChatPanel(opts, [closeBtn]);
166
+ panel.element.classList.add("dcw-launcher-panel", "dcw-hidden");
167
+ if (opts.position === "bottom-left") panel.element.classList.add("dcw-pos-left");
168
+
169
+ const launcherBtn = iconButton(CHAT_ICON, { class: "dcw-launcher-btn", type: "button", "aria-label": "Open chat" });
170
+ if (opts.position === "bottom-left") launcherBtn.classList.add("dcw-pos-left");
171
+
172
+ function setOpen(next) {
173
+ open = next;
174
+ panel.element.classList.toggle("dcw-hidden", !open);
175
+ launcherBtn.innerHTML = open ? CLOSE_ICON : CHAT_ICON;
176
+ launcherBtn.setAttribute("aria-label", open ? "Close chat" : "Open chat");
177
+ if (open) panel.focusInput();
178
+ }
179
+
180
+ launcherBtn.addEventListener("click", () => setOpen(!open));
181
+ closeBtn.addEventListener("click", () => setOpen(false));
182
+
183
+ document.body.appendChild(panel.element);
184
+ document.body.appendChild(launcherBtn);
185
+
186
+ return { ask: panel.ask, element: panel.element, open: () => setOpen(true), close: () => setOpen(false) };
187
+ }
188
+
189
+ function createWidget(options) {
190
+ const opts = Object.assign(
191
+ {
192
+ apiUrl: "/api/chat",
193
+ title: "Ask your database",
194
+ placeholder: "Ask a question about your data...",
195
+ showSql: true,
196
+ mode: options && options.target ? "inline" : "bubble",
197
+ position: "bottom-right",
198
+ csrfCookieName: "csrftoken",
199
+ },
200
+ options
201
+ );
202
+
203
+ return opts.mode === "bubble" ? createLauncherWidget(opts) : createInlineWidget(opts);
204
+ }
205
+
206
+ const DBChatWidget = { init: createWidget };
207
+ global.DBChatWidget = DBChatWidget;
208
+
209
+ function autoInit() {
210
+ if (!scriptEl || !scriptEl.dataset) return;
211
+ const ds = scriptEl.dataset;
212
+ if (!ds.target && !ds.mode) return; // nothing declared on the tag, skip auto-init
213
+
214
+ // Only include keys that were actually set on the tag — Object.assign (used by
215
+ // createWidget to merge in defaults) treats an explicit `undefined` value as
216
+ // present, so passing it here would wipe out the corresponding default.
217
+ const options = { apiUrl: ds.apiUrl || "/api/chat", showSql: ds.showSql !== "false" };
218
+ if (ds.target) options.target = ds.target;
219
+ if (ds.title) options.title = ds.title;
220
+ if (ds.placeholder) options.placeholder = ds.placeholder;
221
+ if (ds.mode) options.mode = ds.mode;
222
+ if (ds.position) options.position = ds.position;
223
+ if (ds.csrfCookieName) options.csrfCookieName = ds.csrfCookieName;
224
+
225
+ createWidget(options);
226
+ }
227
+
228
+ if (document.readyState === "loading") {
229
+ document.addEventListener("DOMContentLoaded", autoInit);
230
+ } else {
231
+ autoInit();
232
+ }
233
+ })(window);
File without changes
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from django import template
4
+ from django.middleware.csrf import get_token
5
+ from django.templatetags.static import static
6
+ from django.urls import reverse
7
+ from django.utils.html import format_html, format_html_join
8
+ from django.utils.safestring import SafeString
9
+
10
+ from ..conf import get_config
11
+
12
+ register = template.Library()
13
+
14
+
15
+ @register.simple_tag(takes_context=True)
16
+ def db_chat_widget(
17
+ context: template.Context,
18
+ mode: str | None = None,
19
+ title: str | None = None,
20
+ position: str | None = None,
21
+ placeholder: str | None = None,
22
+ target: str | None = None,
23
+ ) -> SafeString:
24
+ """Render the <link>/<script> tags that embed the db-chat-widget.
25
+
26
+ Usage in a template:
27
+
28
+ {% load db_chat_widget %}
29
+ {% db_chat_widget %} {# floating popup, bottom-right #}
30
+ {% db_chat_widget position="bottom-left" %}
31
+ {% db_chat_widget target="#my-chat-div" %} {# inline mode instead of popup #}
32
+
33
+ Requires 'django.template.context_processors.request' in TEMPLATES so the
34
+ tag can access the current request to set the CSRF cookie the widget needs.
35
+ """
36
+ request = context.get("request")
37
+ if request is not None:
38
+ # Force the csrftoken cookie to be set on this response, even if no
39
+ # {% csrf_token %} is rendered elsewhere on the page.
40
+ get_token(request)
41
+
42
+ config = get_config()
43
+ api_url = reverse("db_chat_widget:chat")
44
+
45
+ data_attrs = {
46
+ "data-mode": mode or ("inline" if target else "bubble"),
47
+ "data-api-url": api_url,
48
+ "data-title": title or config["TITLE"],
49
+ }
50
+ if position:
51
+ data_attrs["data-position"] = position
52
+ if placeholder:
53
+ data_attrs["data-placeholder"] = placeholder
54
+ if target:
55
+ data_attrs["data-target"] = target
56
+
57
+ attrs_html = format_html_join(" ", '{}="{}"', data_attrs.items())
58
+
59
+ return format_html(
60
+ '<link rel="stylesheet" href="{}">\n<script src="{}" {}></script>',
61
+ static("django_db_chat_widget/widget.css"),
62
+ static("django_db_chat_widget/widget.js"),
63
+ attrs_html,
64
+ )
@@ -0,0 +1,10 @@
1
+ from django.urls import path
2
+
3
+ from . import views
4
+
5
+ app_name = "db_chat_widget"
6
+
7
+ urlpatterns = [
8
+ path("chat/", views.chat, name="chat"),
9
+ path("health/", views.health, name="health"),
10
+ ]
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from django.http import HttpRequest, HttpResponseBadRequest, JsonResponse
6
+ from django.views.decorators.http import require_GET, require_POST
7
+
8
+ from .conf import get_chat_engine
9
+
10
+
11
+ @require_GET
12
+ def health(request: HttpRequest) -> JsonResponse:
13
+ return JsonResponse({"status": "ok"})
14
+
15
+
16
+ @require_POST
17
+ def chat(request: HttpRequest) -> JsonResponse:
18
+ """Answer a natural-language question about the database.
19
+
20
+ Relies on Django's normal CSRF protection (the request must carry a valid
21
+ X-CSRFToken header/cookie pair) — see the `db_chat_widget` template tag,
22
+ which ensures the CSRF cookie is set on the page that embeds the widget.
23
+ """
24
+ try:
25
+ payload = json.loads(request.body or b"{}")
26
+ except json.JSONDecodeError:
27
+ return HttpResponseBadRequest("Invalid JSON body")
28
+
29
+ question = str(payload.get("question") or "").strip()
30
+ if not question:
31
+ return HttpResponseBadRequest("question must not be empty")
32
+
33
+ engine = get_chat_engine()
34
+ result = engine.ask(question)
35
+ return JsonResponse(result.to_dict())
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-db-chat-widget
3
+ Version: 0.1.0
4
+ Summary: Django integration for db-chat-widget: a natural-language database chatbot widget for Django templates.
5
+ Project-URL: Homepage, https://github.com/krak225/django-db-chat-widget
6
+ Project-URL: Issues, https://github.com/krak225/django-db-chat-widget/issues
7
+ Author: Armand Kouassi
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: chatbot,database,django,llm,nl2sql,sql,widget
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Framework :: Django
13
+ Classifier: Framework :: Django :: 4.2
14
+ Classifier: Framework :: Django :: 5.0
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Database
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Requires-Python: >=3.9
25
+ Requires-Dist: db-chat-widget>=0.1.0
26
+ Requires-Dist: django>=4.2
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest-django>=4.8; extra == 'dev'
29
+ Requires-Dist: pytest>=8.0; extra == 'dev'
30
+ Requires-Dist: ruff>=0.4; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # django-db-chat-widget
34
+
35
+ Django integration for [db-chat-widget](https://github.com/krak225/db-chat-widget):
36
+ drop a natural-language database chatbot into any Django template as a
37
+ floating popup (or inline panel), backed by your existing Django database
38
+ connection.
39
+
40
+ - **Reuses your existing Django database connection** — no separate
41
+ credentials to configure; the SQLAlchemy URL is derived automatically from
42
+ `settings.DATABASES` (PostgreSQL, MySQL, SQLite).
43
+ - **Pluggable LLM backend**: Anthropic Claude, OpenAI, Groq, or a local
44
+ Ollama model (via [db-chat-widget](https://github.com/krak225/db-chat-widget)).
45
+ - **Read-only by default**: generated SQL is parsed and only `SELECT`
46
+ statements are allowed unless you explicitly opt into write access.
47
+ - **One template tag**: `{% db_chat_widget %}` renders a floating chat
48
+ bubble (or an inline panel) — no extra views or URLs to wire up yourself.
49
+ - **Proper CSRF handling**: works with Django's CSRF protection out of the
50
+ box, no `csrf_exempt` required.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install django-db-chat-widget
56
+ ```
57
+
58
+ This pulls in [`db-chat-widget`](https://github.com/krak225/db-chat-widget)
59
+ (the framework-agnostic core: LLM providers, SQL safety checks, query
60
+ execution) as a dependency.
61
+
62
+ Add the app and its URLs:
63
+
64
+ ```python
65
+ # settings.py
66
+ INSTALLED_APPS = [
67
+ ...,
68
+ "django.contrib.staticfiles",
69
+ "django_db_chat_widget",
70
+ ]
71
+
72
+ TEMPLATES = [
73
+ {
74
+ ...,
75
+ "OPTIONS": {
76
+ "context_processors": [
77
+ ...,
78
+ "django.template.context_processors.request", # required
79
+ ],
80
+ },
81
+ },
82
+ ]
83
+
84
+ DB_CHAT_WIDGET = {
85
+ "DB_ALIAS": "default", # which DATABASES entry to query
86
+ "LLM_PROVIDER": "groq", # "anthropic" | "openai" | "groq" | "ollama"
87
+ "LLM_API_KEY": "gsk_...", # or read from os.environ
88
+ "READ_ONLY": True, # default: only SELECT statements allowed
89
+ "MAX_ROWS": 200,
90
+ "ALLOWED_TABLES": None, # e.g. ["orders", "customers"] to scope access
91
+ "TITLE": "Ask your database",
92
+ }
93
+ ```
94
+
95
+ ```python
96
+ # urls.py
97
+ from django.urls import include, path
98
+
99
+ urlpatterns = [
100
+ ...,
101
+ path("db-chat/", include("django_db_chat_widget.urls")),
102
+ ]
103
+ ```
104
+
105
+ Then drop the tag into any template:
106
+
107
+ ```html
108
+ {% load db_chat_widget %}
109
+ {% db_chat_widget %}
110
+ ```
111
+
112
+ That's it — a floating chat bubble now appears in the bottom-right corner of
113
+ that page. See [`example_project/`](example_project) for a complete, runnable
114
+ Django project demonstrating this end to end.
115
+
116
+ ## Template tag options
117
+
118
+ ```html
119
+ {% db_chat_widget %} {# popup, bottom-right #}
120
+ {% db_chat_widget position="bottom-left" %} {# popup, bottom-left #}
121
+ {% db_chat_widget target="#my-chat-div" %} {# inline instead of popup #}
122
+ {% db_chat_widget title="Ask HR" placeholder="..." %}
123
+ ```
124
+
125
+ ## Multiple databases / explicit connection
126
+
127
+ If your Django project has several `DATABASES` entries, point
128
+ `DB_CHAT_WIDGET["DB_ALIAS"]` at the one to query. To bypass Django's
129
+ `DATABASES` entirely (e.g. a read replica not declared there), set
130
+ `DB_CHAT_WIDGET["DB_URL"]` to an explicit SQLAlchemy URL instead — it takes
131
+ precedence over `DB_ALIAS`.
132
+
133
+ ## Safety notes
134
+
135
+ - `READ_ONLY=True` (the default) is enforced by parsing the generated SQL
136
+ with `sqlglot`, not just by prompting the model — treat it as the actual
137
+ security boundary. See
138
+ [db-chat-widget's safety notes](https://github.com/krak225/db-chat-widget#safety-notes)
139
+ for details.
140
+ - Use `ALLOWED_TABLES` to scope the chatbot away from sensitive tables (e.g.
141
+ Django's own `auth_user` / session tables).
142
+ - The `/db-chat/chat/` endpoint is a normal Django view protected by Django's
143
+ CSRF middleware; the template tag ensures the CSRF cookie is set on any
144
+ page that renders it.
145
+
146
+ ## Development
147
+
148
+ ```bash
149
+ pip install -e ".[dev]"
150
+ pytest
151
+ ruff check .
152
+ ```
153
+
154
+ ## License
155
+
156
+ MIT
@@ -0,0 +1,13 @@
1
+ django_db_chat_widget/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ django_db_chat_widget/apps.py,sha256=jy9IElALPg0N62hfR--Rsm3EjTtbs86WrPRE4Yak_NU,207
3
+ django_db_chat_widget/conf.py,sha256=G8piI17bFj9J6eQU5d4Kh5Yh0h0IEtKyYQ1Ta5jWTSg,3477
4
+ django_db_chat_widget/urls.py,sha256=WJoXwVgMACFpWgsDdWG4hSy_qNGmyf4511nqRaK3sBE,192
5
+ django_db_chat_widget/views.py,sha256=1lyHYDnoAcgUt2jHQgjWrbV3zPE-VfCK-rKT4tTj6wQ,1110
6
+ django_db_chat_widget/static/django_db_chat_widget/widget.css,sha256=Hz3WJl5Vzke_951wMvkoxbnFFo4d9UeCYbRGahfvOg0,4597
7
+ django_db_chat_widget/static/django_db_chat_widget/widget.js,sha256=fz4Cftw2ivS7Q5-weNqZGcKytwcRk3FPxabSMBjhU-Y,8330
8
+ django_db_chat_widget/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ django_db_chat_widget/templatetags/db_chat_widget.py,sha256=m9IWFkxJWJzIaagkPFujokqlugHl9z1rGOqP0aWN1Zk,2117
10
+ django_db_chat_widget-0.1.0.dist-info/METADATA,sha256=RReSfUf7jAQWEeExiXSuRflZckeNsu-blAT1V8UpdMM,5236
11
+ django_db_chat_widget-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
12
+ django_db_chat_widget-0.1.0.dist-info/licenses/LICENSE,sha256=oAyChCkTJwAuvSVkMD9o5yvn7UpAfk8FrC3mmFE6Pbw,1071
13
+ django_db_chat_widget-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Armand Kouassi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.