llms-py 2.0.28__py3-none-any.whl → 2.0.30__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/main.py CHANGED
@@ -31,7 +31,7 @@ try:
31
31
  except ImportError:
32
32
  HAS_PIL = False
33
33
 
34
- VERSION = "2.0.28"
34
+ VERSION = "2.0.30"
35
35
  _ROOT = None
36
36
  g_config_path = None
37
37
  g_ui_path = None
@@ -1405,6 +1405,66 @@ async def save_home_configs():
1405
1405
  print("Could not create llms.json. Create one with --init or use --config <path>")
1406
1406
  exit(1)
1407
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
+
1408
1468
  def main():
1409
1469
  global _ROOT, g_verbose, g_default_model, g_logprefix, g_config, g_config_path, g_ui_path
1410
1470
 
@@ -1436,7 +1496,10 @@ def main():
1436
1496
  parser.add_argument('--verbose', action='store_true', help='Verbose output')
1437
1497
 
1438
1498
  cli_args, extra_args = parser.parse_known_args()
1439
- if cli_args.verbose:
1499
+
1500
+ # Check for verbose mode from CLI argument or environment variables
1501
+ verbose_env = os.environ.get('VERBOSE', '').lower()
1502
+ if cli_args.verbose or verbose_env in ('1', 'true'):
1440
1503
  g_verbose = True
1441
1504
  # printdump(cli_args)
1442
1505
  if cli_args.model:
@@ -1492,8 +1555,7 @@ def main():
1492
1555
  g_ui_path = home_ui_path
1493
1556
  g_config = json.loads(text_from_file(g_config_path))
1494
1557
 
1495
- init_llms(g_config)
1496
- asyncio.run(load_llms())
1558
+ asyncio.run(reload_providers())
1497
1559
 
1498
1560
  # print names
1499
1561
  _log(f"enabled providers: {', '.join(g_handlers.keys())}")
@@ -1935,6 +1997,14 @@ def main():
1935
1997
  # Serve index.html as fallback route (SPA routing)
1936
1998
  app.router.add_route('*', '/{tail:.*}', index_handler)
1937
1999
 
2000
+ # Setup file watcher for config files
2001
+ async def start_background_tasks(app):
2002
+ """Start background tasks when the app starts"""
2003
+ # Start watching config files in the background
2004
+ asyncio.create_task(watch_config_files(g_config_path, g_ui_path))
2005
+
2006
+ app.on_startup.append(start_background_tasks)
2007
+
1938
2008
  print(f"Starting server on port {port}...")
1939
2009
  web.run_app(app, host='0.0.0.0', port=port, print=_log)
1940
2010
  exit(0)
llms/ui/Analytics.mjs CHANGED
@@ -24,16 +24,17 @@ export const colors = [
24
24
 
25
25
  const MonthSelector = {
26
26
  template:`
27
- <div class="flex gap-4 items-center">
27
+ <div class="flex flex-col sm:flex-row gap-2 sm:gap-4 items-stretch sm:items-center w-full sm:w-auto">
28
28
  <!-- Months Row -->
29
- <div class="flex gap-2 flex-wrap justify-center">
29
+ <div class="flex gap-1 sm:gap-2 flex-wrap justify-center overflow-x-auto">
30
30
  <template v-for="month in availableMonthsForYear" :key="month">
31
31
  <span v-if="selectedMonth === month"
32
- class="text-xs leading-5 font-semibold bg-indigo-600 text-white rounded-full py-1 px-3 flex items-center space-x-2">
33
- {{ new Date(selectedYear + '-' + month.toString().padStart(2,'0') + '-01').toLocaleString('default', { month: 'long' }) }}
32
+ class="text-xs leading-5 font-semibold bg-indigo-600 text-white rounded-full py-1 px-2 sm:px-3 flex items-center space-x-2 whitespace-nowrap">
33
+ <span class="hidden sm:inline">{{ new Date(selectedYear + '-' + month.toString().padStart(2,'0') + '-01').toLocaleString('default', { month: 'long' }) }}</span>
34
+ <span class="sm:hidden">{{ new Date(selectedYear + '-' + month.toString().padStart(2,'0') + '-01').toLocaleString('default', { month: 'short' }) }}</span>
34
35
  </span>
35
36
  <button v-else type="button"
36
- class="text-xs leading-5 font-semibold bg-slate-400/10 rounded-full py-1 px-3 flex items-center space-x-2 hover:bg-slate-400/20 dark:highlight-white/5"
37
+ class="text-xs leading-5 font-semibold bg-slate-400/10 rounded-full py-1 px-2 sm:px-3 flex items-center space-x-2 hover:bg-slate-400/20 dark:highlight-white/5 whitespace-nowrap"
37
38
  @click="updateSelection(selectedYear, month)">
38
39
  {{ new Date(selectedYear + '-' + month.toString().padStart(2,'0') + '-01').toLocaleString('default', { month: 'short' }) }}
39
40
  </button>
@@ -42,7 +43,7 @@ const MonthSelector = {
42
43
 
43
44
  <!-- Year Dropdown -->
44
45
  <select :value="selectedYear" @change="(e) => updateSelection(parseInt(e.target.value), selectedMonth)"
45
- class="border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300 rounded-md text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-indigo-500">
46
+ class="border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300 rounded-md text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-indigo-500 flex-shrink-0">
46
47
  <option v-for="year in availableYears" :key="year" :value="year">
47
48
  {{ year }}
48
49
  </option>
@@ -115,9 +116,11 @@ export default {
115
116
  template: `
116
117
  <div class="flex flex-col h-full w-full">
117
118
  <!-- Header -->
118
- <div class="border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-4 py-3 min-h-16">
119
- <div class="max-w-6xl mx-auto flex items-center justify-between gap-3">
120
- <h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
119
+ <div class="border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-2 sm:px-4 py-3">
120
+ <div
121
+ :class="!$ai.isSidebarOpen ? 'pl-3' : ''"
122
+ class="max-w-6xl mx-auto flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
123
+ <h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 flex-shrink-0">
121
124
  <RouterLink to="/analytics">Analytics</RouterLink>
122
125
  </h2>
123
126
  <MonthSelector :dailyData="allDailyData" />
@@ -179,13 +182,13 @@ export default {
179
182
  </div>
180
183
 
181
184
  <!-- Cost Analysis Tab -->
182
- <div v-if="activeTab === 'cost'" class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
183
- <div class="flex items-center justify-between mb-6">
184
- <h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Daily Costs</h3>
185
- <h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
185
+ <div v-if="activeTab === 'cost'" class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 sm:p-6">
186
+ <div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
187
+ <h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-gray-100">Daily Costs</h3>
188
+ <h3 class="text-sm sm:text-lg font-semibold text-gray-900 dark:text-gray-100">
186
189
  {{ new Date(selectedDay).toLocaleDateString(undefined, { year: 'numeric', month: 'long' }) }}
187
190
  </h3>
188
- <select v-model="costChartType" class="px-3 pr-6 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300 rounded-md text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-800">
191
+ <select v-model="costChartType" class="px-3 pr-6 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300 rounded-md text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-800 flex-shrink-0">
189
192
  <option value="bar">Bar Chart</option>
190
193
  <option value="line">Line Chart</option>
191
194
  </select>
@@ -200,10 +203,10 @@ export default {
200
203
  </div>
201
204
 
202
205
  <!-- Token Usage Tab -->
203
- <div v-if="activeTab === 'tokens'" class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
204
- <h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-6 flex justify-between items-center">
206
+ <div v-if="activeTab === 'tokens'" class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 sm:p-6">
207
+ <h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-gray-100 mb-6 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2">
205
208
  <span>Daily Token Usage</span>
206
- <span>
209
+ <span class="text-sm sm:text-base">
207
210
  {{ new Date(selectedDay).toLocaleDateString(undefined, { year: 'numeric', month: 'long' }) }}
208
211
  </span>
209
212
  </h3>
@@ -216,16 +219,16 @@ export default {
216
219
  </div>
217
220
  </div>
218
221
 
219
- <div v-if="allDailyData[selectedDay]?.requests && ['cost', 'tokens'].includes(activeTab)" class="mt-8 px-2 text-gray-700 dark:text-gray-300 font-medium flex items-center justify-between">
222
+ <div v-if="allDailyData[selectedDay]?.requests && ['cost', 'tokens'].includes(activeTab)" class="mt-8 px-2 text-sm sm:text-base text-gray-700 dark:text-gray-300 font-medium flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
220
223
  <div>
221
224
  {{ new Date(selectedDay).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) }}
222
225
  </div>
223
- <div>
224
- {{ formatCost(allDailyData[selectedDay]?.cost || 0) }}
225
- &#183;
226
- {{ allDailyData[selectedDay]?.requests || 0 }} Requests
227
- &#183;
228
- {{ humanifyNumber(allDailyData[selectedDay]?.inputTokens || 0) }} -> {{ humanifyNumber(allDailyData[selectedDay]?.outputTokens || 0) }} Tokens
226
+ <div class="flex flex-wrap gap-x-2 gap-y-1">
227
+ <span>{{ formatCost(allDailyData[selectedDay]?.cost || 0) }}</span>
228
+ <span>&#183;</span>
229
+ <span>{{ allDailyData[selectedDay]?.requests || 0 }} Requests</span>
230
+ <span>&#183;</span>
231
+ <span>{{ humanifyNumber(allDailyData[selectedDay]?.inputTokens || 0) }} -> {{ humanifyNumber(allDailyData[selectedDay]?.outputTokens || 0) }} Tokens</span>
229
232
  </div>
230
233
  </div>
231
234
 
@@ -290,10 +293,10 @@ export default {
290
293
  <!-- Activity Tab - Full Page Layout -->
291
294
  <div v-if="activeTab === 'activity'" class="h-full flex flex-col bg-white dark:bg-gray-800">
292
295
  <!-- Filters Bar -->
293
- <div class="flex-shrink-0 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-6 py-4">
294
- <div class="flex flex-wrap gap-4 items-end">
295
- <div class="flex flex-col">
296
- <select v-model="selectedModel" class="px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
296
+ <div class="flex-shrink-0 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-3 sm:px-6 py-4">
297
+ <div class="flex flex-wrap gap-2 sm:gap-4 items-end">
298
+ <div class="flex flex-col flex-1 min-w-[120px] sm:flex-initial">
299
+ <select v-model="selectedModel" class="px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-full">
297
300
  <option value="">All Models</option>
298
301
  <option v-for="model in filterOptions.models" :key="model" :value="model">
299
302
  {{ model }}
@@ -301,8 +304,8 @@ export default {
301
304
  </select>
302
305
  </div>
303
306
 
304
- <div class="flex flex-col">
305
- <select v-model="selectedProvider" class="px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
307
+ <div class="flex flex-col flex-1 min-w-[120px] sm:flex-initial">
308
+ <select v-model="selectedProvider" class="px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-full">
306
309
  <option value="">All Providers</option>
307
310
  <option v-for="provider in filterOptions.providers" :key="provider" :value="provider">
308
311
  {{ provider }}
@@ -310,8 +313,8 @@ export default {
310
313
  </select>
311
314
  </div>
312
315
 
313
- <div class="flex flex-col">
314
- <select v-model="sortBy" class="px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
316
+ <div class="flex flex-col flex-1 min-w-[140px] sm:flex-initial">
317
+ <select v-model="sortBy" class="px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-full">
315
318
  <option value="created">Date (Newest)</option>
316
319
  <option value="cost">Cost (Highest)</option>
317
320
  <option value="duration">Duration (Longest)</option>
@@ -319,7 +322,7 @@ export default {
319
322
  </select>
320
323
  </div>
321
324
 
322
- <button v-if="hasActiveFilters" @click="clearActivityFilters" class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
325
+ <button v-if="hasActiveFilters" @click="clearActivityFilters" class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors whitespace-nowrap">
323
326
  Clear Filters
324
327
  </button>
325
328
  </div>
@@ -339,30 +342,30 @@ export default {
339
342
  </div>
340
343
 
341
344
  <div v-else class="divide-y divide-gray-200 dark:divide-gray-700">
342
- <div v-for="request in activityRequests" :key="request.id" class="px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
343
- <div class="flex items-start justify-between gap-4">
344
- <div class="flex-1 min-w-0">
345
- <div class="flex justify-between">
346
- <div class="flex items-center gap-2 mb-2 flex-wrap">
345
+ <div v-for="request in activityRequests" :key="request.id" class="px-3 sm:px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
346
+ <div class="flex flex-col lg:flex-row items-start justify-between gap-4">
347
+ <div class="flex-1 min-w-0 w-full">
348
+ <div class="flex flex-col sm:flex-row justify-between gap-2 mb-2">
349
+ <div class="flex items-center gap-2 flex-wrap">
347
350
  <span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 rounded font-medium">{{ request.model }}</span>
348
351
  <span class="text-xs px-2 py-1 bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 rounded font-medium">{{ request.provider }}</span>
349
352
  <span v-if="request.providerRef" class="text-xs px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 rounded font-medium">{{ request.providerRef }}</span>
350
353
  <span v-if="request.finishReason" class="text-xs px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300 rounded font-medium">{{ request.finishReason }}</span>
351
354
  </div>
352
- <div class="text-xs text-gray-500 dark:text-gray-400">
355
+ <div class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
353
356
  {{ formatActivityDate(request.created) }}
354
357
  </div>
355
358
  </div>
356
- <div class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
359
+ <div class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate mb-3">
357
360
  {{ request.title }}
358
361
  </div>
359
362
 
360
- <div class="grid grid-cols-2 md:grid-cols-5 gap-4 mt-3">
363
+ <div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4">
361
364
  <div :title="request.cost">
362
365
  <div class="text-xs text-gray-500 dark:text-gray-400 font-medium">Cost</div>
363
366
  <div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ formatCost(request.cost) }}</div>
364
367
  </div>
365
- <div>
368
+ <div class="col-span-2 sm:col-span-1">
366
369
  <div class="text-xs text-gray-500 dark:text-gray-400 font-medium">Tokens</div>
367
370
  <div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
368
371
  {{ humanifyNumber(request.inputTokens) }} -> {{ humanifyNumber(request.outputTokens) }}
@@ -379,12 +382,12 @@ export default {
379
382
  </div>
380
383
  </div>
381
384
  </div>
382
- <div class="flex flex-col gap-2">
383
- <button type="button" v-if="threadExists(request.threadId)" @click="openThread(request.threadId)" class="flex-shrink-0 px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 border border-blue-300 dark:border-blue-600 rounded hover:bg-blue-50 dark:hover:bg-blue-900/30 transition-colors whitespace-nowrap">
384
- View<span class="hidden lg:inline"> Thread</span>
385
+ <div class="flex flex-row lg:flex-col gap-2 w-full lg:w-auto">
386
+ <button type="button" v-if="threadExists(request.threadId)" @click="openThread(request.threadId)" class="flex-1 lg:flex-initial px-3 sm:px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 border border-blue-300 dark:border-blue-600 rounded hover:bg-blue-50 dark:hover:bg-blue-900/30 transition-colors whitespace-nowrap">
387
+ View<span class="hidden sm:inline"> Thread</span>
385
388
  </button>
386
- <button type="button" @click="deleteRequestLog(request.id)" class="flex-shrink-0 px-4 py-2 text-sm font-medium text-red-600 dark:text-red-500 hover:text-red-800 dark:hover:text-red-400 border border-red-300 dark:border-red-600 rounded hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors whitespace-nowrap">
387
- Delete<span class="hidden lg:inline"> Request</span>
389
+ <button type="button" @click="deleteRequestLog(request.id)" class="flex-1 lg:flex-initial px-3 sm:px-4 py-2 text-sm font-medium text-red-600 dark:text-red-500 hover:text-red-800 dark:hover:text-red-400 border border-red-300 dark:border-red-600 rounded hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors whitespace-nowrap">
390
+ Delete<span class="hidden sm:inline"> Request</span>
388
391
  </button>
389
392
  </div>
390
393
  </div>
llms/ui/App.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { inject } from "vue"
1
+ import { inject, ref, onMounted, onUnmounted } from "vue"
2
2
  import Sidebar from "./Sidebar.mjs"
3
3
 
4
4
  export default {
@@ -7,17 +7,89 @@ export default {
7
7
  },
8
8
  setup() {
9
9
  const ai = inject('ai')
10
- return { ai }
10
+ const isMobile = ref(false)
11
+
12
+ const checkMobile = () => {
13
+ const wasMobile = isMobile.value
14
+ isMobile.value = window.innerWidth < 1024 // lg breakpoint
15
+
16
+ // Only auto-adjust sidebar state when transitioning between mobile/desktop
17
+ if (wasMobile !== isMobile.value) {
18
+ if (isMobile.value) {
19
+ ai.isSidebarOpen = false
20
+ } else {
21
+ ai.isSidebarOpen = true
22
+ }
23
+ }
24
+ }
25
+
26
+ const toggleSidebar = () => {
27
+ ai.isSidebarOpen = !ai.isSidebarOpen
28
+ }
29
+
30
+ const closeSidebar = () => {
31
+ if (isMobile.value) {
32
+ ai.isSidebarOpen = false
33
+ }
34
+ }
35
+
36
+ onMounted(() => {
37
+ checkMobile()
38
+ window.addEventListener('resize', checkMobile)
39
+ })
40
+
41
+ onUnmounted(() => {
42
+ window.removeEventListener('resize', checkMobile)
43
+ })
44
+
45
+ return { ai, isMobile, toggleSidebar, closeSidebar }
11
46
  },
12
47
  template: `
13
48
  <div class="flex h-screen bg-white dark:bg-gray-900">
49
+ <!-- Mobile Overlay -->
50
+ <div
51
+ v-if="isMobile && ai.isSidebarOpen && !(ai.requiresAuth && !ai.auth)"
52
+ @click="closeSidebar"
53
+ class="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
54
+ ></div>
55
+
14
56
  <!-- Sidebar (hidden when auth required and not authenticated) -->
15
- <div v-if="!(ai.requiresAuth && !ai.auth)" class="w-72 xl:w-80 flex-shrink-0">
16
- <Sidebar />
57
+ <div
58
+ v-if="!(ai.requiresAuth && !ai.auth) && ai.isSidebarOpen"
59
+ :class="[
60
+ 'transition-transform duration-300 ease-in-out z-50',
61
+ 'w-72 xl:w-80 flex-shrink-0',
62
+ 'lg:relative',
63
+ 'fixed inset-y-0 left-0'
64
+ ]"
65
+ >
66
+ <Sidebar @thread-selected="closeSidebar" @toggle-sidebar="toggleSidebar" />
17
67
  </div>
18
68
 
19
69
  <!-- Main Area -->
20
70
  <div class="flex-1 flex flex-col">
71
+ <!-- Collapsed Sidebar Toggle Button -->
72
+ <div
73
+ v-if="!(ai.requiresAuth && !ai.auth) && !ai.isSidebarOpen"
74
+ class="fixed top-4 left-0"
75
+ >
76
+ <button type="button"
77
+ @click="toggleSidebar"
78
+ class="group p-1 text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
79
+ title="Open sidebar"
80
+ >
81
+ <div class="relative w-5 h-5">
82
+ <!-- Default sidebar icon -->
83
+ <svg class="absolute inset-0 group-hover:hidden" 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">
84
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
85
+ <line x1="9" y1="3" x2="9" y2="21"></line>
86
+ </svg>
87
+ <!-- Hover state: |→ icon -->
88
+ <svg class="absolute inset-0 hidden group-hover:block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="m17.172 11l-4.657-4.657l1.414-1.414L21 12l-7.071 7.071l-1.414-1.414L17.172 13H8v-2zM4 19V5h2v14z"/></svg>
89
+ </div>
90
+ </button>
91
+ </div>
92
+
21
93
  <RouterView />
22
94
  </div>
23
95
  </div>
llms/ui/Brand.mjs CHANGED
@@ -1,14 +1,32 @@
1
1
  export default {
2
2
  template:`
3
- <div class="flex-shrink-0 px-4 py-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 min-h-16 select-none">
3
+ <div class="flex-shrink-0 pl-2 pr-4 py-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 min-h-16 select-none">
4
4
  <div class="flex items-center justify-between">
5
- <button type="button"
6
- @click="$emit('home')"
7
- class="text-lg font-semibold text-gray-900 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400 focus:outline-none transition-colors"
8
- title="Go back to initial state"
9
- >
10
- History
11
- </button>
5
+ <div class="flex items-center space-x-2">
6
+ <button type="button"
7
+ @click="$emit('toggle-sidebar')"
8
+ class="group relative text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 focus:outline-none transition-colors"
9
+ title="Collapse sidebar"
10
+ >
11
+ <div class="relative size-5">
12
+ <!-- Default sidebar icon -->
13
+ <svg class="absolute inset-0 group-hover:hidden" 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">
14
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
15
+ <line x1="9" y1="3" x2="9" y2="21"></line>
16
+ </svg>
17
+ <!-- Hover state: |← icon -->
18
+ <svg class="absolute inset-0 hidden group-hover:block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="m10.071 4.929l1.414 1.414L6.828 11H16v2H6.828l4.657 4.657l-1.414 1.414L3 12zM18.001 19V5h2v14z"/></svg>
19
+ </div>
20
+ </button>
21
+
22
+ <button type="button"
23
+ @click="$emit('home')"
24
+ class="text-lg font-semibold text-gray-900 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400 focus:outline-none transition-colors"
25
+ title="Go back to initial state"
26
+ >
27
+ History
28
+ </button>
29
+ </div>
12
30
 
13
31
  <div class="flex items-center space-x-2">
14
32
  <button type="button"
@@ -30,5 +48,5 @@ export default {
30
48
  </div>
31
49
  </div>
32
50
  `,
33
- emits:['home','new','analytics'],
51
+ emits:['home','new','analytics','toggle-sidebar'],
34
52
  }
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
 
@@ -73,7 +87,7 @@ export default {
73
87
  <div class="flex-1">
74
88
  <div class="relative">
75
89
  <textarea
76
- ref="messageInput"
90
+ ref="refMessage"
77
91
  v-model="messageText"
78
92
  @keydown.enter.exact.prevent="sendMessage"
79
93
  @keydown.enter.shift.exact="addNewLine"
@@ -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
 
@@ -151,6 +168,7 @@ export default {
151
168
  } = threads
152
169
 
153
170
  const fileInput = ref(null)
171
+ const refMessage = ref(null)
154
172
  const showSettings = ref(false)
155
173
  const { applySettings } = chatSettings
156
174
 
@@ -304,6 +322,10 @@ export default {
304
322
  }
305
323
  messageText.value = ''
306
324
 
325
+ // Create AbortController for this request
326
+ const controller = new AbortController()
327
+ chatPrompt.abortController.value = controller
328
+
307
329
  try {
308
330
  let threadId
309
331
 
@@ -434,11 +456,15 @@ export default {
434
456
  }))
435
457
  }
436
458
 
459
+ chatRequest.metadata ??= {}
460
+ chatRequest.metadata.threadId = threadId
461
+
437
462
  // Send to API
438
463
  console.debug('chatRequest', chatRequest)
439
464
  const startTime = Date.now()
440
465
  const response = await ai.post('/v1/chat/completions', {
441
- body: JSON.stringify(chatRequest)
466
+ body: JSON.stringify(chatRequest),
467
+ signal: controller.signal
442
468
  })
443
469
 
444
470
  let result = null
@@ -513,11 +539,29 @@ export default {
513
539
  attachedFiles.value = []
514
540
  // Error will be cleared when user sends next message (no auto-timeout)
515
541
  }
542
+ } catch (error) {
543
+ // Check if the error is due to abort
544
+ if (error.name === 'AbortError') {
545
+ console.log('Request was cancelled by user')
546
+ // Don't show error for cancelled requests
547
+ } else {
548
+ // Re-throw other errors to be handled by outer catch
549
+ throw error
550
+ }
516
551
  } finally {
517
552
  isGenerating.value = false
553
+ chatPrompt.abortController.value = null
554
+ // Restore focus to the textarea
555
+ nextTick(() => {
556
+ refMessage.value?.focus()
557
+ })
518
558
  }
519
559
  }
520
560
 
561
+ const cancelRequest = () => {
562
+ chatPrompt.cancel()
563
+ }
564
+
521
565
  const addNewLine = () => {
522
566
  // Enter key already adds new line
523
567
  //messageText.value += '\n'
@@ -528,6 +572,7 @@ export default {
528
572
  attachedFiles,
529
573
  messageText,
530
574
  fileInput,
575
+ refMessage,
531
576
  showSettings,
532
577
  isDragging,
533
578
  triggerFilePicker,
@@ -538,6 +583,7 @@ export default {
538
583
  onDrop,
539
584
  removeAttachment,
540
585
  sendMessage,
586
+ cancelRequest,
541
587
  addNewLine,
542
588
  }
543
589
  }