llms-py 3.0.2__py3-none-any.whl → 3.0.3__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/db.py CHANGED
@@ -1,10 +1,11 @@
1
1
  import json
2
+ import os
2
3
  import sqlite3
3
4
  import threading
4
5
  from queue import Empty, Queue
5
6
  from threading import Event, Thread
6
7
 
7
- POOL = True
8
+ POOL = os.getenv("LLMS_POOL", "1") == "1"
8
9
 
9
10
 
10
11
  def create_reader_connection(db_path):
@@ -39,7 +40,7 @@ def writer_thread(ctx, db_path, task_queue, stop_event):
39
40
  sql, args, callback = task # Optional callback for results
40
41
 
41
42
  try:
42
- ctx.dbg("SQL>" + ("\n" if "\n" in sql else " ") + sql + ("\n" if args else "") + str(args))
43
+ ctx.dbg("SQL>" + ("\n" if "\n" in sql else " ") + sql + ("\n" if args else " ") + str(args))
43
44
  cursor = conn.execute(sql, args)
44
45
  conn.commit()
45
46
  ctx.dbg(f"lastrowid {cursor.lastrowid}, rowcount {cursor.rowcount}")
@@ -172,7 +173,9 @@ class DbManager:
172
173
 
173
174
  def log_sql(self, sql, parameters=None):
174
175
  if self.ctx.debug:
175
- self.ctx.dbg("SQL>" + ("\n" if "\n" in sql else " ") + sql + ("\n" if parameters else "") + str(parameters))
176
+ self.ctx.dbg(
177
+ "SQL>" + ("\n" if "\n" in sql else " ") + sql + ("\n" if parameters else " ") + str(parameters)
178
+ )
176
179
 
177
180
  def exec(self, connection, sql, parameters=None):
178
181
  self.log_sql(sql, parameters)
@@ -289,17 +292,20 @@ class DbManager:
289
292
  async def insert_async(self, table, columns, info):
290
293
  event = threading.Event()
291
294
 
292
- ret = [None]
295
+ ret = [None, None]
293
296
 
294
297
  def cb(lastrowid, rowcount, error=None):
295
298
  nonlocal ret
296
299
  if error:
297
- raise error
298
- ret[0] = lastrowid
300
+ ret[1] = error
301
+ else:
302
+ ret[0] = lastrowid
299
303
  event.set()
300
304
 
301
305
  self.insert(table, columns, info, cb)
302
306
  event.wait()
307
+ if ret[1]:
308
+ raise ret[1]
303
309
  return ret[0]
304
310
 
305
311
  def update(self, table, columns, info, callback=None):
@@ -323,17 +329,20 @@ class DbManager:
323
329
  async def update_async(self, table, columns, info):
324
330
  event = threading.Event()
325
331
 
326
- ret = [None]
332
+ ret = [None, None]
327
333
 
328
334
  def cb(lastrowid, rowcount, error=None):
329
335
  nonlocal ret
330
336
  if error:
331
- raise error
332
- ret[0] = rowcount
337
+ ret[1] = error
338
+ else:
339
+ ret[0] = rowcount
333
340
  event.set()
334
341
 
335
342
  self.update(table, columns, info, cb)
336
343
  event.wait()
344
+ if ret[1]:
345
+ raise ret[1]
337
346
  return ret[0]
338
347
 
339
348
  def close(self):
@@ -38,6 +38,7 @@ def install(ctx):
38
38
  "modelInfo",
39
39
  "modalities",
40
40
  "messages",
41
+ "tools",
41
42
  "args",
42
43
  "cost",
43
44
  "inputTokens",
@@ -116,6 +116,9 @@ function replaceThread(thread) {
116
116
  if (currentThread.value?.id === thread.id) {
117
117
  currentThread.value = thread
118
118
  }
119
+ if (thread.completedAt || thread.error) {
120
+ threadDetails.value[thread.id] = thread
121
+ }
119
122
  startWatchingThread()
120
123
  return thread
121
124
  }
llms/main.py CHANGED
@@ -41,7 +41,7 @@ try:
41
41
  except ImportError:
42
42
  HAS_PIL = False
43
43
 
44
- VERSION = "3.0.2"
44
+ VERSION = "3.0.3"
45
45
  _ROOT = None
46
46
  DEBUG = os.getenv("DEBUG") == "1"
47
47
  MOCK = os.getenv("MOCK") == "1"
@@ -1288,6 +1288,8 @@ def to_error_message(e):
1288
1288
  # check if has 'message' attribute
1289
1289
  if hasattr(e, "message"):
1290
1290
  return e.message
1291
+ if hasattr(e, "status"):
1292
+ return str(e.status)
1291
1293
  return str(e)
1292
1294
 
1293
1295
 
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: '3.0.2',
9
+ version: '3.0.3',
10
10
  base,
11
11
  prefsKey,
12
12
  welcome: 'Welcome to llms.py',
llms/ui/app.css CHANGED
@@ -18,6 +18,12 @@
18
18
  --color-red-700: oklch(50.5% 0.213 27.518);
19
19
  --color-red-800: oklch(44.4% 0.177 26.899);
20
20
  --color-red-900: oklch(39.6% 0.141 25.723);
21
+ --color-orange-100: oklch(95.4% 0.038 75.164);
22
+ --color-orange-200: oklch(90.1% 0.076 70.697);
23
+ --color-orange-400: oklch(75% 0.183 55.934);
24
+ --color-orange-600: oklch(64.6% 0.222 41.116);
25
+ --color-orange-800: oklch(47% 0.157 37.304);
26
+ --color-orange-900: oklch(40.8% 0.123 38.172);
21
27
  --color-yellow-50: oklch(98.7% 0.026 102.212);
22
28
  --color-yellow-100: oklch(97.3% 0.071 103.193);
23
29
  --color-yellow-200: oklch(94.5% 0.129 101.54);
@@ -1103,6 +1109,9 @@
1103
1109
  .cursor-default {
1104
1110
  cursor: default;
1105
1111
  }
1112
+ .cursor-help {
1113
+ cursor: help;
1114
+ }
1106
1115
  .cursor-not-allowed {
1107
1116
  cursor: not-allowed;
1108
1117
  }
@@ -1668,6 +1677,9 @@
1668
1677
  .bg-indigo-700 {
1669
1678
  background-color: var(--color-indigo-700);
1670
1679
  }
1680
+ .bg-orange-100 {
1681
+ background-color: var(--color-orange-100);
1682
+ }
1671
1683
  .bg-purple-100 {
1672
1684
  background-color: var(--color-purple-100);
1673
1685
  }
@@ -1743,9 +1755,6 @@
1743
1755
  .bg-yellow-50 {
1744
1756
  background-color: var(--color-yellow-50);
1745
1757
  }
1746
- .bg-yellow-100 {
1747
- background-color: var(--color-yellow-100);
1748
- }
1749
1758
  .bg-yellow-400 {
1750
1759
  background-color: var(--color-yellow-400);
1751
1760
  }
@@ -2313,6 +2322,12 @@
2313
2322
  .text-indigo-700 {
2314
2323
  color: var(--color-indigo-700);
2315
2324
  }
2325
+ .text-orange-600 {
2326
+ color: var(--color-orange-600);
2327
+ }
2328
+ .text-orange-800 {
2329
+ color: var(--color-orange-800);
2330
+ }
2316
2331
  .text-purple-600 {
2317
2332
  color: var(--color-purple-600);
2318
2333
  }
@@ -2385,9 +2400,6 @@
2385
2400
  .text-yellow-700 {
2386
2401
  color: var(--color-yellow-700);
2387
2402
  }
2388
- .text-yellow-800 {
2389
- color: var(--color-yellow-800);
2390
- }
2391
2403
  .capitalize {
2392
2404
  text-transform: capitalize;
2393
2405
  }
@@ -4830,6 +4842,11 @@
4830
4842
  background-color: var(--color-indigo-900);
4831
4843
  }
4832
4844
  }
4845
+ .dark\:bg-orange-900 {
4846
+ &:where(.dark, .dark *) {
4847
+ background-color: var(--color-orange-900);
4848
+ }
4849
+ }
4833
4850
  .dark\:bg-purple-600 {
4834
4851
  &:where(.dark, .dark *) {
4835
4852
  background-color: var(--color-purple-600);
@@ -4920,23 +4937,11 @@
4920
4937
  background-color: var(--color-yellow-200);
4921
4938
  }
4922
4939
  }
4923
- .dark\:bg-yellow-900 {
4924
- &:where(.dark, .dark *) {
4925
- background-color: var(--color-yellow-900);
4926
- }
4927
- }
4928
4940
  .dark\:fill-gray-300 {
4929
4941
  &:where(.dark, .dark *) {
4930
4942
  fill: var(--color-gray-300);
4931
4943
  }
4932
4944
  }
4933
- .dark\:dark\:text-gray-200 {
4934
- &:where(.dark, .dark *) {
4935
- &:where(.dark, .dark *) {
4936
- color: var(--color-gray-200);
4937
- }
4938
- }
4939
- }
4940
4945
  .dark\:text-black {
4941
4946
  &:where(.dark, .dark *) {
4942
4947
  color: var(--color-black);
@@ -5073,6 +5078,16 @@
5073
5078
  color: var(--color-indigo-500);
5074
5079
  }
5075
5080
  }
5081
+ .dark\:text-orange-200 {
5082
+ &:where(.dark, .dark *) {
5083
+ color: var(--color-orange-200);
5084
+ }
5085
+ }
5086
+ .dark\:text-orange-400 {
5087
+ &:where(.dark, .dark *) {
5088
+ color: var(--color-orange-400);
5089
+ }
5090
+ }
5076
5091
  .dark\:text-purple-300 {
5077
5092
  &:where(.dark, .dark *) {
5078
5093
  color: var(--color-purple-300);
@@ -5118,11 +5133,6 @@
5118
5133
  color: var(--color-white);
5119
5134
  }
5120
5135
  }
5121
- .dark\:text-yellow-200 {
5122
- &:where(.dark, .dark *) {
5123
- color: var(--color-yellow-200);
5124
- }
5125
- }
5126
5136
  .dark\:placeholder-gray-400 {
5127
5137
  &:where(.dark, .dark *) {
5128
5138
  &::placeholder {
llms/ui/ctx.mjs CHANGED
@@ -30,6 +30,7 @@ export class ExtensionScope {
30
30
  return this.ctx.ai.get(combinePaths(this.baseUrl, url), options)
31
31
  }
32
32
  delete(url, options) {
33
+ this.ctx.clearError()
33
34
  return this.ctx.ai.get(combinePaths(this.baseUrl, url), {
34
35
  ...options,
35
36
  method: 'DELETE'
@@ -39,41 +40,49 @@ export class ExtensionScope {
39
40
  return this.ctx.ai.getJson(combinePaths(this.baseUrl, url), options)
40
41
  }
41
42
  async deleteJson(url, options) {
43
+ this.ctx.clearError()
42
44
  return this.ctx.ai.getJson(combinePaths(this.baseUrl, url), {
43
45
  ...options,
44
46
  method: 'DELETE'
45
47
  })
46
48
  }
47
49
  post(url, options) {
50
+ this.ctx.clearError()
48
51
  return this.ctx.ai.post(combinePaths(this.baseUrl, url), options)
49
52
  }
50
53
  put(url, options) {
54
+ this.ctx.clearError()
51
55
  return this.ctx.ai.post(combinePaths(this.baseUrl, url), {
52
56
  ...options,
53
57
  method: 'PUT'
54
58
  })
55
59
  }
56
60
  patch(url, options) {
61
+ this.ctx.clearError()
57
62
  return this.ctx.ai.post(combinePaths(this.baseUrl, url), {
58
63
  ...options,
59
64
  method: 'PATCH'
60
65
  })
61
66
  }
62
67
  async postForm(url, options) {
68
+ this.ctx.clearError()
63
69
  return await this.ctx.ai.postForm(combinePaths(this.baseUrl, url), options)
64
70
  }
65
71
  async postJson(url, body) {
72
+ this.ctx.clearError()
66
73
  return this.ctx.ai.postJson(combinePaths(this.baseUrl, url), {
67
74
  body: body instanceof FormData ? body : JSON.stringify(body)
68
75
  })
69
76
  }
70
77
  async putJson(url, body) {
78
+ this.ctx.clearError()
71
79
  return this.ctx.ai.postJson(combinePaths(this.baseUrl, url), {
72
80
  method: 'PUT',
73
81
  body: body instanceof FormData ? body : JSON.stringify(body)
74
82
  })
75
83
  }
76
84
  async patchJson(url, body) {
85
+ this.ctx.clearError()
77
86
  return this.ctx.ai.postJson(combinePaths(this.baseUrl, url), {
78
87
  method: 'PATCH',
79
88
  body: body instanceof FormData ? body : JSON.stringify(body)
@@ -136,6 +145,7 @@ export class AppContext {
136
145
  this.chatErrorFilters = []
137
146
  this.createThreadFilters = []
138
147
  this.updateThreadFilters = []
148
+ this.threadHeaderComponents = {}
139
149
  this.threadFooterComponents = {}
140
150
  this.top = {}
141
151
  this.left = {}
@@ -297,6 +307,9 @@ export class AppContext {
297
307
  this.toggleLayout('left', toggle)
298
308
  return toggle
299
309
  }
310
+ setThreadHeaders(components) {
311
+ Object.assign(this.threadHeaderComponents, components)
312
+ }
300
313
  setThreadFooters(components) {
301
314
  Object.assign(this.threadFooterComponents, components)
302
315
  }
@@ -70,6 +70,7 @@ export default {
70
70
  <!-- Messages Area -->
71
71
  <div class="flex-1 overflow-y-auto" ref="messagesContainer">
72
72
  <div class="mx-auto max-w-6xl px-4 py-6">
73
+
73
74
  <div v-if="!$ai.hasAccess">
74
75
  <OAuthSignIn v-if="$ai.authType === 'oauth'" @done="$ai.signIn($event)" />
75
76
  <SignIn v-else @done="$ai.signIn($event)" />
@@ -81,291 +82,287 @@ export default {
81
82
  </div>
82
83
 
83
84
  <!-- Messages -->
84
- <div v-else-if="currentThread?.messages?.length" class="space-y-2">
85
- <div v-if="currentThread?.messages.length && currentThread?.model" class="flex items-center justify-center select-none">
86
- <span @click="$chat.setSelectedModel({ name: currentThread.model})"
87
- class="flex items-center cursor-pointer px-1.5 py-0.5 text-xs rounded text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100 transition-colors border hover:border-gray-300 dark:hover:border-gray-700">
88
- <ProviderIcon class="size-4 mr-1" :provider="$chat.getProviderForModel(currentThread.model)" />
89
- {{currentThread.model}}
90
- </span>
91
- </div>
92
- <div
93
- v-for="message in currentThread.messages.filter(x => x.role !== 'system')"
94
- :key="message.id"
95
- v-show="!(message.role === 'tool' && isToolLinked(message))"
96
- class="flex items-start space-x-3 group"
97
- :class="message.role === 'user' ? 'flex-row-reverse space-x-reverse' : ''"
98
- >
99
- <!-- Avatar outside the bubble -->
100
- <div class="flex-shrink-0 flex flex-col justify-center">
101
- <div class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium"
102
- :class="message.role === 'user'
103
- ? 'bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 border border-blue-200 dark:border-blue-700'
104
- : message.role === 'tool'
105
- ? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 border border-purple-200 dark:border-purple-800'
106
- : 'bg-gray-600 dark:bg-gray-500 text-white'"
107
- >
108
- <span v-if="message.role === 'user'">U</span>
109
- <svg v-else-if="message.role === 'tool'" class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
110
- <path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path>
111
- </svg>
112
- <span v-else>AI</span>
113
- </div>
114
-
115
- <!-- Delete button (shown on hover) -->
116
- <button type="button" @click.stop="$threads.deleteMessageFromThread(currentThread.id, message.id)"
117
- class="mx-auto opacity-0 group-hover:opacity-100 mt-2 rounded 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 transition-all"
118
- title="Delete message">
119
- <svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
120
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
121
- </svg>
122
- </button>
123
- </div>
124
-
125
- <!-- Message bubble -->
85
+ <div v-else-if="currentThread">
86
+ <ThreadHeader v-if="currentThread" :thread="currentThread" class="mb-2" />
87
+ <div class="space-y-2" v-if="currentThread?.messages?.length">
126
88
  <div
127
- class="message rounded-lg px-4 py-3 relative group"
128
- :class="message.role === 'user'
129
- ? 'bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 border border-blue-200 dark:border-blue-700'
130
- : 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 border border-gray-200 dark:border-gray-700'"
89
+ v-for="message in currentThread.messages.filter(x => x.role !== 'system')"
90
+ :key="message.id"
91
+ v-show="!(message.role === 'tool' && isToolLinked(message))"
92
+ class="flex items-start space-x-3 group"
93
+ :class="message.role === 'user' ? 'flex-row-reverse space-x-reverse' : ''"
131
94
  >
132
- <!-- Copy button in top right corner -->
133
- <button
134
- type="button"
135
- @click="copyMessageContent(message)"
136
- class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 focus:outline-none focus:ring-0"
137
- :class="message.role === 'user' ? 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'"
138
- title="Copy message content"
139
- >
140
- <svg v-if="copying === message" class="size-4 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
141
- <svg v-else class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
142
- <rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>
143
- <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
144
- </svg>
145
- </button>
95
+ <!-- Avatar outside the bubble -->
96
+ <div class="flex-shrink-0 flex flex-col justify-center">
97
+ <div class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium"
98
+ :class="message.role === 'user'
99
+ ? 'bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 border border-blue-200 dark:border-blue-700'
100
+ : message.role === 'tool'
101
+ ? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 border border-purple-200 dark:border-purple-800'
102
+ : 'bg-gray-600 dark:bg-gray-500 text-white'"
103
+ >
104
+ <span v-if="message.role === 'user'">U</span>
105
+ <svg v-else-if="message.role === 'tool'" class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
106
+ <path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path>
107
+ </svg>
108
+ <span v-else>AI</span>
109
+ </div>
110
+
111
+ <!-- Delete button (shown on hover) -->
112
+ <button type="button" @click.stop="$threads.deleteMessageFromThread(currentThread.id, message.id)"
113
+ class="mx-auto opacity-0 group-hover:opacity-100 mt-2 rounded 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 transition-all"
114
+ title="Delete message">
115
+ <svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
116
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
117
+ </svg>
118
+ </button>
119
+ </div>
146
120
 
121
+ <!-- Message bubble -->
147
122
  <div
148
- v-if="message.role === 'assistant'"
149
- v-html="$fmt.markdown(message.content)"
150
- class="prose prose-sm max-w-none dark:prose-invert"
151
- ></div>
152
-
153
- <!-- Collapsible reasoning section -->
154
- <MessageReasoning v-if="message.role === 'assistant' && (message.reasoning || message.thinking || message.reasoning_content)"
155
- :reasoning="message.reasoning || message.thinking || message.reasoning_content" :message="message" />
156
-
157
- <!-- Tool Calls & Outputs -->
158
- <div v-if="message.tool_calls && message.tool_calls.length > 0" class="mb-3 space-y-4">
159
- <div v-for="(tool, i) in message.tool_calls" :key="i" class="rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden">
160
- <!-- Tool Call Header -->
161
- <div class="px-3 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between bg-gray-50/30 dark:bg-gray-800 space-x-4">
162
- <div class="flex items-center gap-2">
163
- <svg class="size-3.5 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg>
164
- <span class="font-mono text-xs font-bold text-gray-700 dark:text-gray-300">{{ tool.function.name }}</span>
123
+ class="message rounded-lg px-4 py-3 relative group"
124
+ :class="message.role === 'user'
125
+ ? 'bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 border border-blue-200 dark:border-blue-700'
126
+ : 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 border border-gray-200 dark:border-gray-700'"
127
+ >
128
+ <!-- Copy button in top right corner -->
129
+ <button
130
+ type="button"
131
+ @click="copyMessageContent(message)"
132
+ class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 focus:outline-none focus:ring-0"
133
+ :class="message.role === 'user' ? 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'"
134
+ title="Copy message content"
135
+ >
136
+ <svg v-if="copying === message" class="size-4 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
137
+ <svg v-else class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
138
+ <rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>
139
+ <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
140
+ </svg>
141
+ </button>
142
+
143
+ <div
144
+ v-if="message.role === 'assistant'"
145
+ v-html="$fmt.markdown(message.content)"
146
+ class="prose prose-sm max-w-none dark:prose-invert"
147
+ ></div>
148
+
149
+ <!-- Collapsible reasoning section -->
150
+ <MessageReasoning v-if="message.role === 'assistant' && (message.reasoning || message.thinking || message.reasoning_content)"
151
+ :reasoning="message.reasoning || message.thinking || message.reasoning_content" :message="message" />
152
+
153
+ <!-- Tool Calls & Outputs -->
154
+ <div v-if="message.tool_calls && message.tool_calls.length > 0" class="mb-3 space-y-4">
155
+ <div v-for="(tool, i) in message.tool_calls" :key="i" class="rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden">
156
+ <!-- Tool Call Header -->
157
+ <div class="px-3 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between bg-gray-50/30 dark:bg-gray-800 space-x-4">
158
+ <div class="flex items-center gap-2">
159
+ <svg class="size-3.5 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg>
160
+ <span class="font-mono text-xs font-bold text-gray-700 dark:text-gray-300">{{ tool.function.name }}</span>
161
+ </div>
162
+ <span class="text-[10px] uppercase tracking-wider text-gray-400 font-medium">Tool Call</span>
163
+ </div>
164
+
165
+ <!-- Arguments -->
166
+ <div v-if="tool.function.arguments && tool.function.arguments != '{}'" class="not-prose px-3 py-2">
167
+ <HtmlFormat v-if="hasJsonStructure(tool.function.arguments)" :value="tryParseJson(tool.function.arguments)" :classes="customHtmlClasses" />
168
+ <pre v-else class="tool-arguments">{{ tool.function.arguments }}</pre>
165
169
  </div>
166
- <span class="text-[10px] uppercase tracking-wider text-gray-400 font-medium">Tool Call</span>
167
- </div>
168
-
169
- <!-- Arguments -->
170
- <div v-if="tool.function.arguments && tool.function.arguments != '{}'" class="not-prose px-3 py-2">
171
- <HtmlFormat v-if="hasJsonStructure(tool.function.arguments)" :value="tryParseJson(tool.function.arguments)" :classes="customHtmlClasses" />
172
- <pre v-else class="tool-arguments">{{ tool.function.arguments }}</pre>
173
- </div>
174
170
 
175
- <!-- Tool Output (Nested) -->
176
- <div v-if="getToolOutput(tool.id)" class="border-t border-gray-200 dark:border-gray-700">
177
- <div class="px-3 py-1.5 flex justify-between items-center border-b border-gray-200 dark:border-gray-800 bg-gray-50/30 dark:bg-gray-800">
178
- <div class="flex items-center gap-2 ">
179
- <svg class="size-3.5 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
180
- <span class="text-[10px] uppercase tracking-wider text-gray-400 font-medium">Output</span>
181
- </div>
182
- <div v-if="hasJsonStructure(getToolOutput(tool.id).content)" class="flex items-center gap-2 text-[10px] uppercase tracking-wider font-medium select-none">
183
- <span @click="setPrefs({ toolFormat: 'text' })"
184
- class="cursor-pointer transition-colors"
185
- :class="prefs.toolFormat !== 'preview' ? 'text-gray-600 dark:text-gray-300' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'">
186
- text
187
- </span>
188
- <span class="text-gray-300 dark:text-gray-700">|</span>
189
- <span @click="setPrefs({ toolFormat: 'preview' })"
190
- class="cursor-pointer transition-colors"
191
- :class="prefs.toolFormat == 'preview' ? 'text-gray-600 dark:text-gray-300' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'">
192
- preview
193
- </span>
171
+ <!-- Tool Output (Nested) -->
172
+ <div v-if="getToolOutput(tool.id)" class="border-t border-gray-200 dark:border-gray-700">
173
+ <div class="px-3 py-1.5 flex justify-between items-center border-b border-gray-200 dark:border-gray-800 bg-gray-50/30 dark:bg-gray-800">
174
+ <div class="flex items-center gap-2 ">
175
+ <svg class="size-3.5 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
176
+ <span class="text-[10px] uppercase tracking-wider text-gray-400 font-medium">Output</span>
177
+ </div>
178
+ <div v-if="hasJsonStructure(getToolOutput(tool.id).content)" class="flex items-center gap-2 text-[10px] uppercase tracking-wider font-medium select-none">
179
+ <span @click="setPrefs({ toolFormat: 'text' })"
180
+ class="cursor-pointer transition-colors"
181
+ :class="prefs.toolFormat !== 'preview' ? 'text-gray-600 dark:text-gray-300' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'">
182
+ text
183
+ </span>
184
+ <span class="text-gray-300 dark:text-gray-700">|</span>
185
+ <span @click="setPrefs({ toolFormat: 'preview' })"
186
+ class="cursor-pointer transition-colors"
187
+ :class="prefs.toolFormat == 'preview' ? 'text-gray-600 dark:text-gray-300' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'">
188
+ preview
189
+ </span>
190
+ </div>
194
191
  </div>
195
- </div>
196
- <div class="not-prose px-3 py-2">
197
- <pre v-if="prefs.toolFormat !== 'preview' || !hasJsonStructure(getToolOutput(tool.id).content)" class="tool-output">{{ getToolOutput(tool.id).content }}</pre>
198
- <div v-else class="text-xs">
199
- <HtmlFormat v-if="tryParseJson(getToolOutput(tool.id).content)" :value="tryParseJson(getToolOutput(tool.id).content)" :classes="customHtmlClasses" />
200
- <div v-else class="text-gray-500 italic p-2">Invalid JSON content</div>
192
+ <div class="not-prose px-3 py-2">
193
+ <pre v-if="prefs.toolFormat !== 'preview' || !hasJsonStructure(getToolOutput(tool.id).content)" class="tool-output">{{ getToolOutput(tool.id).content }}</pre>
194
+ <div v-else class="text-xs">
195
+ <HtmlFormat v-if="tryParseJson(getToolOutput(tool.id).content)" :value="tryParseJson(getToolOutput(tool.id).content)" :classes="customHtmlClasses" />
196
+ <div v-else class="text-gray-500 italic p-2">Invalid JSON content</div>
197
+ </div>
201
198
  </div>
202
199
  </div>
203
200
  </div>
204
201
  </div>
205
- </div>
206
202
 
207
- <!-- Tool Output (Orphaned) -->
208
- <div v-if="message.role === 'tool' && !isToolLinked(message)" class="text-sm">
209
- <div class="flex items-center gap-2 mb-1 opacity-70">
210
- <div class="flex items-center text-xs font-mono font-medium text-gray-500 uppercase tracking-wider">
211
- <svg class="size-3 mr-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
212
- Tool Output
213
- </div>
214
- <div v-if="message.name" class="text-xs font-mono bg-gray-200 dark:bg-gray-700 px-1.5 rounded text-gray-700 dark:text-gray-300">
215
- {{ message.name }}
203
+ <!-- Tool Output (Orphaned) -->
204
+ <div v-if="message.role === 'tool' && !isToolLinked(message)" class="text-sm">
205
+ <div class="flex items-center gap-2 mb-1 opacity-70">
206
+ <div class="flex items-center text-xs font-mono font-medium text-gray-500 uppercase tracking-wider">
207
+ <svg class="size-3 mr-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
208
+ Tool Output
209
+ </div>
210
+ <div v-if="message.name" class="text-xs font-mono bg-gray-200 dark:bg-gray-700 px-1.5 rounded text-gray-700 dark:text-gray-300">
211
+ {{ message.name }}
212
+ </div>
213
+ <div v-if="message.tool_call_id" class="text-[10px] font-mono text-gray-400">
214
+ {{ message.tool_call_id.slice(0,8) }}
215
+ </div>
216
216
  </div>
217
- <div v-if="message.tool_call_id" class="text-[10px] font-mono text-gray-400">
218
- {{ message.tool_call_id.slice(0,8) }}
217
+ <div class="not-prose bg-white dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-800 p-2 overflow-x-auto">
218
+ <pre class="tool-output">{{ message.content }}</pre>
219
219
  </div>
220
220
  </div>
221
- <div class="not-prose bg-white dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-800 p-2 overflow-x-auto">
222
- <pre class="tool-output">{{ message.content }}</pre>
223
- </div>
224
- </div>
225
-
226
- <!-- Assistant Images -->
227
- <div v-if="message.images && message.images.length > 0" class="mt-2 flex flex-wrap gap-2">
228
- <template v-for="(img, i) in message.images" :key="i">
229
- <div v-if="img.type === 'image_url'" class="group relative cursor-pointer" @click="openLightbox(resolveUrl(img.image_url.url))">
230
- <img :src="resolveUrl(img.image_url.url)" class="max-w-[400px] max-h-96 rounded-lg border border-gray-200 dark:border-gray-700 object-contain bg-gray-50 dark:bg-gray-900 shadow-sm transition-transform hover:scale-[1.02]" />
231
- </div>
232
- </template>
233
- </div>
234
-
235
- <!-- Assistant Audios -->
236
- <div v-if="message.audios && message.audios.length > 0" class="mt-2 flex flex-wrap gap-2">
237
- <template v-for="(audio, i) in message.audios" :key="i">
238
- <div v-if="audio.type === 'audio_url'" class="flex items-center gap-2 p-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
239
- <audio controls :src="resolveUrl(audio.audio_url.url)" class="h-8 w-64"></audio>
240
- </div>
241
- </template>
242
- </div>
243
221
 
244
- <!-- User Message with separate attachments -->
245
- <div v-else-if="message.role !== 'assistant' && message.role !== 'tool'">
246
- <div v-html="$fmt.markdown(message.content)" class="prose prose-sm max-w-none dark:prose-invert break-words"></div>
247
-
248
- <!-- Attachments Grid -->
249
- <div v-if="hasAttachments(message)" class="mt-2 flex flex-wrap gap-2">
250
- <template v-for="(part, i) in getAttachments(message)" :key="i">
251
- <!-- Image -->
252
- <div v-if="part.type === 'image_url'" class="group relative cursor-pointer" @click="openLightbox(part.image_url.url)">
253
- <img :src="part.image_url.url" class="max-w-[400px] max-h-96 rounded-lg border border-gray-200 dark:border-gray-700 object-contain bg-gray-50 dark:bg-gray-900 shadow-sm transition-transform hover:scale-[1.02]" />
222
+ <!-- Assistant Images -->
223
+ <div v-if="message.images && message.images.length > 0" class="mt-2 flex flex-wrap gap-2">
224
+ <template v-for="(img, i) in message.images" :key="i">
225
+ <div v-if="img.type === 'image_url'" class="group relative cursor-pointer" @click="openLightbox(resolveUrl(img.image_url.url))">
226
+ <img :src="resolveUrl(img.image_url.url)" class="max-w-[400px] max-h-96 rounded-lg border border-gray-200 dark:border-gray-700 object-contain bg-gray-50 dark:bg-gray-900 shadow-sm transition-transform hover:scale-[1.02]" />
254
227
  </div>
255
- <!-- Audio -->
256
- <div v-else-if="part.type === 'input_audio'" class="flex items-center gap-2 p-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
257
- <svg class="w-5 h-5 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle></svg>
258
- <audio controls :src="part.input_audio.data" class="h-8 w-48"></audio>
228
+ </template>
229
+ </div>
230
+
231
+ <!-- Assistant Audios -->
232
+ <div v-if="message.audios && message.audios.length > 0" class="mt-2 flex flex-wrap gap-2">
233
+ <template v-for="(audio, i) in message.audios" :key="i">
234
+ <div v-if="audio.type === 'audio_url'" class="flex items-center gap-2 p-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
235
+ <audio controls :src="resolveUrl(audio.audio_url.url)" class="h-8 w-64"></audio>
259
236
  </div>
260
- <!-- File -->
261
- <a v-else-if="part.type === 'file'" :href="part.file.file_data" target="_blank"
262
- class="flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-sm text-blue-600 dark:text-blue-400 hover:underline">
263
- <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>
264
- <span class="max-w-xs truncate">{{ part.file.filename || 'Attachment' }}</span>
265
- </a>
266
237
  </template>
267
238
  </div>
239
+
240
+ <!-- User Message with separate attachments -->
241
+ <div v-else-if="message.role !== 'assistant' && message.role !== 'tool'">
242
+ <div v-html="$fmt.markdown(message.content)" class="prose prose-sm max-w-none dark:prose-invert break-words"></div>
243
+
244
+ <!-- Attachments Grid -->
245
+ <div v-if="hasAttachments(message)" class="mt-2 flex flex-wrap gap-2">
246
+ <template v-for="(part, i) in getAttachments(message)" :key="i">
247
+ <!-- Image -->
248
+ <div v-if="part.type === 'image_url'" class="group relative cursor-pointer" @click="openLightbox(part.image_url.url)">
249
+ <img :src="part.image_url.url" class="max-w-[400px] max-h-96 rounded-lg border border-gray-200 dark:border-gray-700 object-contain bg-gray-50 dark:bg-gray-900 shadow-sm transition-transform hover:scale-[1.02]" />
250
+ </div>
251
+ <!-- Audio -->
252
+ <div v-else-if="part.type === 'input_audio'" class="flex items-center gap-2 p-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
253
+ <svg class="w-5 h-5 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle></svg>
254
+ <audio controls :src="part.input_audio.data" class="h-8 w-48"></audio>
255
+ </div>
256
+ <!-- File -->
257
+ <a v-else-if="part.type === 'file'" :href="part.file.file_data" target="_blank"
258
+ class="flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-sm text-blue-600 dark:text-blue-400 hover:underline">
259
+ <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>
260
+ <span class="max-w-xs truncate">{{ part.file.filename || 'Attachment' }}</span>
261
+ </a>
262
+ </template>
263
+ </div>
264
+ </div>
265
+
266
+ <MessageUsage :message="message" :usage="getMessageUsage(message)" />
268
267
  </div>
269
268
 
270
- <MessageUsage :message="message" :usage="getMessageUsage(message)" />
269
+ <!-- Edit and Redo buttons (shown on hover for user messages, outside bubble) -->
270
+ <div v-if="message.role === 'user'" class="flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity mt-1">
271
+ <button type="button" @click.stop="editMessage(message)"
272
+ class="whitespace-nowrap text-xs px-2 py-1 rounded text-gray-400 dark:text-gray-500 hover:text-green-600 dark:hover:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/30 transition-all"
273
+ title="Edit message">
274
+ <svg class="size-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
275
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
276
+ </svg>
277
+ Edit
278
+ </button>
279
+ <button type="button" @click.stop="redoMessage(message)"
280
+ class="whitespace-nowrap text-xs px-2 py-1 rounded text-gray-400 dark:text-gray-500 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30 transition-all"
281
+ title="Redo message (clears all responses after this message and re-runs it)">
282
+ <svg class="size-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
283
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
284
+ </svg>
285
+ Redo
286
+ </button>
287
+ </div>
271
288
  </div>
272
289
 
273
- <!-- Edit and Redo buttons (shown on hover for user messages, outside bubble) -->
274
- <div v-if="message.role === 'user'" class="flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity mt-1">
275
- <button type="button" @click.stop="editMessage(message)"
276
- class="whitespace-nowrap text-xs px-2 py-1 rounded text-gray-400 dark:text-gray-500 hover:text-green-600 dark:hover:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/30 transition-all"
277
- title="Edit message">
278
- <svg class="size-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
279
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
280
- </svg>
281
- Edit
282
- </button>
283
- <button type="button" @click.stop="redoMessage(message)"
284
- class="whitespace-nowrap text-xs px-2 py-1 rounded text-gray-400 dark:text-gray-500 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30 transition-all"
285
- title="Redo message (clears all responses after this message and re-runs it)">
286
- <svg class="size-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
287
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
288
- </svg>
289
- Redo
290
- </button>
290
+ <div v-if="currentThread.stats && currentThread.stats.outputTokens" class="text-center text-gray-500 dark:text-gray-400 text-sm">
291
+ <span :title="$fmt.statsTitle(currentThread.stats)">
292
+ {{ currentThread.stats.cost ? $fmt.costLong(currentThread.stats.cost) + ' for ' : '' }} {{ $fmt.humanifyNumber(currentThread.stats.inputTokens) }} → {{ $fmt.humanifyNumber(currentThread.stats.outputTokens) }} tokens over {{ currentThread.stats.requests }} request{{currentThread.stats.requests===1?'':'s'}} in {{ $fmt.humanifyMs(currentThread.stats.duration) }}
293
+ </span>
291
294
  </div>
292
- </div>
293
-
294
- <div v-if="currentThread.stats && currentThread.stats.outputTokens" class="text-center text-gray-500 dark:text-gray-400 text-sm">
295
- <span :title="$fmt.statsTitle(currentThread.stats)">
296
- {{ currentThread.stats.cost ? $fmt.costLong(currentThread.stats.cost) + ' for ' : '' }} {{ $fmt.humanifyNumber(currentThread.stats.inputTokens) }} → {{ $fmt.humanifyNumber(currentThread.stats.outputTokens) }} tokens over {{ currentThread.stats.requests }} request{{currentThread.stats.requests===1?'':'s'}} in {{ $fmt.humanifyMs(currentThread.stats.duration) }}
297
- </span>
298
- </div>
299
295
 
300
- <!-- Loading indicator -->
301
- <div v-if="$threads.watchingThread" class="flex items-start space-x-3 group">
302
- <!-- Avatar outside the bubble -->
303
- <div class="flex-shrink-0">
304
- <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">
305
- AI
296
+ <!-- Loading indicator -->
297
+ <div v-if="$threads.watchingThread" class="flex items-start space-x-3 group">
298
+ <!-- Avatar outside the bubble -->
299
+ <div class="flex-shrink-0">
300
+ <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">
301
+ AI
302
+ </div>
306
303
  </div>
307
- </div>
308
304
 
309
- <!-- Loading bubble -->
310
- <div class="rounded-lg px-4 py-3 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
311
- <div class="flex space-x-1">
312
- <div class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce"></div>
313
- <div class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style="animation-delay: 0.1s"></div>
314
- <div class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
305
+ <!-- Loading bubble -->
306
+ <div class="rounded-lg px-4 py-3 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
307
+ <div class="flex space-x-1">
308
+ <div class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce"></div>
309
+ <div class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style="animation-delay: 0.1s"></div>
310
+ <div class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
311
+ </div>
315
312
  </div>
316
- </div>
317
313
 
318
- <!-- Cancel button -->
319
- <button type="button" @click="$threads.cancelThread()"
320
- 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"
321
- title="Cancel request">
322
- cancel
323
- </button>
324
- </div>
314
+ <!-- Cancel button -->
315
+ <button type="button" @click="$threads.cancelThread()"
316
+ 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"
317
+ title="Cancel request">
318
+ cancel
319
+ </button>
320
+ </div>
325
321
 
326
- <!-- Thread error message bubble -->
327
- <div v-if="currentThread?.error" class="mt-8 flex items-center space-x-3">
328
- <!-- Avatar outside the bubble -->
329
- <div class="flex-shrink-0">
330
- <div class="size-8 rounded-full bg-red-600 dark:bg-red-500 text-white flex items-center justify-center text-lg font-bold">
331
- !
322
+ <!-- Thread error message bubble -->
323
+ <div v-if="currentThread?.error" class="mt-8 flex items-center space-x-3">
324
+ <!-- Avatar outside the bubble -->
325
+ <div class="flex-shrink-0">
326
+ <div class="size-8 rounded-full bg-red-600 dark:bg-red-500 text-white flex items-center justify-center text-lg font-bold">
327
+ !
328
+ </div>
332
329
  </div>
333
- </div>
334
- <!-- Error bubble -->
335
- <div class="max-w-[85%] rounded-lg px-3 py-1 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200 shadow-sm">
336
- <div class="flex items-start space-x-2">
337
- <div class="flex-1 min-w-0">
338
- <div v-if="currentThread.error" class="text-base mb-1">{{ currentThread.error }}</div>
330
+ <!-- Error bubble -->
331
+ <div class="max-w-[85%] rounded-lg px-3 py-1 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200 shadow-sm">
332
+ <div class="flex items-start space-x-2">
333
+ <div class="flex-1 min-w-0">
334
+ <div v-if="currentThread.error" class="text-base mb-1">{{ currentThread.error }}</div>
335
+ </div>
339
336
  </div>
340
337
  </div>
341
338
  </div>
342
- </div>
343
339
 
344
- <!-- Error message bubble -->
345
- <div v-if="$state.error" class="mt-8 flex items-start space-x-3">
346
- <!-- Avatar outside the bubble -->
347
- <div class="flex-shrink-0">
348
- <div class="size-8 rounded-full bg-red-600 dark:bg-red-500 text-white flex items-center justify-center text-lg font-bold">
349
- !
340
+ <!-- Error message bubble -->
341
+ <div v-if="$state.error" class="mt-8 flex items-start space-x-3">
342
+ <!-- Avatar outside the bubble -->
343
+ <div class="flex-shrink-0">
344
+ <div class="size-8 rounded-full bg-red-600 dark:bg-red-500 text-white flex items-center justify-center text-lg font-bold">
345
+ !
346
+ </div>
350
347
  </div>
351
- </div>
352
348
 
353
- <!-- Error bubble -->
354
- <div class="max-w-[85%] rounded-lg px-4 py-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200 shadow-sm">
355
- <div class="flex items-start space-x-2">
356
- <div class="flex-1 min-w-0">
357
- <div class="flex justify-between items-start">
358
- <div class="text-base font-medium mb-1">{{ $state.error?.errorCode || 'Error' }}</div>
359
- <button type="button" @click="$ctx.clearError()" title="Clear Error"
360
- class="text-red-400 dark:text-red-300 hover:text-red-600 dark:hover:text-red-100 flex-shrink-0">
361
- <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
362
- <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
363
- </svg>
364
- </button>
365
- </div>
366
- <div v-if="$state.error?.message" class="text-base mb-1">{{ $state.error.message }}</div>
367
- <div v-if="$state.error?.stackTrace" class="mt-2 text-sm whitespace-pre-wrap break-words max-h-80 overflow-y-auto font-mono p-2 border border-red-200/70 dark:border-red-800/70">
368
- {{ $state.error.stackTrace }}
349
+ <!-- Error bubble -->
350
+ <div class="max-w-[85%] rounded-lg px-4 py-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200 shadow-sm">
351
+ <div class="flex items-start space-x-2">
352
+ <div class="flex-1 min-w-0">
353
+ <div class="flex justify-between items-start">
354
+ <div class="text-base font-medium mb-1">{{ $state.error?.errorCode || 'Error' }}</div>
355
+ <button type="button" @click="$ctx.clearError()" title="Clear Error"
356
+ class="text-red-400 dark:text-red-300 hover:text-red-600 dark:hover:text-red-100 flex-shrink-0">
357
+ <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
358
+ <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
359
+ </svg>
360
+ </button>
361
+ </div>
362
+ <div v-if="$state.error?.message" class="text-base mb-1">{{ $state.error.message }}</div>
363
+ <div v-if="$state.error?.stackTrace" class="mt-2 text-sm whitespace-pre-wrap break-words max-h-80 overflow-y-auto font-mono p-2 border border-red-200/70 dark:border-red-800/70">
364
+ {{ $state.error.stackTrace }}
365
+ </div>
369
366
  </div>
370
367
  </div>
371
368
  </div>
@@ -374,7 +371,6 @@ export default {
374
371
  <ThreadFooter v-if="$threads.threadDetails.value[currentThread.id]" :thread="$threads.threadDetails.value[currentThread.id]" />
375
372
  </div>
376
373
  </div>
377
-
378
374
  </div>
379
375
 
380
376
  <!-- Input Area -->
@@ -1,5 +1,5 @@
1
1
 
2
- import { ref, watch, nextTick, inject } from 'vue'
2
+ import { ref, watch, computed, nextTick, inject } from 'vue'
3
3
  import { $$, createElement, lastRightPart, ApiResult, createErrorStatus, pick } from "@servicestack/client"
4
4
  import SettingsDialog, { useSettings } from './SettingsDialog.mjs'
5
5
  import ChatBody from './ChatBody.mjs'
@@ -782,18 +782,79 @@ const HomeTools = {
782
782
  `,
783
783
  }
784
784
 
785
+ const ThreadHeader = {
786
+ template: `
787
+ <div v-if="showComponents.length" class="flex items-center justify-center gap-2">
788
+ <div v-for="component in showComponents">
789
+ <component :is="component" :thread="thread" />
790
+ </div>
791
+ </div>
792
+ `,
793
+ props: { thread: Object },
794
+ setup(props) {
795
+ const ctx = inject('ctx')
796
+ const showComponents = computed(() => {
797
+ const args = { thread: props.thread }
798
+ return Object.values(ctx.threadHeaderComponents).filter(def => def.show(args)).map(def => def.component)
799
+ })
800
+ return {
801
+ showComponents,
802
+ }
803
+ }
804
+ }
805
+
785
806
  const ThreadFooter = {
786
807
  template: `
787
- <div>
788
- <div v-for="(componentDef,id) in $ctx.threadFooterComponents">
789
- <component v-if="componentDef.show(thread)" :is="componentDef.component" :thread="thread" />
808
+ <div v-if="showComponents.length">
809
+ <div v-for="component in showComponents">
810
+ <component :is="component" :thread="thread" />
790
811
  </div>
791
812
  </div>
792
813
  `,
793
- props: {
794
- thread: Object,
795
- },
814
+ props: { thread: Object },
815
+ setup(props) {
816
+ const ctx = inject('ctx')
817
+ const showComponents = computed(() => {
818
+ const args = { thread: props.thread }
819
+ return Object.values(ctx.threadFooterComponents).filter(def => def.show(args)).map(def => def.component)
820
+ })
821
+ return {
822
+ showComponents,
823
+ }
824
+ }
825
+ }
826
+
827
+ const ThreadModel = {
828
+ template: `
829
+ <span @click="$chat.setSelectedModel({ name: thread.model})"
830
+ class="flex items-center cursor-pointer px-1.5 py-0.5 text-xs rounded text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100 transition-colors border hover:border-gray-300 dark:hover:border-gray-700">
831
+ <ProviderIcon class="size-4 mr-1" :provider="$chat.getProviderForModel(thread.model)" />
832
+ {{thread.model}}
833
+ </span>
834
+ `,
835
+ props: { thread: Object },
836
+ }
837
+
838
+ const ThreadTools = {
839
+ template: `
840
+ <div class="text-sm flex items-center gap-1 flex items-center px-1.5 py-0.5 text-xs rounded text-gray-600 dark:text-gray-300 border cursor-help" :title="title">
841
+ <svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 10h3V7L6.5 3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1-3 3l-6-6a6 6 0 0 1-8-8z"/></svg>
842
+ <span v-if="toolFns.length==1">{{toolFns[0].function.name}}</span>
843
+ <span v-else-if="toolFns.length>1">{{toolFns.length}} Tools</span>
844
+ </div>
845
+ `,
846
+ props: { thread: Object },
796
847
  setup(props) {
848
+ const toolFns = computed(() => props.thread.tools.filter(x => x.type === 'function'))
849
+ const title = computed(() => toolFns.value.length == 1
850
+ ? toolFns.value[0].function.name
851
+ : toolFns.value.length > 1
852
+ ? toolFns.value.map(x => x.function.name).join('\n')
853
+ : '')
854
+ return {
855
+ toolFns,
856
+ title,
857
+ }
797
858
  }
798
859
  }
799
860
 
@@ -807,6 +868,7 @@ export default {
807
868
  ChatBody,
808
869
  HomeTools,
809
870
  Home,
871
+ ThreadHeader,
810
872
  ThreadFooter,
811
873
  })
812
874
  ctx.setGlobals({
@@ -835,6 +897,17 @@ export default {
835
897
  { path: '/c/:id', component: ChatBody, meta },
836
898
  ])
837
899
 
900
+ ctx.setThreadHeaders({
901
+ model: {
902
+ component: ThreadModel,
903
+ show({ thread }) { return thread.model }
904
+ },
905
+ tools: {
906
+ component: ThreadTools,
907
+ show({ thread }) { return (thread.tools || []).filter(x => x.type === 'function').length }
908
+ }
909
+ })
910
+
838
911
  const prefs = ctx.getPrefs()
839
912
  if (prefs.model) {
840
913
  ctx.state.selectedModel = prefs.model
@@ -2,7 +2,7 @@
2
2
  @import "tailwindcss";
3
3
  @source "./lib/servicestack-vue.mjs";
4
4
  @source "../../extensions";
5
- @source "../../llms-home/extensions";
5
+ @source "../../llms-home/extensions/";
6
6
 
7
7
  @custom-variant dark (&:where(.dark, .dark *));
8
8
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: llms-py
3
- Version: 3.0.2
3
+ Version: 3.0.3
4
4
  Summary: A lightweight CLI tool and OpenAI-compatible server for querying multiple Large Language Model (LLM) providers
5
5
  Home-page: https://github.com/ServiceStack/llms
6
6
  Author: ServiceStack
@@ -1,18 +1,18 @@
1
1
  llms/__init__.py,sha256=DKwTZDsyYL_wHe7yvLw49Nf8PSgPSyWaeVdotUqSvrQ,84
2
2
  llms/__main__.py,sha256=hrBulHIt3lmPm1BCyAEVtB6DQ0Hvc3gnIddhHCmJasg,151
3
- llms/db.py,sha256=UGbdnzYLId6qtAOeUAYSJCmkiJuZfqcGRaLyZSRs8tA,11465
3
+ llms/db.py,sha256=BEwBSh7AOc6seylB6Q5k1ru07Zx8WDkSHkto0YWNDSg,11687
4
4
  llms/index.html,sha256=nGk1Djtn9p7l6LuKp4Kg0JIB9fCzxtTWXFfmDb4ggpc,1658
5
5
  llms/llms.json,sha256=gebw9PNWf5UTpU7RDIwo6QcBaJEkU0z3k9EeXR4hQiI,11147
6
- llms/main.py,sha256=otLS2Wq-3UTW_jbyY2FyWEaO6e07Rx3MoowagH5Amws,156220
6
+ llms/main.py,sha256=W9h1UX95pvcCb2gGm9eXHZUHXlb_u28zuGs2N5VHsPE,156278
7
7
  llms/providers-extra.json,sha256=w7_5gB0YUPK0PJNeViM7vRDfNGChXUKMHfGHenVxEkM,10165
8
8
  llms/providers.json,sha256=Hc1STJW9hqM_MWBjeJljs_0I3bTo6qhGlEsL0o-dUuA,285221
9
9
  llms/extensions/analytics/ui/index.mjs,sha256=cr9dPmEJjha2xX6A_7xbJxkOolWFP1p6TgIoO8M8juI,69540
10
10
  llms/extensions/app/README.md,sha256=TKoblZpHlheLCh_dfXOxqTc5OvxlgMBa-vKo8Hqb2gg,1370
11
- llms/extensions/app/__init__.py,sha256=yQ-yw_g0ierrOkCiPCvQn4dYrrMXOydHcK2xoUmJbT0,20795
11
+ llms/extensions/app/__init__.py,sha256=eOdIGG7jrQhBZ-ndNlvbTFOhZlH9n1tG-WvqJw8cakA,20812
12
12
  llms/extensions/app/db.py,sha256=CqpHReXXjrLXXNxINo-wsnBJenKZVVHKlWlhtXFWj08,21503
13
13
  llms/extensions/app/ui/Recents.mjs,sha256=HT9R2IEus3dGEqtKPGrgX1eMp4EQ2j2jLwv-Fn1Us5E,9253
14
14
  llms/extensions/app/ui/index.mjs,sha256=sB9176LLNuKFsZ28yL-tROA6J4xePNtvxtSrzFcinRo,13271
15
- llms/extensions/app/ui/threadStore.mjs,sha256=Lf2wR5DYAMLzJFu35lRnc1W28w7_0bUkMpM509xdEBY,12092
15
+ llms/extensions/app/ui/threadStore.mjs,sha256=5qZv3maUfpLXnoEQN6EPYXUbe3B_OCgT_ttoBpvA4Dk,12192
16
16
  llms/extensions/core_tools/CALCULATOR.md,sha256=pJRtCVF01BgxFrSNh2Ys_lrRi3SFwLgJzAX93AGh93Q,1944
17
17
  llms/extensions/core_tools/__init__.py,sha256=nYeQH20Iy_Yl9S8qGcgLmU7x4e2C43BsiOMG8XYfPM4,23086
18
18
  llms/extensions/core_tools/ui/index.mjs,sha256=KycJ2FcQ6BieBY7fjWGxVBGHN6WuFx712OFrO6flXww,31770
@@ -140,13 +140,13 @@ llms/extensions/system_prompts/ui/prompts.json,sha256=t5DD3bird-87wFa4OlW-bC2wdo
140
140
  llms/extensions/tools/__init__.py,sha256=yfIK7dVqYiZGX5VeJ3x7HQWLPQfuPqeNYJsd0lpZUM4,120
141
141
  llms/extensions/tools/ui/index.mjs,sha256=Nu69U6odCUh8uu1i8d5f8ryO4Lj_OFbPX2LnVyYu1fk,9602
142
142
  llms/ui/App.mjs,sha256=zU-GtbcSMREizjUS9nWiMK6m_oT14MNJ-p_RCz2RVqA,7445
143
- llms/ui/ai.mjs,sha256=F5JXpruePVvlBFiLoGW1x87fC5yL1pMMWtwnfs9QTxk,6540
144
- llms/ui/app.css,sha256=_sRdNrUkYtfjt5nBDsCA5oTT0XjdcadF3RUqjQHVAME,175928
145
- llms/ui/ctx.mjs,sha256=pd_VAnJ6DEmocuMKvIbzw3UdsLBA54AW43_OM1njS8c,11922
143
+ llms/ui/ai.mjs,sha256=W3l-b0tNhGq6B-0joEUgPMMylp_N6rNohLb2j7OAff0,6540
144
+ llms/ui/app.css,sha256=L0ZiQ0yN3fyIluZz-t94Qe4yac6u4m5TqBOekb3HK9c,176286
145
+ llms/ui/ctx.mjs,sha256=eFTkWzVBKRU5YDFFVUq3ZDd6eMPa4VRQpE3q0O5Sfgg,12337
146
146
  llms/ui/fav.svg,sha256=_R6MFeXl6wBFT0lqcUxYQIDWgm246YH_3hSTW0oO8qw,734
147
147
  llms/ui/index.mjs,sha256=o-J2GVYYGFtfV4Pzlj-kjK4UrVP6ovTre5jD44gAiIc,4316
148
148
  llms/ui/markdown.mjs,sha256=ZeGXxX4_UEUCVkLZzmwXlqWBfReSFzBivdxNu8uSgFk,6648
149
- llms/ui/tailwind.input.css,sha256=fJInSAoAoF-fhLiWFlocuP5i3b4eWp8grIfG6LyvtVw,15785
149
+ llms/ui/tailwind.input.css,sha256=ZneSjo_uNcvqMT8cAU837yUZoLZ_sWfgr5Gto7TNJPc,15786
150
150
  llms/ui/typography.css,sha256=6o7pbMIamRVlm2GfzSStpcOG4T5eFCK_WcQ3RIHKAsU,19587
151
151
  llms/ui/utils.mjs,sha256=Vv2YsKaOJCNYrxv19q-gU1wSuuMhEcTcm6M7_g9Ff0Y,6664
152
152
  llms/ui/lib/chart.js,sha256=dx8FdDX0Rv6OZtZjr9FQh5h-twFsKjfnb-FvFlQ--cU,196176
@@ -163,12 +163,12 @@ llms/ui/lib/vue.mjs,sha256=75FuLhUTPk19sncwNIrm0BGEL0_Qw298-_v01fPWYoI,542872
163
163
  llms/ui/modules/icons.mjs,sha256=LGcH0ys0QLS2ZKCO42qHpwPYbBV_EssoWLezU4XZEzU,27751
164
164
  llms/ui/modules/layout.mjs,sha256=8pAxs8bedQI3b3eRA9nrfpLZznLmrpp4BZvigYAQjpQ,12572
165
165
  llms/ui/modules/model-selector.mjs,sha256=6U4rAZ7vmQELFRQGWk4YEtq02v3lyHdMq6yUOp-ArXg,43184
166
- llms/ui/modules/chat/ChatBody.mjs,sha256=DCEjXpXJBW3tg2IZr39-ab1NGoy2WDsqGJa5gCAQ4x4,42441
166
+ llms/ui/modules/chat/ChatBody.mjs,sha256=YbpIEl6OeBi1RqCPkW5UFbUk6qNi5B_GfRUK5Pmfms8,42875
167
167
  llms/ui/modules/chat/SettingsDialog.mjs,sha256=HMBJTwrapKrRIAstIIqp0QlJL5O-ho4hzgvfagPfsX8,19930
168
- llms/ui/modules/chat/index.mjs,sha256=d_BSYXQvlNRK3A0L1GOigFxl66FXlTw9TTQAJx34JAU,34062
169
- llms_py-3.0.2.dist-info/licenses/LICENSE,sha256=bus9cuAOWeYqBk2OuhSABVV1P4z7hgrEFISpyda_H5w,1532
170
- llms_py-3.0.2.dist-info/METADATA,sha256=hbUYmI79etmIMdHIrxeingjIjTiG3y_O9AV5KsBfBe4,2191
171
- llms_py-3.0.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
172
- llms_py-3.0.2.dist-info/entry_points.txt,sha256=WswyE7PfnkZMIxboC-MS6flBD6wm-CYU7JSUnMhqMfM,40
173
- llms_py-3.0.2.dist-info/top_level.txt,sha256=gC7hk9BKSeog8gyg-EM_g2gxm1mKHwFRfK-10BxOsa4,5
174
- llms_py-3.0.2.dist-info/RECORD,,
168
+ llms/ui/modules/chat/index.mjs,sha256=NA_9R7JEqHKZRnYGqte_J7qoDbG3RY8969TTNXqZa1k,37011
169
+ llms_py-3.0.3.dist-info/licenses/LICENSE,sha256=bus9cuAOWeYqBk2OuhSABVV1P4z7hgrEFISpyda_H5w,1532
170
+ llms_py-3.0.3.dist-info/METADATA,sha256=NUThc-W_gsTuY1CbwnHiAuIS2MuqgksPmDFmMBhq2MM,2191
171
+ llms_py-3.0.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
172
+ llms_py-3.0.3.dist-info/entry_points.txt,sha256=WswyE7PfnkZMIxboC-MS6flBD6wm-CYU7JSUnMhqMfM,40
173
+ llms_py-3.0.3.dist-info/top_level.txt,sha256=gC7hk9BKSeog8gyg-EM_g2gxm1mKHwFRfK-10BxOsa4,5
174
+ llms_py-3.0.3.dist-info/RECORD,,