llms-py 2.0.26__py3-none-any.whl → 2.0.28__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.
- llms/index.html +17 -1
- llms/llms.json +12 -2
- llms/main.py +125 -4
- llms/ui/Analytics.mjs +85 -77
- llms/ui/App.mjs +1 -1
- llms/ui/Brand.mjs +4 -4
- llms/ui/ChatPrompt.mjs +110 -9
- llms/ui/Main.mjs +38 -35
- llms/ui/ModelSelector.mjs +3 -4
- llms/ui/OAuthSignIn.mjs +4 -4
- llms/ui/ProviderStatus.mjs +12 -12
- llms/ui/Recents.mjs +13 -13
- llms/ui/SettingsDialog.mjs +65 -65
- llms/ui/Sidebar.mjs +17 -17
- llms/ui/SystemPromptEditor.mjs +5 -5
- llms/ui/SystemPromptSelector.mjs +4 -4
- llms/ui/Welcome.mjs +2 -2
- llms/ui/ai.mjs +1 -1
- llms/ui/app.css +343 -27
- llms/ui/markdown.mjs +8 -8
- llms/ui/tailwind.input.css +2 -0
- llms/ui/typography.css +54 -36
- {llms_py-2.0.26.dist-info → llms_py-2.0.28.dist-info}/METADATA +32 -9
- llms_py-2.0.28.dist-info/RECORD +48 -0
- llms_py-2.0.26.dist-info/RECORD +0 -48
- {llms_py-2.0.26.dist-info → llms_py-2.0.28.dist-info}/WHEEL +0 -0
- {llms_py-2.0.26.dist-info → llms_py-2.0.28.dist-info}/entry_points.txt +0 -0
- {llms_py-2.0.26.dist-info → llms_py-2.0.28.dist-info}/licenses/LICENSE +0 -0
- {llms_py-2.0.26.dist-info → llms_py-2.0.28.dist-info}/top_level.txt +0 -0
llms/index.html
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<html>
|
|
2
2
|
<head>
|
|
3
3
|
<title>llms.py</title>
|
|
4
|
-
<link rel="stylesheet" href="/ui/typography.css">
|
|
5
4
|
<link rel="stylesheet" href="/ui/app.css">
|
|
5
|
+
<link rel="stylesheet" href="/ui/typography.css">
|
|
6
6
|
<link rel="icon" type="image/svg" href="/ui/fav.svg">
|
|
7
7
|
<style>
|
|
8
8
|
[type='button'],button[type='submit']{cursor:pointer}
|
|
@@ -38,6 +38,22 @@
|
|
|
38
38
|
<body>
|
|
39
39
|
<div id="app"></div>
|
|
40
40
|
</body>
|
|
41
|
+
<script>
|
|
42
|
+
let colorScheme = location.search === "?dark"
|
|
43
|
+
? "dark"
|
|
44
|
+
: location.search === "?light"
|
|
45
|
+
? "light"
|
|
46
|
+
: localStorage.getItem('color-scheme')
|
|
47
|
+
let darkMode = colorScheme != null
|
|
48
|
+
? colorScheme === 'dark'
|
|
49
|
+
: window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
50
|
+
let html = document.documentElement
|
|
51
|
+
html.classList.toggle('dark', darkMode)
|
|
52
|
+
html.style.setProperty('color-scheme', darkMode ? 'dark' : null)
|
|
53
|
+
if (localStorage.getItem('color-scheme') === null) {
|
|
54
|
+
localStorage.setItem('color-scheme', darkMode ? 'dark' : 'light')
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
41
57
|
<script type="module">
|
|
42
58
|
import { createApp, defineAsyncComponent } from 'vue'
|
|
43
59
|
import { createWebHistory, createRouter } from "vue-router"
|
llms/llms.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"auth": {
|
|
3
|
-
"enabled":
|
|
3
|
+
"enabled": false,
|
|
4
4
|
"github": {
|
|
5
5
|
"client_id": "$GITHUB_CLIENT_ID",
|
|
6
6
|
"client_secret": "$GITHUB_CLIENT_SECRET",
|
|
7
|
-
"redirect_uri": "http://localhost:8000/auth/github/callback"
|
|
7
|
+
"redirect_uri": "http://localhost:8000/auth/github/callback",
|
|
8
|
+
"restrict_to": "$GITHUB_USERS"
|
|
8
9
|
}
|
|
9
10
|
},
|
|
10
11
|
"defaults": {
|
|
@@ -104,6 +105,15 @@
|
|
|
104
105
|
"stream": false
|
|
105
106
|
}
|
|
106
107
|
},
|
|
108
|
+
"limits": {
|
|
109
|
+
"client_max_size": 20971520
|
|
110
|
+
},
|
|
111
|
+
"convert": {
|
|
112
|
+
"image": {
|
|
113
|
+
"max_size": "1536x1024",
|
|
114
|
+
"max_length": 1572864
|
|
115
|
+
}
|
|
116
|
+
},
|
|
107
117
|
"providers": {
|
|
108
118
|
"openrouter_free": {
|
|
109
119
|
"enabled": true,
|
llms/main.py
CHANGED
|
@@ -15,6 +15,8 @@ import traceback
|
|
|
15
15
|
import sys
|
|
16
16
|
import site
|
|
17
17
|
import secrets
|
|
18
|
+
import re
|
|
19
|
+
from io import BytesIO
|
|
18
20
|
from urllib.parse import parse_qs, urlencode
|
|
19
21
|
|
|
20
22
|
import aiohttp
|
|
@@ -23,7 +25,13 @@ from aiohttp import web
|
|
|
23
25
|
from pathlib import Path
|
|
24
26
|
from importlib import resources # Py≥3.9 (pip install importlib_resources for 3.7/3.8)
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
try:
|
|
29
|
+
from PIL import Image
|
|
30
|
+
HAS_PIL = True
|
|
31
|
+
except ImportError:
|
|
32
|
+
HAS_PIL = False
|
|
33
|
+
|
|
34
|
+
VERSION = "2.0.28"
|
|
27
35
|
_ROOT = None
|
|
28
36
|
g_config_path = None
|
|
29
37
|
g_ui_path = None
|
|
@@ -200,6 +208,77 @@ def price_to_string(price: float | int | str | None) -> str | None:
|
|
|
200
208
|
except (ValueError, TypeError):
|
|
201
209
|
return None
|
|
202
210
|
|
|
211
|
+
def convert_image_if_needed(image_bytes, mimetype='image/png'):
|
|
212
|
+
"""
|
|
213
|
+
Convert and resize image to WebP if it exceeds configured limits.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
image_bytes: Raw image bytes
|
|
217
|
+
mimetype: Original image MIME type
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
tuple: (converted_bytes, new_mimetype) or (original_bytes, original_mimetype) if no conversion needed
|
|
221
|
+
"""
|
|
222
|
+
if not HAS_PIL:
|
|
223
|
+
return image_bytes, mimetype
|
|
224
|
+
|
|
225
|
+
# Get conversion config
|
|
226
|
+
convert_config = g_config.get('convert', {}).get('image', {}) if g_config else {}
|
|
227
|
+
if not convert_config:
|
|
228
|
+
return image_bytes, mimetype
|
|
229
|
+
|
|
230
|
+
max_size_str = convert_config.get('max_size', '1536x1024')
|
|
231
|
+
max_length = convert_config.get('max_length', 1.5*1024*1024) # 1.5MB
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
# Parse max_size (e.g., "1536x1024")
|
|
235
|
+
max_width, max_height = map(int, max_size_str.split('x'))
|
|
236
|
+
|
|
237
|
+
# Open image
|
|
238
|
+
with Image.open(BytesIO(image_bytes)) as img:
|
|
239
|
+
original_width, original_height = img.size
|
|
240
|
+
|
|
241
|
+
# Check if image exceeds limits
|
|
242
|
+
needs_resize = original_width > max_width or original_height > max_height
|
|
243
|
+
|
|
244
|
+
# Check if base64 length would exceed max_length (in KB)
|
|
245
|
+
# Base64 encoding increases size by ~33%, so check raw bytes * 1.33 / 1024
|
|
246
|
+
estimated_kb = (len(image_bytes) * 1.33) / 1024
|
|
247
|
+
needs_conversion = estimated_kb > max_length
|
|
248
|
+
|
|
249
|
+
if not needs_resize and not needs_conversion:
|
|
250
|
+
return image_bytes, mimetype
|
|
251
|
+
|
|
252
|
+
# Convert RGBA to RGB if necessary (WebP doesn't support transparency in RGB mode)
|
|
253
|
+
if img.mode in ('RGBA', 'LA', 'P'):
|
|
254
|
+
# Create a white background
|
|
255
|
+
background = Image.new('RGB', img.size, (255, 255, 255))
|
|
256
|
+
if img.mode == 'P':
|
|
257
|
+
img = img.convert('RGBA')
|
|
258
|
+
background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None)
|
|
259
|
+
img = background
|
|
260
|
+
elif img.mode != 'RGB':
|
|
261
|
+
img = img.convert('RGB')
|
|
262
|
+
|
|
263
|
+
# Resize if needed (preserve aspect ratio)
|
|
264
|
+
if needs_resize:
|
|
265
|
+
img.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
|
266
|
+
_log(f"Resized image from {original_width}x{original_height} to {img.size[0]}x{img.size[1]}")
|
|
267
|
+
|
|
268
|
+
# Convert to WebP
|
|
269
|
+
output = BytesIO()
|
|
270
|
+
img.save(output, format='WEBP', quality=85, method=6)
|
|
271
|
+
converted_bytes = output.getvalue()
|
|
272
|
+
|
|
273
|
+
_log(f"Converted image to WebP: {len(image_bytes)} bytes -> {len(converted_bytes)} bytes ({len(converted_bytes)*100//len(image_bytes)}%)")
|
|
274
|
+
|
|
275
|
+
return converted_bytes, 'image/webp'
|
|
276
|
+
|
|
277
|
+
except Exception as e:
|
|
278
|
+
_log(f"Error converting image: {e}")
|
|
279
|
+
# Return original if conversion fails
|
|
280
|
+
return image_bytes, mimetype
|
|
281
|
+
|
|
203
282
|
async def process_chat(chat):
|
|
204
283
|
if not chat:
|
|
205
284
|
raise Exception("No chat provided")
|
|
@@ -230,19 +309,31 @@ async def process_chat(chat):
|
|
|
230
309
|
mimetype = get_file_mime_type(get_filename(url))
|
|
231
310
|
if 'Content-Type' in response.headers:
|
|
232
311
|
mimetype = response.headers['Content-Type']
|
|
312
|
+
# convert/resize image if needed
|
|
313
|
+
content, mimetype = convert_image_if_needed(content, mimetype)
|
|
233
314
|
# convert to data uri
|
|
234
315
|
image_url['url'] = f"data:{mimetype};base64,{base64.b64encode(content).decode('utf-8')}"
|
|
235
316
|
elif is_file_path(url):
|
|
236
317
|
_log(f"Reading image: {url}")
|
|
237
318
|
with open(url, "rb") as f:
|
|
238
319
|
content = f.read()
|
|
239
|
-
ext = os.path.splitext(url)[1].lower().lstrip('.') if '.' in url else 'png'
|
|
240
320
|
# get mimetype from file extension
|
|
241
321
|
mimetype = get_file_mime_type(get_filename(url))
|
|
322
|
+
# convert/resize image if needed
|
|
323
|
+
content, mimetype = convert_image_if_needed(content, mimetype)
|
|
242
324
|
# convert to data uri
|
|
243
325
|
image_url['url'] = f"data:{mimetype};base64,{base64.b64encode(content).decode('utf-8')}"
|
|
244
326
|
elif url.startswith('data:'):
|
|
245
|
-
|
|
327
|
+
# Extract existing data URI and process it
|
|
328
|
+
if ';base64,' in url:
|
|
329
|
+
prefix = url.split(';base64,')[0]
|
|
330
|
+
mimetype = prefix.split(':')[1] if ':' in prefix else 'image/png'
|
|
331
|
+
base64_data = url.split(';base64,')[1]
|
|
332
|
+
content = base64.b64decode(base64_data)
|
|
333
|
+
# convert/resize image if needed
|
|
334
|
+
content, mimetype = convert_image_if_needed(content, mimetype)
|
|
335
|
+
# update data uri with potentially converted image
|
|
336
|
+
image_url['url'] = f"data:{mimetype};base64,{base64.b64encode(content).decode('utf-8')}"
|
|
246
337
|
else:
|
|
247
338
|
raise Exception(f"Invalid image: {url}")
|
|
248
339
|
elif item['type'] == 'input_audio' and 'input_audio' in item:
|
|
@@ -1480,7 +1571,9 @@ def main():
|
|
|
1480
1571
|
|
|
1481
1572
|
_log("Authentication enabled - GitHub OAuth configured")
|
|
1482
1573
|
|
|
1483
|
-
|
|
1574
|
+
client_max_size = g_config.get('limits', {}).get('client_max_size', 20*1024*1024) # 20MB max request size (to handle base64 encoding overhead)
|
|
1575
|
+
_log(f"client_max_size set to {client_max_size} bytes ({client_max_size/1024/1024:.1f}MB)")
|
|
1576
|
+
app = web.Application(client_max_size=client_max_size)
|
|
1484
1577
|
|
|
1485
1578
|
# Authentication middleware helper
|
|
1486
1579
|
def check_auth(request):
|
|
@@ -1601,6 +1694,29 @@ def main():
|
|
|
1601
1694
|
auth_url = f"https://github.com/login/oauth/authorize?{urlencode(params)}"
|
|
1602
1695
|
|
|
1603
1696
|
return web.HTTPFound(auth_url)
|
|
1697
|
+
|
|
1698
|
+
def validate_user(github_username):
|
|
1699
|
+
auth_config = g_config['auth']['github']
|
|
1700
|
+
# Check if user is restricted
|
|
1701
|
+
restrict_to = auth_config.get('restrict_to', '')
|
|
1702
|
+
|
|
1703
|
+
# Expand environment variables
|
|
1704
|
+
if restrict_to.startswith('$'):
|
|
1705
|
+
restrict_to = os.environ.get(restrict_to[1:], '')
|
|
1706
|
+
|
|
1707
|
+
# If restrict_to is configured, validate the user
|
|
1708
|
+
if restrict_to:
|
|
1709
|
+
# Parse allowed users (comma or space delimited)
|
|
1710
|
+
allowed_users = [u.strip() for u in re.split(r'[,\s]+', restrict_to) if u.strip()]
|
|
1711
|
+
|
|
1712
|
+
# Check if user is in the allowed list
|
|
1713
|
+
if not github_username or github_username not in allowed_users:
|
|
1714
|
+
_log(f"Access denied for user: {github_username}. Not in allowed list: {allowed_users}")
|
|
1715
|
+
return web.Response(
|
|
1716
|
+
text=f"Access denied. User '{github_username}' is not authorized to access this application.",
|
|
1717
|
+
status=403
|
|
1718
|
+
)
|
|
1719
|
+
return None
|
|
1604
1720
|
|
|
1605
1721
|
async def github_callback_handler(request):
|
|
1606
1722
|
"""Handle GitHub OAuth callback"""
|
|
@@ -1664,6 +1780,11 @@ def main():
|
|
|
1664
1780
|
async with session.get(user_url, headers=headers) as resp:
|
|
1665
1781
|
user_data = await resp.json()
|
|
1666
1782
|
|
|
1783
|
+
# Validate user
|
|
1784
|
+
error_response = validate_user(user_data.get('login', ''))
|
|
1785
|
+
if error_response:
|
|
1786
|
+
return error_response
|
|
1787
|
+
|
|
1667
1788
|
# Create session
|
|
1668
1789
|
session_token = secrets.token_urlsafe(32)
|
|
1669
1790
|
g_sessions[session_token] = {
|