llms-py 3.0.14__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.
@@ -0,0 +1,177 @@
1
+ """Skill validation logic."""
2
+
3
+ import unicodedata
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from .errors import ParseError
8
+ from .parser import find_skill_md, parse_frontmatter
9
+
10
+ MAX_SKILL_NAME_LENGTH = 64
11
+ MAX_DESCRIPTION_LENGTH = 1024
12
+ MAX_COMPATIBILITY_LENGTH = 500
13
+
14
+ # Allowed frontmatter fields per Agent Skills Spec
15
+ ALLOWED_FIELDS = {
16
+ "name",
17
+ "description",
18
+ "license",
19
+ "allowed-tools",
20
+ "metadata",
21
+ "compatibility",
22
+ }
23
+
24
+
25
+ def _validate_name(name: str, skill_dir: Path) -> list[str]:
26
+ """Validate skill name format and directory match.
27
+
28
+ Skill names support i18n characters (Unicode letters) plus hyphens.
29
+ Names must be lowercase and cannot start/end with hyphens.
30
+ """
31
+ errors = []
32
+
33
+ if not name or not isinstance(name, str) or not name.strip():
34
+ errors.append("Field 'name' must be a non-empty string")
35
+ return errors
36
+
37
+ name = unicodedata.normalize("NFKC", name.strip())
38
+
39
+ if len(name) > MAX_SKILL_NAME_LENGTH:
40
+ errors.append(
41
+ f"Skill name '{name}' exceeds {MAX_SKILL_NAME_LENGTH} character limit "
42
+ f"({len(name)} chars)"
43
+ )
44
+
45
+ if name != name.lower():
46
+ errors.append(f"Skill name '{name}' must be lowercase")
47
+
48
+ if name.startswith("-") or name.endswith("-"):
49
+ errors.append("Skill name cannot start or end with a hyphen")
50
+
51
+ if "--" in name:
52
+ errors.append("Skill name cannot contain consecutive hyphens")
53
+
54
+ if not all(c.isalnum() or c == "-" for c in name):
55
+ errors.append(
56
+ f"Skill name '{name}' contains invalid characters. "
57
+ "Only letters, digits, and hyphens are allowed."
58
+ )
59
+
60
+ if skill_dir:
61
+ dir_name = unicodedata.normalize("NFKC", skill_dir.name)
62
+ if dir_name != name:
63
+ errors.append(
64
+ f"Directory name '{skill_dir.name}' must match skill name '{name}'"
65
+ )
66
+
67
+ return errors
68
+
69
+
70
+ def _validate_description(description: str) -> list[str]:
71
+ """Validate description format."""
72
+ errors = []
73
+
74
+ if not description or not isinstance(description, str) or not description.strip():
75
+ errors.append("Field 'description' must be a non-empty string")
76
+ return errors
77
+
78
+ if len(description) > MAX_DESCRIPTION_LENGTH:
79
+ errors.append(
80
+ f"Description exceeds {MAX_DESCRIPTION_LENGTH} character limit "
81
+ f"({len(description)} chars)"
82
+ )
83
+
84
+ return errors
85
+
86
+
87
+ def _validate_compatibility(compatibility: str) -> list[str]:
88
+ """Validate compatibility format."""
89
+ errors = []
90
+
91
+ if not isinstance(compatibility, str):
92
+ errors.append("Field 'compatibility' must be a string")
93
+ return errors
94
+
95
+ if len(compatibility) > MAX_COMPATIBILITY_LENGTH:
96
+ errors.append(
97
+ f"Compatibility exceeds {MAX_COMPATIBILITY_LENGTH} character limit "
98
+ f"({len(compatibility)} chars)"
99
+ )
100
+
101
+ return errors
102
+
103
+
104
+ def _validate_metadata_fields(metadata: dict) -> list[str]:
105
+ """Validate that only allowed fields are present."""
106
+ errors = []
107
+
108
+ extra_fields = set(metadata.keys()) - ALLOWED_FIELDS
109
+ if extra_fields:
110
+ errors.append(
111
+ f"Unexpected fields in frontmatter: {', '.join(sorted(extra_fields))}. "
112
+ f"Only {sorted(ALLOWED_FIELDS)} are allowed."
113
+ )
114
+
115
+ return errors
116
+
117
+
118
+ def validate_metadata(metadata: dict, skill_dir: Optional[Path] = None) -> list[str]:
119
+ """Validate parsed skill metadata.
120
+
121
+ This is the core validation function that works on already-parsed metadata,
122
+ avoiding duplicate file I/O when called from the parser.
123
+
124
+ Args:
125
+ metadata: Parsed YAML frontmatter dictionary
126
+ skill_dir: Optional path to skill directory (for name-directory match check)
127
+
128
+ Returns:
129
+ List of validation error messages. Empty list means valid.
130
+ """
131
+ errors = []
132
+ errors.extend(_validate_metadata_fields(metadata))
133
+
134
+ if "name" not in metadata:
135
+ errors.append("Missing required field in frontmatter: name")
136
+ else:
137
+ errors.extend(_validate_name(metadata["name"], skill_dir))
138
+
139
+ if "description" not in metadata:
140
+ errors.append("Missing required field in frontmatter: description")
141
+ else:
142
+ errors.extend(_validate_description(metadata["description"]))
143
+
144
+ if "compatibility" in metadata:
145
+ errors.extend(_validate_compatibility(metadata["compatibility"]))
146
+
147
+ return errors
148
+
149
+
150
+ def validate(skill_dir: Path) -> list[str]:
151
+ """Validate a skill directory.
152
+
153
+ Args:
154
+ skill_dir: Path to the skill directory
155
+
156
+ Returns:
157
+ List of validation error messages. Empty list means valid.
158
+ """
159
+ skill_dir = Path(skill_dir)
160
+
161
+ if not skill_dir.exists():
162
+ return [f"Path does not exist: {skill_dir}"]
163
+
164
+ if not skill_dir.is_dir():
165
+ return [f"Not a directory: {skill_dir}"]
166
+
167
+ skill_md = find_skill_md(skill_dir)
168
+ if skill_md is None:
169
+ return ["Missing required file: SKILL.md"]
170
+
171
+ try:
172
+ content = skill_md.read_text()
173
+ metadata, _ = parse_frontmatter(content)
174
+ except ParseError as e:
175
+ return [str(e)]
176
+
177
+ return validate_metadata(metadata, skill_dir)
@@ -163,7 +163,7 @@ const SystemPromptEditor = {
163
163
  </div>
164
164
  </div>
165
165
  <div v-if="hasMessages" class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-3 py-2 text-sm">
166
- {{$threads.currentThread.value?.systemPrompt || 'No System Prompt was used' }}
166
+ <TextViewer prefsName="systemPrompt" :text="$threads.getCurrentThreadSystemPrompt() || 'No System Prompt was used'" />
167
167
  </div>
168
168
  <div v-else>
169
169
  <textarea
@@ -251,21 +251,17 @@ export default {
251
251
  }
252
252
  })
253
253
 
254
- ctx.chatRequestFilters.push(({ request, thread }) => {
254
+ ctx.chatRequestFilters.push(({ request, thread, context }) => {
255
255
 
256
- const hasSystemPrompt = request.messages.find(x => x.role === 'system')
256
+ const hasSystemPrompt = !!context.systemPrompt
257
+ console.log('system_prompts chatRequestFilters', hasSystemPrompt)
257
258
  if (hasSystemPrompt) {
258
259
  console.log('Already has system prompt', hasSystemPrompt.content)
259
260
  return
260
261
  }
261
262
 
262
- // Only add the selected system prompt for new requests
263
- if (ext.prefs.systemPrompt && request.messages.length <= 1) {
264
- // add message to start
265
- request.messages.unshift({
266
- role: 'system',
267
- content: ext.prefs.systemPrompt
268
- })
263
+ if (ext.prefs.systemPrompt) {
264
+ context.systemPrompt = ext.prefs.systemPrompt
269
265
  }
270
266
  })
271
267
 
@@ -656,7 +656,7 @@ export default {
656
656
  }
657
657
  })
658
658
 
659
- ctx.chatRequestFilters.push(({ request, thread }) => {
659
+ ctx.chatRequestFilters.push(({ request, thread, context }) => {
660
660
  // Tool Preferences
661
661
  const prefs = ctx.prefs
662
662
  if (prefs.onlyTools != null) {
llms/main.py CHANGED
@@ -56,7 +56,7 @@ try:
56
56
  except ImportError:
57
57
  HAS_PIL = False
58
58
 
59
- VERSION = "3.0.14"
59
+ VERSION = "3.0.15"
60
60
  _ROOT = None
61
61
  DEBUG = os.getenv("DEBUG") == "1"
62
62
  MOCK = os.getenv("MOCK") == "1"
@@ -857,7 +857,7 @@ def chat_to_prompt(chat):
857
857
  prompt = ""
858
858
  if "messages" in chat:
859
859
  for message in chat["messages"]:
860
- if message["role"] == "user":
860
+ if message.get("role") == "user":
861
861
  # if content is string
862
862
  if isinstance(message["content"], str):
863
863
  if prompt:
@@ -876,7 +876,7 @@ def chat_to_prompt(chat):
876
876
  def chat_to_system_prompt(chat):
877
877
  if "messages" in chat:
878
878
  for message in chat["messages"]:
879
- if message["role"] == "system":
879
+ if message.get("role") == "system":
880
880
  # if content is string
881
881
  if isinstance(message["content"], str):
882
882
  return message["content"]
@@ -904,7 +904,7 @@ def last_user_prompt(chat):
904
904
  prompt = ""
905
905
  if "messages" in chat:
906
906
  for message in chat["messages"]:
907
- if message["role"] == "user":
907
+ if message.get("role") == "user":
908
908
  # if content is string
909
909
  if isinstance(message["content"], str):
910
910
  prompt = message["content"]
@@ -1807,7 +1807,10 @@ async def g_chat_completion(chat, context=None):
1807
1807
  # If we get here, all providers failed
1808
1808
  if first_exception:
1809
1809
  raise first_exception
1810
- raise Exception("All providers failed")
1810
+
1811
+ e = Exception("All providers failed")
1812
+ await g_app.on_chat_error(e, context or {"chat": chat})
1813
+ raise e
1811
1814
 
1812
1815
 
1813
1816
  async def cli_chat(chat, tools=None, image=None, audio=None, file=None, args=None, raw=False):
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.14',
9
+ version: '3.0.15',
10
10
  base,
11
11
  prefsKey,
12
12
  welcome: 'Welcome to llms.py',
llms/ui/app.css CHANGED
@@ -424,9 +424,6 @@
424
424
  .right-0 {
425
425
  right: calc(var(--spacing) * 0);
426
426
  }
427
- .right-1 {
428
- right: calc(var(--spacing) * 1);
429
- }
430
427
  .right-2 {
431
428
  right: calc(var(--spacing) * 2);
432
429
  }
@@ -562,9 +559,6 @@
562
559
  .-mt-1 {
563
560
  margin-top: calc(var(--spacing) * -1);
564
561
  }
565
- .-mt-2 {
566
- margin-top: calc(var(--spacing) * -2);
567
- }
568
562
  .-mt-8 {
569
563
  margin-top: calc(var(--spacing) * -8);
570
564
  }
@@ -631,6 +625,12 @@
631
625
  .-mb-px {
632
626
  margin-bottom: -1px;
633
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
+ }
634
634
  .mb-1 {
635
635
  margin-bottom: calc(var(--spacing) * 1);
636
636
  }
@@ -830,15 +830,6 @@
830
830
  .h-screen {
831
831
  height: 100vh;
832
832
  }
833
- .max-h-10 {
834
- max-height: calc(var(--spacing) * 10);
835
- }
836
- .max-h-20 {
837
- max-height: calc(var(--spacing) * 20);
838
- }
839
- .max-h-50 {
840
- max-height: calc(var(--spacing) * 50);
841
- }
842
833
  .max-h-60 {
843
834
  max-height: calc(var(--spacing) * 60);
844
835
  }
@@ -917,6 +908,9 @@
917
908
  .w-16 {
918
909
  width: calc(var(--spacing) * 16);
919
910
  }
911
+ .w-28 {
912
+ width: calc(var(--spacing) * 28);
913
+ }
920
914
  .w-32 {
921
915
  width: calc(var(--spacing) * 32);
922
916
  }
@@ -1685,6 +1679,12 @@
1685
1679
  background-color: color-mix(in oklab, var(--color-gray-50) 50%, transparent);
1686
1680
  }
1687
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
+ }
1688
1688
  .bg-gray-100 {
1689
1689
  background-color: var(--color-gray-100);
1690
1690
  }
@@ -2524,6 +2524,10 @@
2524
2524
  --tw-ordinal: ordinal;
2525
2525
  font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
2526
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
+ }
2527
2531
  .overline {
2528
2532
  text-decoration-line: overline;
2529
2533
  }
@@ -2951,13 +2955,6 @@
2951
2955
  }
2952
2956
  }
2953
2957
  }
2954
- .group-hover\:opacity-50 {
2955
- &:is(:where(.group):hover *) {
2956
- @media (hover: hover) {
2957
- opacity: 50%;
2958
- }
2959
- }
2960
- }
2961
2958
  .group-hover\:opacity-75 {
2962
2959
  &:is(:where(.group):hover *) {
2963
2960
  @media (hover: hover) {
@@ -4913,6 +4910,14 @@
4913
4910
  }
4914
4911
  }
4915
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
+ }
4916
4921
  .dark\:bg-gray-900 {
4917
4922
  &:where(.dark, .dark *) {
4918
4923
  background-color: var(--color-gray-900);
@@ -6307,6 +6312,15 @@
6307
6312
  border-radius: 0 !important;
6308
6313
  overflow: auto !important;
6309
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
+ }
6310
6324
  .hljs {
6311
6325
  background: white;
6312
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
  }
@@ -56,7 +56,7 @@ function embedHtml(html) {
56
56
  export const TypeText = {
57
57
  template: `
58
58
  <div data-type="text" v-if="text.type === 'text'">
59
- <div v-html="html?.trim()" class="whitespace-pre-wrap"></div>
59
+ !<div v-html="html?.trim()" class="whitespace-pre-wrap"></div>
60
60
  </div>
61
61
  `,
62
62
  props: {
@@ -346,6 +346,124 @@ 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">
@@ -356,21 +474,8 @@ export const ToolArguments = {
356
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 data-arg="string" v-else-if="typeof v === 'string'" class="align-top py-2 px-4 text-sm whitespace-pre-wrap">
360
- <div v-if="v.length > 200" class="relative">
361
- <button type="button" @click="maximized[k] = !maximized[k]" class="absolute top-0 right-3 opacity-0 group-hover:opacity-50 transition-opacity duration-200 rounded focus:outline-none focus:ring-0">
362
- <div class="flex items-center space-x-1">
363
- <span class="text-xs text-gray-600 dark:text-gray-400" :title="v.length + ' characters'">{{ $fmt.humanifyNumber(v.length) }}</span>
364
- <svg class="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
365
- <path v-if="maximized[k]" fill="currentColor" d="M9 9H3V7h4V3h2zm0 6H3v2h4v4h2zm12 0h-6v6h2v-4h4zm-6-6h6V7h-4V3h-2z"/>
366
- <path v-else fill="currentColor" d="M3 3h6v2H5v4H3zm0 18h6v-2H5v-4H3zm12 0h6v-6h-2v4h-4zm6-18h-6v2h4v4h2z"/>
367
- </svg>
368
- </div>
369
- </button>
370
- <div v-if="!maximized[k]" class="max-h-60 overflow-y-auto">{{ v }}</div>
371
- <div v-else class="w-full h-full">{{ v }}</div>
372
- </div>
373
- <div v-else>{{ v }}</div>
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" />
374
479
  </td>
375
480
  <td data-arg="value" v-else class="align-top py-2 px-4 text-sm whitespace-pre-wrap">
376
481
  <HtmlFormat :value="v" :classes="$utils.htmlFormatClasses" />
@@ -457,9 +562,11 @@ export const ToolOutput = {
457
562
  </span>
458
563
  </div>
459
564
  </div>
460
- <div class="not-prose px-3 py-2">
461
- <pre v-if="$ctx.prefs.toolFormat !== 'preview' || !hasJsonStructure(output.content)" class="tool-output">{{ output.content }}</pre>
462
- <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">
463
570
  <HtmlFormat v-if="tryParseJson(output.content)" :value="tryParseJson(output.content)" :classes="$utils.htmlFormatClasses" />
464
571
  <div v-else class="text-gray-500 italic p-2">Invalid JSON content</div>
465
572
  </div>