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.
- django_db_chat_widget/__init__.py +1 -0
- django_db_chat_widget/apps.py +7 -0
- django_db_chat_widget/conf.py +106 -0
- django_db_chat_widget/static/django_db_chat_widget/widget.css +253 -0
- django_db_chat_widget/static/django_db_chat_widget/widget.js +233 -0
- django_db_chat_widget/templatetags/__init__.py +0 -0
- django_db_chat_widget/templatetags/db_chat_widget.py +64 -0
- django_db_chat_widget/urls.py +10 -0
- django_db_chat_widget/views.py +35 -0
- django_db_chat_widget-0.1.0.dist-info/METADATA +156 -0
- django_db_chat_widget-0.1.0.dist-info/RECORD +13 -0
- django_db_chat_widget-0.1.0.dist-info/WHEEL +4 -0
- django_db_chat_widget-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -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,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,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.
|