llms-py 2.0.27__py3-none-any.whl → 2.0.29__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/llms.json CHANGED
@@ -4,7 +4,8 @@
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
- VERSION = "2.0.27"
28
+ try:
29
+ from PIL import Image
30
+ HAS_PIL = True
31
+ except ImportError:
32
+ HAS_PIL = False
33
+
34
+ VERSION = "2.0.29"
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
- pass
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:
@@ -1314,6 +1405,66 @@ async def save_home_configs():
1314
1405
  print("Could not create llms.json. Create one with --init or use --config <path>")
1315
1406
  exit(1)
1316
1407
 
1408
+ async def reload_providers():
1409
+ global g_config, g_handlers
1410
+ g_handlers = init_llms(g_config)
1411
+ await load_llms()
1412
+ _log(f"{len(g_handlers)} providers loaded")
1413
+ return g_handlers
1414
+
1415
+ async def watch_config_files(config_path, ui_path, interval=1):
1416
+ """Watch config files and reload providers when they change"""
1417
+ global g_config
1418
+
1419
+ config_path = Path(config_path)
1420
+ ui_path = Path(ui_path) if ui_path else None
1421
+
1422
+ file_mtimes = {}
1423
+
1424
+ _log(f"Watching config files: {config_path}" + (f", {ui_path}" if ui_path else ""))
1425
+
1426
+ while True:
1427
+ await asyncio.sleep(interval)
1428
+
1429
+ # Check llms.json
1430
+ try:
1431
+ if config_path.is_file():
1432
+ mtime = config_path.stat().st_mtime
1433
+
1434
+ if str(config_path) not in file_mtimes:
1435
+ file_mtimes[str(config_path)] = mtime
1436
+ elif file_mtimes[str(config_path)] != mtime:
1437
+ _log(f"Config file changed: {config_path.name}")
1438
+ file_mtimes[str(config_path)] = mtime
1439
+
1440
+ try:
1441
+ # Reload llms.json
1442
+ with open(config_path, "r") as f:
1443
+ g_config = json.load(f)
1444
+
1445
+ # Reload providers
1446
+ await reload_providers()
1447
+ _log("Providers reloaded successfully")
1448
+ except Exception as e:
1449
+ _log(f"Error reloading config: {e}")
1450
+ except FileNotFoundError:
1451
+ pass
1452
+
1453
+ # Check ui.json
1454
+ if ui_path:
1455
+ try:
1456
+ if ui_path.is_file():
1457
+ mtime = ui_path.stat().st_mtime
1458
+
1459
+ if str(ui_path) not in file_mtimes:
1460
+ file_mtimes[str(ui_path)] = mtime
1461
+ elif file_mtimes[str(ui_path)] != mtime:
1462
+ _log(f"Config file changed: {ui_path.name}")
1463
+ file_mtimes[str(ui_path)] = mtime
1464
+ _log("ui.json reloaded - reload page to update")
1465
+ except FileNotFoundError:
1466
+ pass
1467
+
1317
1468
  def main():
1318
1469
  global _ROOT, g_verbose, g_default_model, g_logprefix, g_config, g_config_path, g_ui_path
1319
1470
 
@@ -1401,8 +1552,7 @@ def main():
1401
1552
  g_ui_path = home_ui_path
1402
1553
  g_config = json.loads(text_from_file(g_config_path))
1403
1554
 
1404
- init_llms(g_config)
1405
- asyncio.run(load_llms())
1555
+ asyncio.run(reload_providers())
1406
1556
 
1407
1557
  # print names
1408
1558
  _log(f"enabled providers: {', '.join(g_handlers.keys())}")
@@ -1480,7 +1630,9 @@ def main():
1480
1630
 
1481
1631
  _log("Authentication enabled - GitHub OAuth configured")
1482
1632
 
1483
- app = web.Application()
1633
+ client_max_size = g_config.get('limits', {}).get('client_max_size', 20*1024*1024) # 20MB max request size (to handle base64 encoding overhead)
1634
+ _log(f"client_max_size set to {client_max_size} bytes ({client_max_size/1024/1024:.1f}MB)")
1635
+ app = web.Application(client_max_size=client_max_size)
1484
1636
 
1485
1637
  # Authentication middleware helper
1486
1638
  def check_auth(request):
@@ -1601,6 +1753,29 @@ def main():
1601
1753
  auth_url = f"https://github.com/login/oauth/authorize?{urlencode(params)}"
1602
1754
 
1603
1755
  return web.HTTPFound(auth_url)
1756
+
1757
+ def validate_user(github_username):
1758
+ auth_config = g_config['auth']['github']
1759
+ # Check if user is restricted
1760
+ restrict_to = auth_config.get('restrict_to', '')
1761
+
1762
+ # Expand environment variables
1763
+ if restrict_to.startswith('$'):
1764
+ restrict_to = os.environ.get(restrict_to[1:], '')
1765
+
1766
+ # If restrict_to is configured, validate the user
1767
+ if restrict_to:
1768
+ # Parse allowed users (comma or space delimited)
1769
+ allowed_users = [u.strip() for u in re.split(r'[,\s]+', restrict_to) if u.strip()]
1770
+
1771
+ # Check if user is in the allowed list
1772
+ if not github_username or github_username not in allowed_users:
1773
+ _log(f"Access denied for user: {github_username}. Not in allowed list: {allowed_users}")
1774
+ return web.Response(
1775
+ text=f"Access denied. User '{github_username}' is not authorized to access this application.",
1776
+ status=403
1777
+ )
1778
+ return None
1604
1779
 
1605
1780
  async def github_callback_handler(request):
1606
1781
  """Handle GitHub OAuth callback"""
@@ -1664,6 +1839,11 @@ def main():
1664
1839
  async with session.get(user_url, headers=headers) as resp:
1665
1840
  user_data = await resp.json()
1666
1841
 
1842
+ # Validate user
1843
+ error_response = validate_user(user_data.get('login', ''))
1844
+ if error_response:
1845
+ return error_response
1846
+
1667
1847
  # Create session
1668
1848
  session_token = secrets.token_urlsafe(32)
1669
1849
  g_sessions[session_token] = {
@@ -1814,6 +1994,14 @@ def main():
1814
1994
  # Serve index.html as fallback route (SPA routing)
1815
1995
  app.router.add_route('*', '/{tail:.*}', index_handler)
1816
1996
 
1997
+ # Setup file watcher for config files
1998
+ async def start_background_tasks(app):
1999
+ """Start background tasks when the app starts"""
2000
+ # Start watching config files in the background
2001
+ asyncio.create_task(watch_config_files(g_config_path, g_ui_path))
2002
+
2003
+ app.on_startup.append(start_background_tasks)
2004
+
1817
2005
  print(f"Starting server on port {port}...")
1818
2006
  web.run_app(app, host='0.0.0.0', port=port, print=_log)
1819
2007
  exit(0)
llms/ui/Analytics.mjs CHANGED
@@ -1021,7 +1021,9 @@ export default {
1021
1021
 
1022
1022
  // Only display label if percentage > 1%
1023
1023
  if (parseFloat(percentage) > 1) {
1024
- chartCtx.fillStyle = '#000'
1024
+ // Use white color in dark mode, black in light mode
1025
+ const isDarkMode = document.documentElement.classList.contains('dark')
1026
+ chartCtx.fillStyle = isDarkMode ? '#fff' : '#000'
1025
1027
  chartCtx.font = 'bold 12px Arial'
1026
1028
  chartCtx.textAlign = 'center'
1027
1029
  chartCtx.textBaseline = 'middle'
@@ -1078,7 +1080,9 @@ export default {
1078
1080
 
1079
1081
  // Only display label if percentage > 1%
1080
1082
  if (parseFloat(percentage) > 1) {
1081
- chartCtx.fillStyle = '#000'
1083
+ // Use white color in dark mode, black in light mode
1084
+ const isDarkMode = document.documentElement.classList.contains('dark')
1085
+ chartCtx.fillStyle = isDarkMode ? '#fff' : '#000'
1082
1086
  chartCtx.font = 'bold 12px Arial'
1083
1087
  chartCtx.textAlign = 'center'
1084
1088
  chartCtx.textBaseline = 'middle'
@@ -1135,7 +1139,9 @@ export default {
1135
1139
 
1136
1140
  // Only display label if percentage > 1%
1137
1141
  if (parseFloat(percentage) > 1) {
1138
- chartCtx.fillStyle = '#000'
1142
+ // Use white color in dark mode, black in light mode
1143
+ const isDarkMode = document.documentElement.classList.contains('dark')
1144
+ chartCtx.fillStyle = isDarkMode ? '#fff' : '#000'
1139
1145
  chartCtx.font = 'bold 12px Arial'
1140
1146
  chartCtx.textAlign = 'center'
1141
1147
  chartCtx.textBaseline = 'middle'
@@ -1192,7 +1198,9 @@ export default {
1192
1198
 
1193
1199
  // Only display label if percentage > 1%
1194
1200
  if (parseFloat(percentage) > 1) {
1195
- chartCtx.fillStyle = '#000'
1201
+ // Use white color in dark mode, black in light mode
1202
+ const isDarkMode = document.documentElement.classList.contains('dark')
1203
+ chartCtx.fillStyle = isDarkMode ? '#fff' : '#000'
1196
1204
  chartCtx.font = 'bold 12px Arial'
1197
1205
  chartCtx.textAlign = 'center'
1198
1206
  chartCtx.textBaseline = 'middle'
llms/ui/ChatPrompt.mjs CHANGED
@@ -11,6 +11,7 @@ export function useChatPrompt() {
11
11
  const attachedFiles = ref([])
12
12
  const isGenerating = ref(false)
13
13
  const errorStatus = ref(null)
14
+ const abortController = ref(null)
14
15
  const hasImage = () => attachedFiles.value.some(f => imageExts.includes(lastRightPart(f.name, '.')))
15
16
  const hasAudio = () => attachedFiles.value.some(f => audioExts.includes(lastRightPart(f.name, '.')))
16
17
  const hasFile = () => attachedFiles.value.length > 0
@@ -21,6 +22,17 @@ export function useChatPrompt() {
21
22
  isGenerating.value = false
22
23
  attachedFiles.value = []
23
24
  messageText.value = ''
25
+ abortController.value = null
26
+ }
27
+
28
+ function cancel() {
29
+ // Cancel the pending request
30
+ if (abortController.value) {
31
+ abortController.value.abort()
32
+ }
33
+ // Reset UI state
34
+ isGenerating.value = false
35
+ abortController.value = null
24
36
  }
25
37
 
26
38
  return {
@@ -28,6 +40,7 @@ export function useChatPrompt() {
28
40
  attachedFiles,
29
41
  errorStatus,
30
42
  isGenerating,
43
+ abortController,
31
44
  get generating() {
32
45
  return isGenerating.value
33
46
  },
@@ -36,6 +49,7 @@ export function useChatPrompt() {
36
49
  hasFile,
37
50
  // hasText,
38
51
  reset,
52
+ cancel,
39
53
  }
40
54
  }
41
55
 
@@ -91,15 +105,18 @@ export default {
91
105
  ]"
92
106
  :disabled="isGenerating || !model"
93
107
  ></textarea>
94
- <button title="Send (Enter)" type="button"
108
+ <button v-if="!isGenerating" title="Send (Enter)" type="button"
95
109
  @click="sendMessage"
96
110
  :disabled="!messageText.trim() || isGenerating || !model"
97
111
  class="absolute bottom-2 right-2 size-8 flex items-center justify-center rounded-md border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:text-gray-400 disabled:cursor-not-allowed disabled:border-gray-200 dark:disabled:border-gray-700 transition-colors">
98
- <svg v-if="isGenerating" class="size-5 animate-spin" fill="none" viewBox="0 0 24 24">
99
- <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
100
- <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
112
+ <svg class="size-5" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path stroke-dasharray="20" stroke-dashoffset="20" d="M12 21l0 -17.5"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.2s" values="20;0"/></path><path stroke-dasharray="12" stroke-dashoffset="12" d="M12 3l7 7M12 3l-7 7"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.2s" dur="0.2s" values="12;0"/></path></g></svg>
113
+ </button>
114
+ <button v-else title="Cancel request" type="button"
115
+ @click="cancelRequest"
116
+ class="absolute bottom-2 right-2 size-8 flex items-center justify-center rounded-md border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors">
117
+ <svg class="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
118
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
101
119
  </svg>
102
- <svg v-else class="size-5" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path stroke-dasharray="20" stroke-dashoffset="20" d="M12 21l0 -17.5"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.2s" values="20;0"/></path><path stroke-dasharray="12" stroke-dashoffset="12" d="M12 3l7 7M12 3l-7 7"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.2s" dur="0.2s" values="12;0"/></path></g></svg>
103
120
  </button>
104
121
  </div>
105
122
 
@@ -304,6 +321,10 @@ export default {
304
321
  }
305
322
  messageText.value = ''
306
323
 
324
+ // Create AbortController for this request
325
+ const controller = new AbortController()
326
+ chatPrompt.abortController.value = controller
327
+
307
328
  try {
308
329
  let threadId
309
330
 
@@ -434,11 +455,15 @@ export default {
434
455
  }))
435
456
  }
436
457
 
458
+ chatRequest.metadata ??= {}
459
+ chatRequest.metadata.threadId = threadId
460
+
437
461
  // Send to API
438
462
  console.debug('chatRequest', chatRequest)
439
463
  const startTime = Date.now()
440
464
  const response = await ai.post('/v1/chat/completions', {
441
- body: JSON.stringify(chatRequest)
465
+ body: JSON.stringify(chatRequest),
466
+ signal: controller.signal
442
467
  })
443
468
 
444
469
  let result = null
@@ -513,11 +538,25 @@ export default {
513
538
  attachedFiles.value = []
514
539
  // Error will be cleared when user sends next message (no auto-timeout)
515
540
  }
541
+ } catch (error) {
542
+ // Check if the error is due to abort
543
+ if (error.name === 'AbortError') {
544
+ console.log('Request was cancelled by user')
545
+ // Don't show error for cancelled requests
546
+ } else {
547
+ // Re-throw other errors to be handled by outer catch
548
+ throw error
549
+ }
516
550
  } finally {
517
551
  isGenerating.value = false
552
+ chatPrompt.abortController.value = null
518
553
  }
519
554
  }
520
555
 
556
+ const cancelRequest = () => {
557
+ chatPrompt.cancel()
558
+ }
559
+
521
560
  const addNewLine = () => {
522
561
  // Enter key already adds new line
523
562
  //messageText.value += '\n'
@@ -538,6 +577,7 @@ export default {
538
577
  onDrop,
539
578
  removeAttachment,
540
579
  sendMessage,
580
+ cancelRequest,
541
581
  addNewLine,
542
582
  }
543
583
  }
llms/ui/Main.mjs CHANGED
@@ -38,7 +38,6 @@ export default {
38
38
  <SystemPromptSelector :prompts="prompts" v-model="selectedPrompt"
39
39
  :show="showSystemPrompt" @toggle="showSystemPrompt = !showSystemPrompt" />
40
40
  <Avatar />
41
- <DarkModeToggle />
42
41
  </div>
43
42
  </div>
44
43
  </div>
@@ -63,7 +62,7 @@ export default {
63
62
  </div>
64
63
 
65
64
  <!-- Export/Import buttons -->
66
- <div class="mt-2 flex space-x-3 justify-center">
65
+ <div class="mt-2 flex space-x-3 justify-center items-center">
67
66
  <button type="button"
68
67
  @click="(e) => e.altKey ? exportRequests() : exportThreads()"
69
68
  :disabled="isExporting"
@@ -104,6 +103,9 @@ export default {
104
103
  @change="handleFileImport"
105
104
  class="hidden"
106
105
  />
106
+
107
+ <DarkModeToggle />
108
+
107
109
  </div>
108
110
 
109
111
  </div>
@@ -216,7 +218,7 @@ export default {
216
218
  </div>
217
219
 
218
220
  <!-- Loading indicator -->
219
- <div v-if="isGenerating" class="flex items-start space-x-3">
221
+ <div v-if="isGenerating" class="flex items-start space-x-3 group">
220
222
  <!-- Avatar outside the bubble -->
221
223
  <div class="flex-shrink-0">
222
224
  <div class="w-8 h-8 rounded-full bg-gray-600 dark:bg-gray-500 text-white flex items-center justify-center text-sm font-medium">
@@ -232,6 +234,13 @@ export default {
232
234
  <div class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
233
235
  </div>
234
236
  </div>
237
+
238
+ <!-- Cancel button -->
239
+ <button type="button" @click="cancelRequest"
240
+ class="px-3 py-1 rounded text-sm text-gray-400 dark:text-gray-500 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 border border-transparent hover:border-red-300 dark:hover:border-red-600 transition-all"
241
+ title="Cancel request">
242
+ cancel
243
+ </button>
235
244
  </div>
236
245
 
237
246
  <!-- Error message bubble -->
@@ -739,6 +748,11 @@ export default {
739
748
  editingMessage.value = null
740
749
  }
741
750
 
751
+ // Cancel pending request
752
+ const cancelRequest = () => {
753
+ chatPrompt.cancel()
754
+ }
755
+
742
756
  function tokensTitle(usage) {
743
757
  let title = []
744
758
  if (usage.tokens && usage.price) {
@@ -788,6 +802,7 @@ export default {
788
802
  editMessage,
789
803
  saveEditedMessage,
790
804
  cancelEdit,
805
+ cancelRequest,
791
806
  configUpdated,
792
807
  exportThreads,
793
808
  exportRequests,
llms/ui/ModelSelector.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { ref, onMounted, onUnmounted } from "vue"
1
2
  import ProviderStatus from "./ProviderStatus.mjs"
2
3
  import ProviderIcon from "./ProviderIcon.mjs"
3
4
 
@@ -9,7 +10,7 @@ export default {
9
10
  template:`
10
11
  <!-- Model Selector -->
11
12
  <div class="pl-1 flex space-x-2">
12
- <Autocomplete id="model" :options="models" label=""
13
+ <Autocomplete ref="refSelector" id="model" :options="models" label=""
13
14
  :modelValue="modelValue" @update:modelValue="$emit('update:modelValue', $event)"
14
15
  class="w-72 xl:w-84"
15
16
  :match="(x, value) => x.id.toLowerCase().includes(value.toLowerCase())"
@@ -52,8 +53,25 @@ export default {
52
53
  return ret.endsWith('.00') ? ret.slice(0, -3) : ret
53
54
  }
54
55
 
56
+ const refSelector = ref()
57
+
58
+ function collapse(e) {
59
+ // call toggle when clicking outside of the Autocomplete component
60
+ if (refSelector.value && !refSelector.value.$el.contains(e.target)) {
61
+ refSelector.value.toggle(false)
62
+ }
63
+ }
64
+
65
+ onMounted(() => {
66
+ document.addEventListener('click', collapse)
67
+ })
68
+ onUnmounted(() => {
69
+ document.removeEventListener('click', collapse)
70
+ })
71
+
55
72
  return {
56
- tokenPrice
73
+ refSelector,
74
+ tokenPrice,
57
75
  }
58
76
  }
59
77
  }
@@ -1,10 +1,11 @@
1
+ import { ref, onMounted, onUnmounted } from "vue"
1
2
  export default {
2
3
  template:`
3
4
  <button v-if="modelValue" type="button" title="Clear System Prompt" @click="$emit('update:modelValue', null)">
4
5
  <svg class="size-4 text-gray-500 dark:text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z"/></svg>
5
6
  </button>
6
7
 
7
- <Autocomplete id="prompt" :options="prompts" label=""
8
+ <Autocomplete ref="refSelector" id="prompt" :options="prompts" label=""
8
9
  :modelValue="modelValue" @update:modelValue="$emit('update:modelValue', $event)"
9
10
  class="w-72 xl:w-84"
10
11
  :match="(x, value) => x.name.toLowerCase().includes(value.toLowerCase())"
@@ -32,5 +33,24 @@ export default {
32
33
  show: Boolean,
33
34
  },
34
35
  setup() {
36
+ const refSelector = ref()
37
+
38
+ function collapse(e) {
39
+ // call toggle when clicking outside of the Autocomplete component
40
+ if (refSelector.value && !refSelector.value.$el.contains(e.target)) {
41
+ refSelector.value.toggle(false)
42
+ }
43
+ }
44
+
45
+ onMounted(() => {
46
+ document.addEventListener('click', collapse)
47
+ })
48
+ onUnmounted(() => {
49
+ document.removeEventListener('click', collapse)
50
+ })
51
+
52
+ return {
53
+ refSelector,
54
+ }
35
55
  }
36
56
  }
llms/ui/ai.mjs CHANGED
@@ -6,7 +6,7 @@ const headers = { 'Accept': 'application/json' }
6
6
  const prefsKey = 'llms.prefs'
7
7
 
8
8
  export const o = {
9
- version: '2.0.27',
9
+ version: '2.0.29',
10
10
  base,
11
11
  prefsKey,
12
12
  welcome: 'Welcome to llms.py',