@eventcatalog/core 5.5.1 → 5.6.0

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 (61) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +5 -5
  5. package/dist/{chunk-6KR5ZLEQ.js → chunk-7TTNTHHN.js} +10 -10
  6. package/dist/{chunk-K5QKWVI3.js → chunk-DEO4QEGO.js} +1 -1
  7. package/dist/{chunk-GPIKYBQ5.js → chunk-ETS4G6RR.js} +1 -1
  8. package/dist/{chunk-GZLVDQPM.js → chunk-T3U27UXC.js} +1 -1
  9. package/dist/{chunk-EBZQJP7F.js → chunk-XBRKEQ66.js} +1 -1
  10. package/dist/constants.cjs +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/docs/development/customization/05-resource-sidebar.md +1 -1
  13. package/dist/docs/development/guides/domains/09-reference.md +5 -1
  14. package/dist/docs/development/guides/resources/schemas/03-add-specifications-to-services/04-add-generic-specifications.md +139 -0
  15. package/dist/docs/development/guides/resources/schemas/03-add-specifications-to-services/_category_.json +1 -1
  16. package/dist/docs/development/guides/resources/services/08-reference.md +6 -1
  17. package/dist/eventcatalog.cjs +1 -1
  18. package/dist/eventcatalog.js +10 -10
  19. package/dist/generate.cjs +1 -1
  20. package/dist/generate.js +3 -3
  21. package/dist/utils/cli-logger.cjs +1 -1
  22. package/dist/utils/cli-logger.js +2 -2
  23. package/eventcatalog/public/icons/specification.svg +1 -0
  24. package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +38 -20
  25. package/eventcatalog/src/components/Grids/specification-utils.ts +7 -1
  26. package/eventcatalog/src/components/MDX/ResourceRef/ResourceRef.astro +1 -1
  27. package/eventcatalog/src/components/SchemaExplorer/SchemaCodeBlock.tsx +58 -0
  28. package/eventcatalog/src/components/SchemaExplorer/SchemaCodeModal.tsx +2 -1
  29. package/eventcatalog/src/components/SchemaExplorer/SchemaContentViewer.tsx +38 -76
  30. package/eventcatalog/src/components/SchemaExplorer/SchemaDetailsPanel.tsx +15 -11
  31. package/eventcatalog/src/components/SchemaExplorer/SchemaExplorer.tsx +171 -142
  32. package/eventcatalog/src/components/SchemaExplorer/SchemaListItem.tsx +5 -8
  33. package/eventcatalog/src/components/SchemaExplorer/types.ts +2 -0
  34. package/eventcatalog/src/components/SchemaExplorer/utils.ts +70 -1
  35. package/eventcatalog/src/content.config.ts +3 -1
  36. package/eventcatalog/src/features/ai/chat-api.ts +6 -1
  37. package/eventcatalog/src/features/mcp/apps/architecture-diagram/view.tsx +8 -2
  38. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +2 -2
  39. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +67 -67
  40. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +72 -72
  41. package/eventcatalog/src/features/mcp/apps/schema-viewer/view.tsx +2 -1
  42. package/eventcatalog/src/features/mcp/apps/shared/app-view.tsx +24 -5
  43. package/eventcatalog/src/features/mcp/mcp-apps.ts +6 -1
  44. package/eventcatalog/src/features/mcp/mcp-server.ts +2 -0
  45. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +2 -0
  46. package/eventcatalog/src/features/studio/tool-sync.ts +67 -3
  47. package/eventcatalog/src/features/tools/catalog-tools.ts +8 -3
  48. package/eventcatalog/src/pages/.well-known/api-catalog.ts +1 -11
  49. package/eventcatalog/src/pages/api-catalog/specifications/[collection]/[id]/[version]/[specification].ts +1 -11
  50. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/generic/[filename].astro +137 -0
  51. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/generic/_SpecificationSource.tsx +44 -0
  52. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/generic/_[filename].data.ts +75 -0
  53. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro +14 -1
  54. package/eventcatalog/src/stores/sidebar-store/builders/domain.ts +10 -1
  55. package/eventcatalog/src/stores/sidebar-store/builders/service.ts +8 -1
  56. package/eventcatalog/src/stores/sidebar-store/custom-sidebar.ts +3 -1
  57. package/eventcatalog/src/utils/collections/util.ts +38 -1
  58. package/eventcatalog/src/utils/icon.ts +8 -0
  59. package/eventcatalog/src/utils/node-graphs/utils/utils.ts +5 -2
  60. package/eventcatalog/src/utils/schema-explorer.ts +7 -2
  61. package/package.json +6 -6
@@ -1,30 +1,121 @@
1
- import { useState, useMemo, useEffect, useRef } from 'react';
2
- import { AdjustmentsHorizontalIcon, DocumentTextIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
1
+ import { useState, useMemo, useEffect, useRef, type ComponentType, type ReactNode } from 'react';
2
+ import {
3
+ AdjustmentsHorizontalIcon,
4
+ BoltIcon,
5
+ ChatBubbleLeftIcon,
6
+ CheckIcon,
7
+ DocumentCheckIcon,
8
+ DocumentTextIcon,
9
+ MagnifyingGlassIcon,
10
+ } from '@heroicons/react/24/outline';
3
11
  import type { CollectionMessageTypes } from '@types';
4
12
  import semver from 'semver';
5
13
  import SchemaListItem from './SchemaListItem';
6
14
  import SchemaDetailsPanel from './SchemaDetailsPanel';
7
15
  import Pagination from './Pagination';
8
16
  import type { SchemaItem } from './types';
17
+ import { getFormatBadge, getFormatOptions, getSchemaFormat, getSchemaIconUrl, normalizeSchemaFormat } from './utils';
9
18
 
10
- // Specification file types (OpenAPI, AsyncAPI, GraphQL)
11
- const SPEC_TYPES = ['openapi', 'asyncapi', 'graphql'];
12
- const HIDDEN_FORMAT_FILTERS = new Set(['graphql', 'gql', 'yaml', 'yml']);
19
+ // Labels for the formats getSchemaFormat returns
13
20
  const SCHEMA_TYPE_LABELS: Record<string, string> = {
14
21
  json: 'JSON Schema',
22
+ 'json-document': 'JSON',
15
23
  asyncapi: 'AsyncAPI',
16
24
  openapi: 'OpenAPI',
17
25
  graphql: 'GraphQL',
18
26
  avro: 'Avro',
19
- avsc: 'Avro',
20
27
  proto: 'Protobuf',
21
- protobuf: 'Protobuf',
22
28
  yaml: 'YAML',
23
- yml: 'YAML',
24
29
  xml: 'XML',
25
30
  xsd: 'XML Schema',
31
+ text: 'Text',
26
32
  };
27
33
 
34
+ const TYPE_ICONS: Record<string, ComponentType<{ className?: string }>> = {
35
+ events: BoltIcon,
36
+ commands: ChatBubbleLeftIcon,
37
+ queries: MagnifyingGlassIcon,
38
+ specifications: DocumentTextIcon,
39
+ 'data-contracts': DocumentCheckIcon,
40
+ };
41
+
42
+ function TypeIcon({ type }: { type: string }) {
43
+ const Icon = TYPE_ICONS[type] ?? DocumentTextIcon;
44
+ return <Icon className="h-3.5 w-3.5" />;
45
+ }
46
+
47
+ /** The same icon or badge a schema of this format shows in the list */
48
+ function FormatIcon({ format }: { format: string }) {
49
+ // A generic JSON file gets the plain JSON badge, not the JSON Schema icon
50
+ const iconUrl = format === 'json-document' ? undefined : getSchemaIconUrl({ schemaExtension: format });
51
+ if (iconUrl) return <img src={iconUrl} alt="" className="schema-icon h-3.5 w-3.5" />;
52
+ const { label, color } = getFormatBadge(format === 'json-document' ? 'json' : format);
53
+ return <span className={`font-mono text-[7px] font-bold uppercase ${color}`}>{label}</span>;
54
+ }
55
+
56
+ function FilterSection({ title, role, children }: { title: string; role?: 'radiogroup'; children: ReactNode }) {
57
+ return (
58
+ <fieldset>
59
+ <legend className="mb-1.5 text-[11px] font-semibold text-[rgb(var(--ec-page-text))]">{title}</legend>
60
+ <div role={role} aria-label={title} className="grid grid-cols-2 gap-x-3 gap-y-0.5">
61
+ {children}
62
+ </div>
63
+ </fieldset>
64
+ );
65
+ }
66
+
67
+ /** A checkbox (pick any) or radio (pick one) row in the filter panel */
68
+ function FilterOption({
69
+ label,
70
+ count,
71
+ icon,
72
+ isActive,
73
+ control,
74
+ onClick,
75
+ }: {
76
+ label: string;
77
+ count: number;
78
+ icon?: ReactNode;
79
+ isActive: boolean;
80
+ control: 'checkbox' | 'radio';
81
+ onClick: () => void;
82
+ }) {
83
+ return (
84
+ <button
85
+ type="button"
86
+ role={control}
87
+ aria-checked={isActive}
88
+ onClick={onClick}
89
+ className={`flex min-w-0 items-center gap-2 rounded-md px-1.5 py-1 text-left text-[11px] transition-colors hover:bg-[rgb(var(--ec-content-hover))] ${
90
+ isActive
91
+ ? 'text-[rgb(var(--ec-page-text))]'
92
+ : 'text-[rgb(var(--ec-page-text-muted))] hover:text-[rgb(var(--ec-page-text))]'
93
+ }`}
94
+ >
95
+ <span
96
+ aria-hidden
97
+ className={`flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center border transition-colors ${
98
+ control === 'radio' ? 'rounded-full' : 'rounded-[3px]'
99
+ } ${
100
+ isActive
101
+ ? 'border-[rgb(var(--ec-accent))] bg-[rgb(var(--ec-accent))] text-white'
102
+ : 'border-[rgb(var(--ec-page-text-muted)/0.5)] bg-[rgb(var(--ec-dropdown-bg))]'
103
+ }`}
104
+ >
105
+ {isActive &&
106
+ (control === 'radio' ? (
107
+ <span className="h-1.5 w-1.5 rounded-full bg-white" />
108
+ ) : (
109
+ <CheckIcon className="h-2.5 w-2.5 stroke-[3]" />
110
+ ))}
111
+ </span>
112
+ {icon && <span className="flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">{icon}</span>}
113
+ <span className={`min-w-0 flex-1 truncate ${isActive ? 'font-medium' : ''}`}>{label}</span>
114
+ <span className="flex-shrink-0 text-[10px] tabular-nums text-[rgb(var(--ec-page-text-muted)/0.8)]">{count}</span>
115
+ </button>
116
+ );
117
+ }
118
+
28
119
  /** Resolve the spec filename for a schema item, with consistent fallback. */
29
120
  function getSpecFile(item: SchemaItem): string {
30
121
  return item.specFilenameWithoutExtension || item.specName || '';
@@ -73,7 +164,7 @@ export default function SchemaExplorer({ schemas }: SchemaExplorerProps) {
73
164
  const [selectedSchemaType, setSelectedSchemaType] = useState<'all' | string>(() => {
74
165
  if (typeof window !== 'undefined') {
75
166
  const stored = localStorage.getItem('schemaRegistrySelectedSchemaType');
76
- return stored !== null ? stored : 'all';
167
+ return stored !== null && stored !== 'all' ? normalizeSchemaFormat(stored) : 'all';
77
168
  }
78
169
  return 'all';
79
170
  });
@@ -164,36 +255,27 @@ export default function SchemaExplorer({ schemas }: SchemaExplorerProps) {
164
255
  return Array.from(messagesByIdAndVersions.values()).map((versions) => versions[0]);
165
256
  }, [messagesByIdAndVersions]);
166
257
 
167
- const schemaTypes = useMemo(() => {
168
- const types = new Set<string>();
169
- latestMessages.forEach((msg) => {
170
- if (msg.schemaExtension && !HIDDEN_FORMAT_FILTERS.has(msg.schemaExtension.toLowerCase())) {
171
- types.add(msg.schemaExtension.toLowerCase());
172
- }
173
- });
174
- return Array.from(types).sort();
175
- }, [latestMessages]);
258
+ // Schemas of the selected types; the format filter offers (and counts) only the formats among them
259
+ const typeFilteredMessages = useMemo(() => {
260
+ if (selectedTypes.size === 0) return latestMessages;
261
+ return latestMessages.filter(
262
+ (msg) =>
263
+ selectedTypes.has(msg.collection as CollectionMessageTypes) ||
264
+ (selectedTypes.has('specifications') && Boolean(msg.specType)) ||
265
+ (selectedTypes.has('data-contracts') && msg.collection === 'data-products')
266
+ );
267
+ }, [latestMessages, selectedTypes]);
268
+
269
+ const formatOptions = useMemo(
270
+ () => getFormatOptions(typeFilteredMessages, selectedSchemaType === 'all' ? undefined : selectedSchemaType),
271
+ [typeFilteredMessages, selectedSchemaType]
272
+ );
176
273
 
177
274
  const filteredMessages = useMemo(() => {
178
- let result = [...latestMessages];
179
-
180
- if (selectedTypes.size > 0) {
181
- result = result.filter((msg) => {
182
- if (selectedTypes.has(msg.collection as CollectionMessageTypes)) {
183
- return true;
184
- }
185
- if (selectedTypes.has('specifications') && SPEC_TYPES.includes(msg.schemaExtension?.toLowerCase() || '')) {
186
- return true;
187
- }
188
- if (selectedTypes.has('data-contracts') && msg.collection === 'data-products') {
189
- return true;
190
- }
191
- return false;
192
- });
193
- }
275
+ let result = [...typeFilteredMessages];
194
276
 
195
277
  if (selectedSchemaType !== 'all') {
196
- result = result.filter((msg) => msg.schemaExtension?.toLowerCase() === selectedSchemaType);
278
+ result = result.filter((msg) => getSchemaFormat(msg) === selectedSchemaType);
197
279
  }
198
280
 
199
281
  if (searchQuery) {
@@ -213,7 +295,7 @@ export default function SchemaExplorer({ schemas }: SchemaExplorerProps) {
213
295
  });
214
296
 
215
297
  return result;
216
- }, [latestMessages, searchQuery, selectedTypes, selectedSchemaType]);
298
+ }, [typeFilteredMessages, searchQuery, selectedSchemaType]);
217
299
 
218
300
  const totalPages = Math.ceil(filteredMessages.length / ITEMS_PER_PAGE);
219
301
  const paginatedMessages = useMemo(() => {
@@ -338,11 +420,10 @@ export default function SchemaExplorer({ schemas }: SchemaExplorerProps) {
338
420
 
339
421
  const stats = useMemo(() => {
340
422
  return {
341
- total: latestMessages.length,
342
423
  events: latestMessages.filter((m) => m.collection === 'events').length,
343
424
  commands: latestMessages.filter((m) => m.collection === 'commands').length,
344
425
  queries: latestMessages.filter((m) => m.collection === 'queries').length,
345
- specifications: latestMessages.filter((m) => SPEC_TYPES.includes(m.schemaExtension?.toLowerCase() || '')).length,
426
+ specifications: latestMessages.filter((m) => Boolean(m.specType)).length,
346
427
  dataContracts: latestMessages.filter((m) => m.collection === 'data-products').length,
347
428
  };
348
429
  }, [latestMessages]);
@@ -366,7 +447,6 @@ export default function SchemaExplorer({ schemas }: SchemaExplorerProps) {
366
447
  const hasActiveFilters = searchQuery.length > 0 || selectedTypes.size > 0 || selectedSchemaType !== 'all';
367
448
  const activeFilterCount = (selectedTypes.size > 0 ? 1 : 0) + (selectedSchemaType !== 'all' ? 1 : 0);
368
449
  const collectionTabs = [
369
- { key: 'all', label: 'All', count: stats.total },
370
450
  { key: 'events', label: 'Events', count: stats.events },
371
451
  { key: 'commands', label: 'Commands', count: stats.commands },
372
452
  { key: 'queries', label: 'Queries', count: stats.queries },
@@ -402,7 +482,7 @@ export default function SchemaExplorer({ schemas }: SchemaExplorerProps) {
402
482
  />
403
483
  </div>
404
484
 
405
- {schemaTypes.length > 0 && (
485
+ {latestMessages.length > 0 && (
406
486
  <button
407
487
  onClick={() => setShowFormatFilters((prev) => !prev)}
408
488
  aria-pressed={showFormatFilters}
@@ -423,110 +503,59 @@ export default function SchemaExplorer({ schemas }: SchemaExplorerProps) {
423
503
  </div>
424
504
 
425
505
  {showFormatFilters && latestMessages.length > 0 && (
426
- <div className="flex-shrink-0 border-b border-[rgb(var(--ec-page-border))] px-4 py-3">
427
- <div className="rounded-xl border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-content-hover)/0.45)] p-3">
428
- <div className="mb-3 flex items-center justify-between gap-3">
429
- <span className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[rgb(var(--ec-page-text-muted))]">
430
- Filters
431
- </span>
432
- {hasActiveFilters && (
433
- <button
434
- onClick={() => {
435
- setSearchQuery('');
436
- clearTypeFilters();
437
- setSelectedSchemaType('all');
438
- }}
439
- className="text-[11px] font-medium text-[rgb(var(--ec-accent))] hover:opacity-80"
440
- >
441
- Reset
442
- </button>
443
- )}
444
- </div>
445
-
446
- <div>
447
- <div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-[rgb(var(--ec-page-text-muted))]">
448
- Type
449
- </div>
450
- <div className="flex flex-wrap gap-1.5">
451
- {collectionTabs.map((tab) => {
452
- const isAll = tab.key === 'all';
453
- const isActive = isAll ? selectedTypes.size === 0 : selectedTypes.has(tab.key as CollectionMessageTypes);
454
-
455
- return (
456
- <button
457
- key={tab.key}
458
- onClick={() => {
459
- if (isAll) {
460
- clearTypeFilters();
461
- return;
462
- }
463
- toggleType(tab.key as CollectionMessageTypes | 'specifications' | 'data-contracts');
464
- }}
465
- className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[10px] font-medium transition-all ${
466
- isActive
467
- ? 'border-[rgb(var(--ec-accent)/0.5)] bg-[rgb(var(--ec-accent)/0.16)] text-[rgb(var(--ec-page-text))] shadow-[0_0_0_1px_rgb(var(--ec-accent)/0.2)]'
468
- : 'border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-dropdown-bg))] text-[rgb(var(--ec-page-text-muted))] hover:border-[rgb(var(--ec-page-text-muted)/0.3)] hover:text-[rgb(var(--ec-page-text))]'
469
- }`}
470
- >
471
- <span>{tab.label}</span>
472
- <span
473
- className={`rounded-sm px-1 py-0.5 text-[8px] font-semibold tabular-nums ${
474
- isActive
475
- ? 'bg-[rgb(var(--ec-accent)/0.18)] text-[rgb(var(--ec-page-text))]'
476
- : 'bg-[rgb(var(--ec-content-hover))] text-[rgb(var(--ec-page-text-muted))]'
477
- }`}
478
- >
479
- {tab.count}
480
- </span>
481
- </button>
482
- );
483
- })}
484
- </div>
485
- </div>
486
-
487
- {schemaTypes.length > 0 && (
488
- <div className="mt-3">
489
- <div className="mb-2 flex items-center justify-between gap-3">
490
- <span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-[rgb(var(--ec-page-text-muted))]">
491
- Format
492
- </span>
493
- {selectedSchemaType !== 'all' && (
494
- <button
495
- onClick={() => setSelectedSchemaType('all')}
496
- className="text-[10px] font-medium text-[rgb(var(--ec-accent))] hover:opacity-80"
497
- >
498
- Clear
499
- </button>
500
- )}
501
- </div>
502
- <div className="flex flex-wrap gap-1.5">
503
- <button
504
- onClick={() => setSelectedSchemaType('all')}
505
- className={`rounded-md border px-2 py-1 text-[10px] font-medium transition-all ${
506
- selectedSchemaType === 'all'
507
- ? 'border-[rgb(var(--ec-accent)/0.45)] bg-[rgb(var(--ec-accent)/0.16)] text-[rgb(var(--ec-page-text))]'
508
- : 'border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-dropdown-bg))] text-[rgb(var(--ec-page-text-muted))] hover:text-[rgb(var(--ec-page-text))]'
509
- }`}
510
- >
511
- All formats
512
- </button>
513
- {schemaTypes.map((type) => (
514
- <button
515
- key={type}
516
- onClick={() => setSelectedSchemaType(selectedSchemaType === type ? 'all' : type)}
517
- className={`rounded-md border px-2 py-1 text-[10px] font-medium transition-all ${
518
- selectedSchemaType === type
519
- ? 'border-[rgb(var(--ec-accent)/0.45)] bg-[rgb(var(--ec-accent)/0.16)] text-[rgb(var(--ec-page-text))]'
520
- : 'border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-dropdown-bg))] text-[rgb(var(--ec-page-text-muted))] hover:text-[rgb(var(--ec-page-text))]'
521
- }`}
522
- >
523
- {SCHEMA_TYPE_LABELS[type] || type.charAt(0).toUpperCase() + type.slice(1)}
524
- </button>
525
- ))}
526
- </div>
527
- </div>
506
+ <div className="flex-shrink-0 space-y-3 border-b border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-content-hover)/0.3)] px-4 py-3">
507
+ <div className="flex items-center justify-between">
508
+ <span className="text-[12px] font-semibold text-[rgb(var(--ec-page-text))]">Filters</span>
509
+ {hasActiveFilters && (
510
+ <button
511
+ onClick={() => {
512
+ setSearchQuery('');
513
+ clearTypeFilters();
514
+ setSelectedSchemaType('all');
515
+ }}
516
+ className="text-[11px] font-medium text-[rgb(var(--ec-accent))] hover:opacity-80"
517
+ >
518
+ Reset
519
+ </button>
528
520
  )}
529
521
  </div>
522
+
523
+ <FilterSection title="Type">
524
+ {collectionTabs.map((tab) => (
525
+ <FilterOption
526
+ key={tab.key}
527
+ control="checkbox"
528
+ label={tab.label}
529
+ count={tab.count}
530
+ icon={<TypeIcon type={tab.key} />}
531
+ isActive={selectedTypes.has(tab.key as CollectionMessageTypes)}
532
+ onClick={() => toggleType(tab.key as CollectionMessageTypes | 'specifications' | 'data-contracts')}
533
+ />
534
+ ))}
535
+ </FilterSection>
536
+
537
+ {formatOptions.length > 0 && (
538
+ <FilterSection title="Format" role="radiogroup">
539
+ <FilterOption
540
+ control="radio"
541
+ label="All formats"
542
+ count={typeFilteredMessages.length}
543
+ isActive={selectedSchemaType === 'all'}
544
+ onClick={() => setSelectedSchemaType('all')}
545
+ />
546
+ {formatOptions.map(({ format, count }) => (
547
+ <FilterOption
548
+ key={format}
549
+ control="radio"
550
+ label={SCHEMA_TYPE_LABELS[format] || format.charAt(0).toUpperCase() + format.slice(1)}
551
+ count={count}
552
+ icon={<FormatIcon format={format} />}
553
+ isActive={selectedSchemaType === format}
554
+ onClick={() => setSelectedSchemaType(format)}
555
+ />
556
+ ))}
557
+ </FilterSection>
558
+ )}
530
559
  </div>
531
560
  )}
532
561
 
@@ -1,6 +1,5 @@
1
- import { buildUrl } from '@utils/url-builder';
2
1
  import type { SchemaItem } from './types';
3
- import { getSchemaTypeLabel, ICON_SPECS, getFormatBadge, extractServiceName } from './utils';
2
+ import { getSchemaDisplayLabel, getSchemaIconUrl, getFormatBadge, extractServiceName } from './utils';
4
3
 
5
4
  interface SchemaListItemProps {
6
5
  message: SchemaItem;
@@ -34,7 +33,7 @@ function getNamespace(message: SchemaItem): string | null {
34
33
 
35
34
  export default function SchemaListItem({ message, isSelected, versions, onClick, itemRef }: SchemaListItemProps) {
36
35
  const ext = message.schemaExtension?.toLowerCase() || '';
37
- const iconSpec = ICON_SPECS[ext];
36
+ const iconUrl = getSchemaIconUrl(message);
38
37
  const { label: formatLabel, color: formatColor } = getFormatBadge(ext);
39
38
  const namespace = getNamespace(message);
40
39
  const summary = message.data.summary || `Browse the ${message.data.name} schema.`;
@@ -52,8 +51,8 @@ export default function SchemaListItem({ message, isSelected, versions, onClick,
52
51
  >
53
52
  <div className="flex items-start justify-between gap-2">
54
53
  <div className="mt-0.5 flex h-4.5 w-4.5 flex-shrink-0 items-center justify-center rounded-sm border border-[rgb(var(--ec-page-border)/0.55)] bg-[rgb(var(--ec-content-hover))]">
55
- {iconSpec ? (
56
- <img src={buildUrl(`/icons/${iconSpec}.svg`, true)} alt={`${ext} icon`} className="schema-icon h-2.5 w-2.5" />
54
+ {iconUrl ? (
55
+ <img src={iconUrl} alt={`${ext} icon`} className="schema-icon h-2.5 w-2.5" />
57
56
  ) : (
58
57
  <span className={`font-mono text-[6px] font-bold uppercase ${formatColor}`}>{formatLabel}</span>
59
58
  )}
@@ -82,9 +81,7 @@ export default function SchemaListItem({ message, isSelected, versions, onClick,
82
81
  <div className="mt-1.5 flex items-end justify-between gap-2">
83
82
  <div className="min-w-0">
84
83
  <div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[9px] text-[rgb(var(--ec-page-text-muted))]">
85
- <span className="font-medium text-[rgb(var(--ec-page-text-muted))]">
86
- {getSchemaTypeLabel(message.schemaExtension)}
87
- </span>
84
+ <span className="font-medium text-[rgb(var(--ec-page-text-muted))]">{getSchemaDisplayLabel(message)}</span>
88
85
  {namespace && (
89
86
  <>
90
87
  <span className="opacity-35">&rsaquo;</span>
@@ -79,6 +79,8 @@ export interface SchemaItem {
79
79
  schemaName?: string;
80
80
  source?: SchemaSourceInfo;
81
81
  specType?: string;
82
+ /** A generic specification's custom icon */
83
+ specIcon?: string;
82
84
  specName?: string;
83
85
  specFilenameWithoutExtension?: string;
84
86
  // For data contracts
@@ -1,5 +1,6 @@
1
- import type { SchemaRelationshipCollection } from './types';
1
+ import type { SchemaItem, SchemaRelationshipCollection } from './types';
2
2
  import { buildUrl } from '@utils/url-builder';
3
+ import { getGenericSpecificationIcon, resolveIconUrl } from '@utils/icon';
3
4
 
4
5
  // Schema types that should use SVG icons
5
6
  export const ICON_SPECS: Record<string, string> = {
@@ -14,6 +15,72 @@ export const ICON_SPECS: Record<string, string> = {
14
15
  json: 'json-schema',
15
16
  };
16
17
 
18
+ type SchemaFormat = Pick<SchemaItem, 'schemaExtension' | 'specType' | 'specIcon'>;
19
+
20
+ /** The icon for a schema, or undefined when it shows a format badge instead */
21
+ export function getSchemaIconUrl(message: SchemaFormat): string | undefined {
22
+ if (message.specType === 'generic') return resolveIconUrl(getGenericSpecificationIcon(message.specIcon));
23
+ const iconSpec = ICON_SPECS[message.schemaExtension?.toLowerCase() || ''];
24
+ return iconSpec ? buildUrl(`/icons/${iconSpec}.svg`, true) : undefined;
25
+ }
26
+
27
+ // A generic specification's extension is only its syntax, so a JSON file is not called a JSON Schema
28
+ const GENERIC_SPECIFICATION_LABELS: Record<string, string> = { json: 'JSON', yaml: 'YAML', xml: 'XML', text: 'Text' };
29
+
30
+ export const getSchemaDisplayLabel = (message: SchemaFormat): string =>
31
+ (message.specType === 'generic' && GENERIC_SPECIFICATION_LABELS[message.schemaExtension || '']) ||
32
+ getSchemaTypeLabel(message.schemaExtension);
33
+
34
+ // Extensions that are the same format, so they share one filter
35
+ const FORMAT_ALIASES: Record<string, string> = { yml: 'yaml', gql: 'graphql', avsc: 'avro', protobuf: 'proto' };
36
+
37
+ /**
38
+ * The format a schema is filtered by in the Schema Explorer. A generic specification's extension is only its syntax,
39
+ * so a generic JSON file is plain JSON (`json-document`), not a JSON Schema.
40
+ */
41
+ export function getSchemaFormat(message: Pick<SchemaItem, 'schemaExtension' | 'specType'>): string | undefined {
42
+ if (!message.schemaExtension) return undefined;
43
+ if (message.specType === 'generic' && message.schemaExtension.toLowerCase() === 'json') return 'json-document';
44
+ return normalizeSchemaFormat(message.schemaExtension);
45
+ }
46
+
47
+ /** A format with extensions of the same format merged, so a filter saved as `avsc` still selects Avro */
48
+ export const normalizeSchemaFormat = (format: string): string => FORMAT_ALIASES[format.toLowerCase()] ?? format.toLowerCase();
49
+
50
+ /**
51
+ * The extension to download a schema with. A generic specification's schemaExtension is only the syntax it's
52
+ * highlighted in (a WSDL is `xml`), so it keeps its own file's extension.
53
+ */
54
+ export function getSchemaDownloadExtension(message: Pick<SchemaItem, 'schemaExtension' | 'specType' | 'data'>): string {
55
+ const fileExtension =
56
+ message.specType === 'generic'
57
+ ? message.data.schemaPath
58
+ ?.split('/')
59
+ .pop()
60
+ ?.match(/\.([^.]+)$/)?.[1]
61
+ : undefined;
62
+ return fileExtension || message.schemaExtension || 'json';
63
+ }
64
+
65
+ /**
66
+ * The formats of the given schemas with how many use each, most used first. The selected format is always included
67
+ * (with a count of 0 when none of the schemas use it) so it can be cleared.
68
+ */
69
+ export function getFormatOptions(
70
+ messages: Pick<SchemaItem, 'schemaExtension' | 'specType'>[],
71
+ selectedFormat?: string
72
+ ): { format: string; count: number }[] {
73
+ const counts = new Map<string, number>();
74
+ for (const message of messages) {
75
+ const format = getSchemaFormat(message);
76
+ if (format) counts.set(format, (counts.get(format) ?? 0) + 1);
77
+ }
78
+ if (selectedFormat && !counts.has(selectedFormat)) counts.set(selectedFormat, 0);
79
+ return [...counts]
80
+ .map(([format, count]) => ({ format, count }))
81
+ .sort((a, b) => b.count - a.count || a.format.localeCompare(b.format));
82
+ }
83
+
17
84
  // Schema format badge config for non-icon types: short label + color
18
85
  export function getFormatBadge(ext?: string): { label: string; color: string } {
19
86
  const type = ext?.toLowerCase();
@@ -105,6 +172,8 @@ export const getLanguageForHighlight = (extension?: string): string => {
105
172
  case 'ts':
106
173
  case 'typescript':
107
174
  return 'typescript';
175
+ case 'text':
176
+ return 'text';
108
177
  case 'js':
109
178
  case 'javascript':
110
179
  return 'javascript';
@@ -146,9 +146,11 @@ const baseSchema = z.object({
146
146
  }),
147
147
  z.array(
148
148
  z.object({
149
- type: z.enum(['openapi', 'asyncapi', 'graphql']),
149
+ type: z.enum(['openapi', 'asyncapi', 'graphql', 'generic']),
150
150
  path: z.string(),
151
151
  name: z.string().optional(),
152
+ // Used by generic specifications only
153
+ icon: z.string().optional(),
152
154
  headers: z.record(z.string(), z.string()).optional(),
153
155
  })
154
156
  ),
@@ -35,6 +35,10 @@ import {
35
35
 
36
36
  const catalogDirectory = process.env.PROJECT_DIR || process.cwd();
37
37
 
38
+ // How many model calls one answer may take. Questions across several domains need many tool calls, so the last
39
+ // step has tools turned off: the model has to answer with what it found, rather than stop without a reply.
40
+ const MAX_STEPS = 10;
41
+
38
42
  export const defaultConfiguration = {
39
43
  temperature: 0.4,
40
44
  topP: 0.9,
@@ -394,7 +398,8 @@ export const POST = async ({ request }: APIContext<{ question: string; messages:
394
398
  instructions: getBaseSystemPrompt(referrer ?? ''),
395
399
  messages: await convertToModelMessages(messages),
396
400
  temperature: modelConfiguration?.temperature ?? 0.7,
397
- stopWhen: isStepCount(5),
401
+ stopWhen: isStepCount(MAX_STEPS),
402
+ prepareStep: ({ stepNumber }) => (stepNumber === MAX_STEPS - 1 ? { toolChoice: 'none' } : undefined),
398
403
  onError: (error) => {
399
404
  console.error('[Chat] On error', error);
400
405
  },
@@ -16,7 +16,7 @@ import type { ArchitectureDiagramPayload } from './shared';
16
16
  const LEVEL_OF_DETAIL = { overview: 1, services: 2, full: 3 } as const;
17
17
  import { NodeChat, describeNode, describeNodeForModel, type SelectedNode } from './node-chat';
18
18
  import { parseAskLink, parseDiagramLink, withAskMenuItem, type DiagramLink } from './links';
19
- import { getToolResultPayload } from '../shared/app-view';
19
+ import { fullscreenStyle, getToolResultPayload } from '../shared/app-view';
20
20
  import { RESOURCE_TYPE_LABELS } from '../shared/resource-types';
21
21
  import { VIEWER_META_KEY, VIEWER_VIEW_TOOL, type ViewerPayload } from '../viewer/shared';
22
22
 
@@ -377,7 +377,13 @@ export function ArchitectureDiagramView({ app, payload: toolPayload, hostContext
377
377
  const graphId = `mcp-${resource.collection}-${resource.id}-${resource.version}`;
378
378
 
379
379
  return (
380
- <div style={{ height: isFullscreen ? '100vh' : INLINE_HEIGHT, display: 'flex', flexDirection: 'column' }}>
380
+ <div
381
+ style={{
382
+ ...(isFullscreen ? fullscreenStyle(hostContext) : { height: INLINE_HEIGHT }),
383
+ display: 'flex',
384
+ flexDirection: 'column',
385
+ }}
386
+ >
381
387
  <div className="ec-mcp-header">
382
388
  <div className="ec-mcp-title">
383
389
  {diagrams.length > 1 && (
@@ -17,7 +17,7 @@ import visualiserStyles from '@eventcatalog/visualiser/styles.css?inline';
17
17
  import StudioDesigner, { type SelectedNode } from '@features/studio/components/StudioDesigner';
18
18
  import { getAgent } from '@features/studio/agents';
19
19
  import { CANVAS_STATUS_LOOK, STATUS } from '@eventcatalog/visualiser/studio';
20
- import { getToolResultPayload, mountView, useMcpAppView } from '../shared/app-view';
20
+ import { fullscreenStyle, getToolResultPayload, mountView, useMcpAppView } from '../shared/app-view';
21
21
  import {
22
22
  CANVAS_CATALOG_TOOL,
23
23
  CANVAS_META_KEY,
@@ -219,7 +219,7 @@ function CanvasApp() {
219
219
  const agentOf = (view: CanvasView) => view.agent ?? getAgent(undefined, hostName);
220
220
 
221
221
  return (
222
- <div className="flex flex-col" style={{ height: fullscreen ? '100vh' : INLINE_HEIGHT }}>
222
+ <div className="flex flex-col" style={fullscreen ? fullscreenStyle(hostContext) : { height: INLINE_HEIGHT }}>
223
223
  <div className="flex items-center justify-between border-b px-3 py-1.5 border-[rgb(var(--ec-page-border))]">
224
224
  <span className="flex min-w-0 items-center gap-1">
225
225
  {canvas && (