llms-py 3.0.13__py3-none-any.whl → 3.0.15__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.
Files changed (34) hide show
  1. llms/extensions/app/ui/threadStore.mjs +10 -3
  2. llms/extensions/computer_use/README.md +96 -0
  3. llms/extensions/computer_use/__init__.py +27 -0
  4. llms/extensions/computer_use/base.py +80 -0
  5. llms/extensions/computer_use/bash.py +185 -0
  6. llms/extensions/computer_use/computer.py +523 -0
  7. llms/extensions/computer_use/edit.py +303 -0
  8. llms/extensions/computer_use/platform.py +461 -0
  9. llms/extensions/computer_use/run.py +37 -0
  10. llms/extensions/providers/anthropic.py +22 -3
  11. llms/extensions/skills/LICENSE +202 -0
  12. llms/extensions/skills/__init__.py +130 -0
  13. llms/extensions/skills/errors.py +25 -0
  14. llms/extensions/skills/models.py +39 -0
  15. llms/extensions/skills/parser.py +178 -0
  16. llms/extensions/skills/ui/index.mjs +335 -0
  17. llms/extensions/skills/ui/skills/create-plan/SKILL.md +74 -0
  18. llms/extensions/skills/validator.py +177 -0
  19. llms/extensions/system_prompts/ui/index.mjs +6 -10
  20. llms/extensions/tools/ui/index.mjs +1 -1
  21. llms/main.py +88 -11
  22. llms/ui/ai.mjs +1 -1
  23. llms/ui/app.css +39 -0
  24. llms/ui/ctx.mjs +53 -6
  25. llms/ui/modules/chat/ChatBody.mjs +138 -13
  26. llms/ui/modules/chat/index.mjs +4 -12
  27. llms/ui/tailwind.input.css +10 -0
  28. llms/ui/utils.mjs +25 -1
  29. {llms_py-3.0.13.dist-info → llms_py-3.0.15.dist-info}/METADATA +1 -1
  30. {llms_py-3.0.13.dist-info → llms_py-3.0.15.dist-info}/RECORD +34 -18
  31. {llms_py-3.0.13.dist-info → llms_py-3.0.15.dist-info}/WHEEL +0 -0
  32. {llms_py-3.0.13.dist-info → llms_py-3.0.15.dist-info}/entry_points.txt +0 -0
  33. {llms_py-3.0.13.dist-info → llms_py-3.0.15.dist-info}/licenses/LICENSE +0 -0
  34. {llms_py-3.0.13.dist-info → llms_py-3.0.15.dist-info}/top_level.txt +0 -0
llms/main.py CHANGED
@@ -26,11 +26,24 @@ import sys
26
26
  import time
27
27
  import traceback
28
28
  from datetime import datetime
29
- from enum import IntEnum
29
+ from enum import Enum, IntEnum
30
30
  from importlib import resources # Py≥3.9 (pip install importlib_resources for 3.7/3.8)
31
31
  from io import BytesIO
32
32
  from pathlib import Path
33
- from typing import Any, Callable, Dict, List, Optional, Tuple, Union, get_type_hints
33
+ from typing import (
34
+ Annotated,
35
+ Any,
36
+ Callable,
37
+ Dict,
38
+ List,
39
+ Literal,
40
+ Optional,
41
+ Tuple,
42
+ Union,
43
+ get_args,
44
+ get_origin,
45
+ get_type_hints,
46
+ )
34
47
  from urllib.parse import parse_qs, urlencode, urljoin
35
48
 
36
49
  import aiohttp
@@ -43,7 +56,7 @@ try:
43
56
  except ImportError:
44
57
  HAS_PIL = False
45
58
 
46
- VERSION = "3.0.13"
59
+ VERSION = "3.0.15"
47
60
  _ROOT = None
48
61
  DEBUG = os.getenv("DEBUG") == "1"
49
62
  MOCK = os.getenv("MOCK") == "1"
@@ -348,22 +361,75 @@ def to_content(result):
348
361
  return str(result)
349
362
 
350
363
 
364
+ def get_literal_values(typ):
365
+ """Recursively extract values from Literal and Union types."""
366
+ origin = get_origin(typ)
367
+ if origin is Literal:
368
+ return list(get_args(typ))
369
+ elif origin is Union:
370
+ values = []
371
+ for arg in get_args(typ):
372
+ # Recurse for nested Unions or Literals
373
+ nested_values = get_literal_values(arg)
374
+ if nested_values:
375
+ for v in nested_values:
376
+ if v not in values:
377
+ values.append(v)
378
+ return values
379
+ return None
380
+
381
+
351
382
  def function_to_tool_definition(func):
352
- type_hints = get_type_hints(func)
383
+ type_hints = get_type_hints(func, include_extras=True)
353
384
  signature = inspect.signature(func)
354
385
  parameters = {"type": "object", "properties": {}, "required": []}
355
386
 
356
387
  for name, param in signature.parameters.items():
357
388
  param_type = type_hints.get(name, str)
358
389
  param_type_name = "string"
359
- if param_type is int:
390
+ enum_values = None
391
+ description = None
392
+
393
+ # Check for Annotated (for description)
394
+ if get_origin(param_type) is Annotated:
395
+ args = get_args(param_type)
396
+ param_type = args[0]
397
+ for arg in args[1:]:
398
+ if isinstance(arg, str):
399
+ description = arg
400
+ break
401
+
402
+ # Check for Enum
403
+ if inspect.isclass(param_type) and issubclass(param_type, Enum):
404
+ enum_values = [e.value for e in param_type]
405
+ else:
406
+ # Check for Literal / Union[Literal]
407
+ enum_values = get_literal_values(param_type)
408
+
409
+ if enum_values:
410
+ # Infer type from the first value
411
+ value_type = type(enum_values[0])
412
+ if value_type is int:
413
+ param_type_name = "integer"
414
+ elif value_type is float:
415
+ param_type_name = "number"
416
+ elif value_type is bool:
417
+ param_type_name = "boolean"
418
+
419
+ elif param_type is int:
360
420
  param_type_name = "integer"
361
421
  elif param_type is float:
362
422
  param_type_name = "number"
363
423
  elif param_type is bool:
364
424
  param_type_name = "boolean"
365
425
 
366
- parameters["properties"][name] = {"type": param_type_name}
426
+ prop = {"type": param_type_name}
427
+ if description:
428
+ prop["description"] = description
429
+ if enum_values:
430
+ prop["enum"] = enum_values
431
+ parameters["properties"][name] = prop
432
+
367
433
  if param.default == inspect.Parameter.empty:
368
434
  parameters["required"].append(name)
369
435
 
@@ -371,7 +437,7 @@ def function_to_tool_definition(func):
371
437
  "type": "function",
372
438
  "function": {
373
439
  "name": func.__name__,
374
- "description": func.__doc__ or "",
440
+ "description": (func.__doc__ or "").strip(),
375
441
  "parameters": parameters,
376
442
  },
377
443
  }
@@ -791,7 +857,7 @@ def chat_to_prompt(chat):
791
857
  prompt = ""
792
858
  if "messages" in chat:
793
859
  for message in chat["messages"]:
794
- if message["role"] == "user":
860
+ if message.get("role") == "user":
795
861
  # if content is string
796
862
  if isinstance(message["content"], str):
797
863
  if prompt:
@@ -810,7 +876,7 @@ def chat_to_prompt(chat):
810
876
  def chat_to_system_prompt(chat):
811
877
  if "messages" in chat:
812
878
  for message in chat["messages"]:
813
- if message["role"] == "system":
879
+ if message.get("role") == "system":
814
880
  # if content is string
815
881
  if isinstance(message["content"], str):
816
882
  return message["content"]
@@ -838,7 +904,7 @@ def last_user_prompt(chat):
838
904
  prompt = ""
839
905
  if "messages" in chat:
840
906
  for message in chat["messages"]:
841
- if message["role"] == "user":
907
+ if message.get("role") == "user":
842
908
  # if content is string
843
909
  if isinstance(message["content"], str):
844
910
  prompt = message["content"]
@@ -1585,6 +1651,9 @@ async def g_chat_completion(chat, context=None):
1585
1651
  if context is None:
1586
1652
  context = {"chat": chat, "tools": "all"}
1587
1653
 
1654
+ if "request_id" not in context:
1655
+ context["request_id"] = str(int(time.time() * 1000))
1656
+
1588
1657
  # get first provider that has the model
1589
1658
  candidate_providers = [name for name, provider in g_handlers.items() if provider.provider_model(model)]
1590
1659
  if len(candidate_providers) == 0:
@@ -1736,7 +1805,12 @@ async def g_chat_completion(chat, context=None):
1736
1805
  continue
1737
1806
 
1738
1807
  # If we get here, all providers failed
1739
- raise first_exception
1808
+ if first_exception:
1809
+ raise first_exception
1810
+
1811
+ e = Exception("All providers failed")
1812
+ await g_app.on_chat_error(e, context or {"chat": chat})
1813
+ raise e
1740
1814
 
1741
1815
 
1742
1816
  async def cli_chat(chat, tools=None, image=None, audio=None, file=None, args=None, raw=False):
@@ -2918,6 +2992,9 @@ class ExtensionContext:
2918
2992
  def add_index_footer(self, html: str):
2919
2993
  self.app.index_footers.append(html)
2920
2994
 
2995
+ def get_home_path(self, name: str = "") -> str:
2996
+ return home_llms_path(name)
2997
+
2921
2998
  def get_config(self) -> Optional[Dict[str, Any]]:
2922
2999
  return g_config
2923
3000
 
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.13',
9
+ version: '3.0.15',
10
10
  base,
11
11
  prefsKey,
12
12
  welcome: 'Welcome to llms.py',
llms/ui/app.css CHANGED
@@ -427,6 +427,9 @@
427
427
  .right-2 {
428
428
  right: calc(var(--spacing) * 2);
429
429
  }
430
+ .right-3 {
431
+ right: calc(var(--spacing) * 3);
432
+ }
430
433
  .right-4 {
431
434
  right: calc(var(--spacing) * 4);
432
435
  }
@@ -622,6 +625,12 @@
622
625
  .-mb-px {
623
626
  margin-bottom: -1px;
624
627
  }
628
+ .mb-0 {
629
+ margin-bottom: calc(var(--spacing) * 0);
630
+ }
631
+ .mb-0\.5 {
632
+ margin-bottom: calc(var(--spacing) * 0.5);
633
+ }
625
634
  .mb-1 {
626
635
  margin-bottom: calc(var(--spacing) * 1);
627
636
  }
@@ -899,6 +908,9 @@
899
908
  .w-16 {
900
909
  width: calc(var(--spacing) * 16);
901
910
  }
911
+ .w-28 {
912
+ width: calc(var(--spacing) * 28);
913
+ }
902
914
  .w-32 {
903
915
  width: calc(var(--spacing) * 32);
904
916
  }
@@ -1667,6 +1679,12 @@
1667
1679
  background-color: color-mix(in oklab, var(--color-gray-50) 50%, transparent);
1668
1680
  }
1669
1681
  }
1682
+ .bg-gray-50\/90 {
1683
+ background-color: color-mix(in srgb, oklch(98.5% 0.002 247.839) 90%, transparent);
1684
+ @supports (color: color-mix(in lab, red, red)) {
1685
+ background-color: color-mix(in oklab, var(--color-gray-50) 90%, transparent);
1686
+ }
1687
+ }
1670
1688
  .bg-gray-100 {
1671
1689
  background-color: var(--color-gray-100);
1672
1690
  }
@@ -2506,6 +2524,10 @@
2506
2524
  --tw-ordinal: ordinal;
2507
2525
  font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
2508
2526
  }
2527
+ .tabular-nums {
2528
+ --tw-numeric-spacing: tabular-nums;
2529
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
2530
+ }
2509
2531
  .overline {
2510
2532
  text-decoration-line: overline;
2511
2533
  }
@@ -4888,6 +4910,14 @@
4888
4910
  }
4889
4911
  }
4890
4912
  }
4913
+ .dark\:bg-gray-800\/90 {
4914
+ &:where(.dark, .dark *) {
4915
+ background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 90%, transparent);
4916
+ @supports (color: color-mix(in lab, red, red)) {
4917
+ background-color: color-mix(in oklab, var(--color-gray-800) 90%, transparent);
4918
+ }
4919
+ }
4920
+ }
4891
4921
  .dark\:bg-gray-900 {
4892
4922
  &:where(.dark, .dark *) {
4893
4923
  background-color: var(--color-gray-900);
@@ -6282,6 +6312,15 @@
6282
6312
  border-radius: 0 !important;
6283
6313
  overflow: auto !important;
6284
6314
  }
6315
+ .prose .frontmatter {
6316
+ white-space: pre-wrap;
6317
+ word-break: break-all;
6318
+ margin-bottom: 1em;
6319
+ padding: 0.5rem;
6320
+ font-size: 0.8rem !important;
6321
+ background: #f9fafb;
6322
+ border: 1px solid #e5e7eb;
6323
+ }
6285
6324
  .hljs {
6286
6325
  background: white;
6287
6326
  color: black;
llms/ui/ctx.mjs CHANGED
@@ -392,12 +392,11 @@ export class AppContext {
392
392
  if (Array.isArray(content)) {
393
393
  content = content.filter(c => c.type === 'text').map(c => c.text).join('\n')
394
394
  }
395
- // Handled by katex
396
- // if (content) {
397
- // content = content
398
- // .replaceAll(`\\[ \\boxed{`, '\n<span class="inline-block text-xl text-blue-500 bg-blue-50 dark:text-blue-400 dark:bg-blue-950 px-3 py-1 rounded">')
399
- // .replaceAll('} \\]', '</span>\n')
400
- // }
395
+ if (content && content.startsWith('---')) {
396
+ const headerEnd = content.indexOf('---', 3)
397
+ const header = content.substring(3, headerEnd).trim()
398
+ content = '<div class="frontmatter">' + header + '</div>\n' + content.substring(headerEnd + 3)
399
+ }
401
400
  return this.marked.parse(content || '')
402
401
  }
403
402
 
@@ -409,4 +408,52 @@ export class AppContext {
409
408
  }
410
409
  return this.renderMarkdown(content)
411
410
  }
411
+
412
+ createChatContext({ request, thread, context }) {
413
+ if (!request.messages) request.messages = []
414
+ if (!request.metadata) request.metadata = {}
415
+ if (!context) context = {}
416
+ Object.assign(context, {
417
+ systemPrompt: '',
418
+ requiredSystemPrompts: [],
419
+ }, context)
420
+ return {
421
+ request,
422
+ thread,
423
+ context,
424
+ }
425
+ }
426
+
427
+ completeChatContext({ request, thread, context }) {
428
+
429
+ let existingSystemPrompt = request.messages.find(m => m.role === 'system')?.content
430
+
431
+ let existingMessages = request.messages.filter(m => m.role == 'assistant' || m.role == 'tool')
432
+ if (existingMessages.length) {
433
+ const messageTypes = {}
434
+ request.messages.forEach(m => {
435
+ messageTypes[m.role] = (messageTypes[m.role] || 0) + 1
436
+ })
437
+ const summary = JSON.stringify(messageTypes).replace(/"/g, '')
438
+ console.debug(`completeChatContext(${summary})`, request)
439
+ return
440
+ }
441
+
442
+ let newSystemPrompts = context.requiredSystemPrompts ?? []
443
+ if (context.systemPrompt) {
444
+ newSystemPrompts.push(context.systemPrompt)
445
+ }
446
+ if (existingSystemPrompt) {
447
+ newSystemPrompts.push(existingSystemPrompt)
448
+ }
449
+
450
+ let newSystemPrompt = newSystemPrompts.join('\n\n')
451
+ if (newSystemPrompt) {
452
+ // add or replace system prompt
453
+ request.messages = request.messages.filter(m => m.role !== 'system')
454
+ request.messages.unshift({ role: 'system', content: newSystemPrompt })
455
+ }
456
+
457
+ console.debug(`completeChatContext()`, request)
458
+ }
412
459
  }
@@ -55,8 +55,8 @@ function embedHtml(html) {
55
55
 
56
56
  export const TypeText = {
57
57
  template: `
58
- <div v-if="text.type === 'text'">
59
- <div v-html="html?.trim()" class="whitespace-pre-wrap"></div>
58
+ <div data-type="text" v-if="text.type === 'text'">
59
+ !<div v-html="html?.trim()" class="whitespace-pre-wrap"></div>
60
60
  </div>
61
61
  `,
62
62
  props: {
@@ -161,7 +161,7 @@ export const LightboxImage = {
161
161
 
162
162
  export const TypeImage = {
163
163
  template: `
164
- <div v-if="image.type === 'image_url'">
164
+ <div data-type="image" v-if="image.type === 'image_url'">
165
165
  <LightboxImage :src="$ctx.resolveUrl(image.image_url.url)" />
166
166
  </div>
167
167
  `,
@@ -175,7 +175,7 @@ export const TypeImage = {
175
175
 
176
176
  export const TypeAudio = {
177
177
  template: `
178
- <div v-if="audio.type === 'audio_url'">
178
+ <div data-type="audio" v-if="audio.type === 'audio_url'">
179
179
  <slot></slot>
180
180
  <audio controls :src="$ctx.resolveUrl(audio.audio_url.url)" class="h-8 w-64"></audio>
181
181
  </div>
@@ -190,7 +190,7 @@ export const TypeAudio = {
190
190
 
191
191
  export const TypeFile = {
192
192
  template: `
193
- <a v-if="file.type === 'file'" :href="$ctx.resolveUrl(file.file.file_data)" target="_blank"
193
+ <a data-type="file" v-if="file.type === 'file'" :href="$ctx.resolveUrl(file.file.file_data)" target="_blank"
194
194
  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">
195
195
  <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>
196
196
  <span class="max-w-xs truncate">{{ file.file.filename || 'Attachment' }}</span>
@@ -211,7 +211,7 @@ export const ViewType = {
211
211
  <TypeImage v-else-if="result.type === 'image_url'" :image="result" />
212
212
  <TypeAudio v-else-if="result.type === 'audio_url'" :audio="result" />
213
213
  <TypeFile v-else-if="result.type === 'file'" :file="result" />
214
- <div v-else>
214
+ <div data-type="other" v-else>
215
215
  <HtmlFormat :value="result" :classes="$utils.htmlFormatClasses" />
216
216
  </div>
217
217
  </div>
@@ -346,17 +346,138 @@ export const MessageReasoning = {
346
346
  }
347
347
  }
348
348
 
349
+ export const TextViewer = {
350
+ template: `
351
+ <div v-if="text.length > 200" class="relative group">
352
+ <div class="absolute top-0 right-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center space-x-2 bg-gray-50/90 dark:bg-gray-800/90 backdrop-blur-sm rounded-md px-2 py-1 z-10 border border-gray-200 dark:border-gray-700 shadow-sm">
353
+ <!-- Style Selector -->
354
+ <div class="relative flex items-center">
355
+ <button type="button" @click="toggleDropdown" class="text-[10px] uppercase font-bold tracking-wider text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 focus:outline-none flex items-center select-none">
356
+ <span>{{ prefs || 'pre' }}</span>
357
+ <svg class="mb-0.5 size-3 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M6 9l6 6 6-6"/></svg>
358
+ </button>
359
+ <!-- Popover -->
360
+ <div v-if="dropdownOpen" class="absolute right-0 top-full w-28 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-20 overflow-hidden">
361
+ <button
362
+ v-for="style in textStyles"
363
+ :key="style"
364
+ @click="setStyle(style)"
365
+ class="block w-full text-left px-3 py-1.5 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors uppercase tracking-wider font-medium"
366
+ :class="{ 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20': prefs === style }"
367
+ >
368
+ {{ style }}
369
+ </button>
370
+ </div>
371
+ </div>
372
+
373
+ <div class="w-px h-3 bg-gray-300 dark:bg-gray-600"></div>
374
+
375
+ <!-- Text Length -->
376
+ <span class="text-xs text-gray-500 dark:text-gray-400 tabular-nums" :title="text.length + ' characters'">
377
+ {{ $fmt.humanifyNumber(text.length) }}
378
+ </span>
379
+
380
+ <!-- Maximize Toggle -->
381
+ <button type="button" @click="toggleMaximized" class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 focus:outline-none p-0.5 rounded transition-colors" :title="isMaximized ? 'Minimize' : 'Maximize'">
382
+ <svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
383
+ <path v-if="isMaximized" fill="currentColor" d="M9 9H3V7h4V3h2zm0 6H3v2h4v4h2zm12 0h-6v6h2v-4h4zm-6-6h6V7h-4V3h-2z"/>
384
+ <path v-else fill="currentColor" d="M3 3h6v2H5v4H3zm0 18h6v-2H5v-4H3zm12 0h6v-6h-2v4h-4zm6-18h-6v2h4v4h2z"/>
385
+ </svg>
386
+ </button>
387
+ </div>
388
+
389
+ <!-- Content -->
390
+ <div :class="containerClass">
391
+ <div v-if="prefs === 'markdown'" class="prose prose-sm max-w-none dark:prose-invert">
392
+ <div v-html="$fmt.markdown(text)"></div>
393
+ </div>
394
+ <div v-else :class="['p-0.5', contentClass]">{{ text }}</div>
395
+ </div>
396
+ </div>
397
+ <div v-else class="whitespace-pre-wrap">{{ text }}</div>
398
+ `,
399
+ props: {
400
+ prefsName: String,
401
+ text: String,
402
+ },
403
+ setup(props) {
404
+ const ctx = inject('ctx')
405
+ const textStyles = ['pre', 'normal', 'markdown']
406
+ const prefs = ref('pre')
407
+ const maximized = ref({})
408
+ const dropdownOpen = ref(false)
409
+ const hash = computed(() => ctx.utils.hashString(props.text))
410
+
411
+ const toggleDropdown = () => {
412
+ dropdownOpen.value = !dropdownOpen.value
413
+ }
414
+
415
+ const setStyle = (style) => {
416
+ prefs.value = style
417
+ dropdownOpen.value = false
418
+ const key = props.prefsName || 'default'
419
+ const currentPrefs = ctx.getPrefs().textStyle || {}
420
+ ctx.setPrefs({
421
+ textStyle: {
422
+ ...currentPrefs,
423
+ [key]: style
424
+ }
425
+ })
426
+ }
427
+
428
+ onMounted(() => {
429
+ const current = ctx.getPrefs()
430
+ const key = props.prefsName || 'default'
431
+ if (current.textStyle && current.textStyle[key]) {
432
+ prefs.value = current.textStyle[key]
433
+ }
434
+ })
435
+
436
+ const isMaximized = computed(() => maximized.value[hash.value])
437
+
438
+ const toggleMaximized = () => {
439
+ maximized.value[hash.value] = !maximized.value[hash.value]
440
+ }
441
+
442
+ const containerClass = computed(() => {
443
+ return isMaximized.value ? 'w-full h-full' : 'max-h-60 overflow-y-auto'
444
+ })
445
+
446
+ const contentClass = computed(() => {
447
+ if (prefs.value === 'pre') return 'whitespace-pre-wrap font-mono text-xs'
448
+ if (prefs.value === 'normal') return 'font-sans text-sm'
449
+ return ''
450
+ })
451
+
452
+ return {
453
+ hash,
454
+ textStyles,
455
+ prefs,
456
+ dropdownOpen,
457
+ toggleDropdown,
458
+ setStyle,
459
+ isMaximized,
460
+ toggleMaximized,
461
+ containerClass,
462
+ contentClass
463
+ }
464
+ }
465
+ }
466
+
349
467
  export const ToolArguments = {
350
468
  template: `
351
469
  <div ref="refArgs" v-if="dict" class="not-prose">
352
470
  <div class="prose html-format">
353
471
  <table class="table-object border-none">
354
472
  <tr v-for="(v, k) in dict" :key="k">
355
- <td class="align-top py-2 px-4 text-left text-sm font-medium tracking-wider whitespace-nowrap lowercase">{{ k }}</td>
356
- <td v-if="$utils.isHtml(v)" style="margin:0;padding:0;width:100%">
473
+ <td data-arg="name" class="align-top py-2 px-4 text-left text-sm font-medium tracking-wider whitespace-nowrap lowercase">{{ k }}</td>
474
+ <td data-arg="html" v-if="$utils.isHtml(v)" style="margin:0;padding:0;width:100%">
357
475
  <div v-html="embedHtml(v)" class="w-full h-full"></div>
358
476
  </td>
359
- <td v-else class="align-top py-2 px-4 text-sm whitespace-pre-wrap">
477
+ <td data-arg="string" v-else-if="typeof v === 'string'" class="align-top py-2 px-4 text-sm">
478
+ <TextViewer prefsName="toolArgs" :text="v" />
479
+ </td>
480
+ <td data-arg="value" v-else class="align-top py-2 px-4 text-sm whitespace-pre-wrap">
360
481
  <HtmlFormat :value="v" :classes="$utils.htmlFormatClasses" />
361
482
  </td>
362
483
  </tr>
@@ -373,6 +494,7 @@ export const ToolArguments = {
373
494
  },
374
495
  setup(props) {
375
496
  const refArgs = ref()
497
+ const maximized = ref({})
376
498
  const dict = computed(() => {
377
499
  if (isEmpty(props.value)) return null
378
500
  const ret = tryParseJson(props.value)
@@ -409,6 +531,7 @@ export const ToolArguments = {
409
531
 
410
532
  return {
411
533
  refArgs,
534
+ maximized,
412
535
  dict,
413
536
  list,
414
537
  isEmpty,
@@ -439,9 +562,11 @@ export const ToolOutput = {
439
562
  </span>
440
563
  </div>
441
564
  </div>
442
- <div class="not-prose px-3 py-2">
443
- <pre v-if="$ctx.prefs.toolFormat !== 'preview' || !hasJsonStructure(output.content)" class="tool-output">{{ output.content }}</pre>
444
- <div v-else class="text-xs">
565
+ <div class="px-3 py-2">
566
+ <div v-if="$ctx.prefs.toolFormat !== 'preview' || !hasJsonStructure(output.content)">
567
+ <TextViewer prefsName="toolOutput" :text="output.content" />
568
+ </div>
569
+ <div v-else class="not-prose text-xs">
445
570
  <HtmlFormat v-if="tryParseJson(output.content)" :value="tryParseJson(output.content)" :classes="$utils.htmlFormatClasses" />
446
571
  <div v-else class="text-gray-500 italic p-2">Invalid JSON content</div>
447
572
  </div>
@@ -524,7 +649,7 @@ export const ChatBody = {
524
649
  : 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 border border-gray-200 dark:border-gray-700'"
525
650
  >
526
651
  <!-- Copy button in top right corner -->
527
- <button
652
+ <button v-if="message.content"
528
653
  type="button"
529
654
  @click="copyMessageContent(message)"
530
655
  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"
@@ -2,7 +2,7 @@
2
2
  import { ref, watch, computed, nextTick, inject } from 'vue'
3
3
  import { $$, createElement, lastRightPart, ApiResult, createErrorStatus } from "@servicestack/client"
4
4
  import SettingsDialog, { useSettings } from './SettingsDialog.mjs'
5
- import { ChatBody, LightboxImage, TypeText, TypeImage, TypeAudio, TypeFile, ViewType, ViewTypes, ViewToolTypes, ToolArguments, ToolOutput, MessageUsage, MessageReasoning } from './ChatBody.mjs'
5
+ import { ChatBody, LightboxImage, TypeText, TypeImage, TypeAudio, TypeFile, ViewType, ViewTypes, ViewToolTypes, TextViewer, ToolArguments, ToolOutput, MessageUsage, MessageReasoning } from './ChatBody.mjs'
6
6
  import { AppContext } from '../../ctx.mjs'
7
7
 
8
8
  const imageExts = 'png,webp,jpg,jpeg,gif,bmp,svg,tiff,ico'.split(',')
@@ -252,23 +252,14 @@ export function useChatPrompt(ctx) {
252
252
  return ctx.createErrorResult({ message: `Model ${request.model || ''} not found`, errorCode: 'NotFound' })
253
253
  }
254
254
 
255
- if (!request.messages) request.messages = []
256
- if (!request.metadata) request.metadata = {}
257
-
258
255
  if (!thread) {
259
256
  const title = getTextContent(request) || 'New Chat'
260
257
  thread = await ctx.threads.startNewThread({ title, model, redirect })
261
258
  }
262
259
 
263
- const threadId = thread?.id
264
-
265
- const ctxRequest = {
266
- request,
267
- thread,
268
- }
260
+ const ctxRequest = ctx.createChatContext({ request, thread })
269
261
  ctx.chatRequestFilters.forEach(f => f(ctxRequest))
270
-
271
- console.debug('completion.request', request)
262
+ ctx.completeChatContext(ctxRequest)
272
263
 
273
264
  // Send to API
274
265
  const startTime = Date.now()
@@ -935,6 +926,7 @@ export default {
935
926
  ViewType,
936
927
  ViewTypes,
937
928
  ViewToolTypes,
929
+ TextViewer,
938
930
  ToolArguments,
939
931
  ToolOutput,
940
932
 
@@ -166,6 +166,16 @@
166
166
  overflow: auto !important;
167
167
  }
168
168
 
169
+ .prose .frontmatter {
170
+ white-space: pre-wrap;
171
+ word-break: break-all;
172
+ margin-bottom: 1em;
173
+ padding: 0.5rem;
174
+ font-size: 0.8rem !important;
175
+ background: #f9fafb;
176
+ border: 1px solid #e5e7eb;
177
+ }
178
+
169
179
  /* highlight.js - vs.css */
170
180
  .hljs {
171
181
  background: white;
llms/ui/utils.mjs CHANGED
@@ -211,6 +211,19 @@ export function isHtml(s) {
211
211
  return isHtml
212
212
  }
213
213
 
214
+ const htmlEntities = {
215
+ '&': '&amp;',
216
+ '<': '&lt;',
217
+ '>': '&gt;',
218
+ '"': '&quot;',
219
+ "'": '&#39;'
220
+ }
221
+
222
+ export function encodeHtml(str) {
223
+ if (!str) return ''
224
+ return str.replace(/[&<>"']/g, m => htmlEntities[m]);
225
+ }
226
+
214
227
  /**
215
228
  * @param {object|array} type
216
229
  * @param {'div'|'table'|'thead'|'th'|'tr'|'td'} tag
@@ -241,6 +254,16 @@ export const nextId = (() => {
241
254
  }
242
255
  })();
243
256
 
257
+ export function fnv1a(str) {
258
+ let hash = 0x811c9dc5
259
+ for (let i = 0; i < str.length; i++) {
260
+ hash ^= str.charCodeAt(i)
261
+ hash = Math.imul(hash, 0x01000193)
262
+ }
263
+ return hash >>> 0
264
+ }
265
+ export const hashString = fnv1a
266
+
244
267
  export function utilsFunctions() {
245
268
  return {
246
269
  nextId,
@@ -256,6 +279,7 @@ export function utilsFunctions() {
256
279
  pluralize,
257
280
  isHtml,
258
281
  htmlFormatClasses,
282
+ encodeHtml,
283
+ hashString,
259
284
  }
260
285
  }
261
-