@eventcatalog/core 5.7.0 → 5.7.2
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.
- package/dist/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +1 -1
- package/dist/analytics/log-build.js +3 -3
- package/dist/{chunk-FMWQ2YYN.js → chunk-B3FUBDJN.js} +1 -1
- package/dist/{chunk-4CV7VC4K.js → chunk-EPI5BHEU.js} +1 -1
- package/dist/{chunk-MOPKQMQP.js → chunk-NNTWQNQ5.js} +1 -1
- package/dist/{chunk-BE5PTWNC.js → chunk-OR4VBY3F.js} +1 -1
- package/dist/{chunk-22KZ4TJF.js → chunk-TYZENGGJ.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +1 -1
- package/dist/eventcatalog.js +5 -5
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +702 -128
- package/eventcatalog/src/components/ChatPanel/ChatPanelButton.tsx +3 -2
- package/eventcatalog/src/components/ChatPanel/assistant-events.ts +37 -0
- package/eventcatalog/src/components/ChatPanel/panel-width.ts +28 -0
- package/eventcatalog/src/components/MDX/ResourceRef/ResourceRef.astro +2 -34
- package/eventcatalog/src/features/ai/chat-api.ts +24 -5
- package/eventcatalog/src/features/mcp/apps/generated/canvas.html +72 -71
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +1 -1
- package/eventcatalog/src/features/studio/agent-choreography.ts +84 -16
- package/eventcatalog/src/features/studio/api/canvases-api.ts +3 -0
- package/eventcatalog/src/features/studio/canvas-actions.ts +330 -15
- package/eventcatalog/src/features/studio/catalog.ts +5 -3
- package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +14 -0
- package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +116 -13
- package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +1 -1
- package/eventcatalog/src/features/studio/pages/[id].astro +2 -0
- package/eventcatalog/src/features/studio/server/assistant-tools.ts +207 -0
- package/eventcatalog/src/features/studio/server/canvas-agent.ts +27 -0
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +3 -15
- package/eventcatalog/src/utils/resource-reference-icons.ts +37 -0
- package/package.json +4 -4
|
@@ -1,5 +1,23 @@
|
|
|
1
|
-
import { useEffect, useRef, useCallback, useState, useMemo, memo } from 'react';
|
|
2
|
-
import {
|
|
1
|
+
import { useEffect, useRef, useCallback, useState, useMemo, memo, type RefObject } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
X,
|
|
4
|
+
Square,
|
|
5
|
+
Trash2,
|
|
6
|
+
Sparkles,
|
|
7
|
+
Copy,
|
|
8
|
+
Check,
|
|
9
|
+
Maximize2,
|
|
10
|
+
Minimize2,
|
|
11
|
+
Wrench,
|
|
12
|
+
ChevronDown,
|
|
13
|
+
ChevronLeft,
|
|
14
|
+
ChevronRight,
|
|
15
|
+
MessageSquare,
|
|
16
|
+
LocateFixed,
|
|
17
|
+
OctagonAlert,
|
|
18
|
+
TriangleAlert,
|
|
19
|
+
Info,
|
|
20
|
+
} from 'lucide-react';
|
|
3
21
|
import { useChat } from '@ai-sdk/react';
|
|
4
22
|
import { DefaultChatTransport } from 'ai';
|
|
5
23
|
import ReactMarkdown from 'react-markdown';
|
|
@@ -10,6 +28,17 @@ import * as Dialog from '@radix-ui/react-dialog';
|
|
|
10
28
|
import * as Popover from '@radix-ui/react-popover';
|
|
11
29
|
import { buildUrl } from '@utils/url-builder';
|
|
12
30
|
import OfflineReply, { ConfigurationErrorReply, getChatError, offlineReplyText } from './OfflineReply';
|
|
31
|
+
import { clampPanelWidth, MAX_PANEL_WIDTH, MIN_PANEL_WIDTH, PANEL_WIDTH, readPanelWidth, savePanelWidth } from './panel-width';
|
|
32
|
+
import {
|
|
33
|
+
ASSISTANT_DRAWING_EVENT,
|
|
34
|
+
CANVAS_FOCUS_EVENT,
|
|
35
|
+
CANVAS_HOTSPOT_EVENT,
|
|
36
|
+
canvasRefIn,
|
|
37
|
+
nodesInFocusLink,
|
|
38
|
+
} from './assistant-events';
|
|
39
|
+
import { getResourceReferenceColorName } from '@utils/resource-reference-colors';
|
|
40
|
+
import { tailwind500RgbByColor } from '@utils/collection-colors';
|
|
41
|
+
import { getResourceReferenceIcon } from '@utils/resource-reference-icons';
|
|
13
42
|
|
|
14
43
|
interface ToolMetadata {
|
|
15
44
|
name: string;
|
|
@@ -23,10 +52,6 @@ const CHAT_PANEL_STYLES = `
|
|
|
23
52
|
0%, 100% { transform: scale(1); opacity: .7; }
|
|
24
53
|
50% { transform: scale(1.06); opacity: 1; }
|
|
25
54
|
}
|
|
26
|
-
@keyframes ec-chat-dot {
|
|
27
|
-
0%, 80%, 100% { transform: translateY(0); opacity: .35; }
|
|
28
|
-
40% { transform: translateY(-3px); opacity: 1; }
|
|
29
|
-
}
|
|
30
55
|
.ec-chat-surface { font-weight: 400; }
|
|
31
56
|
.ec-chat-avatar { background: rgb(var(--ec-accent) / .06); animation: ec-chat-breathe 5s ease-in-out infinite; }
|
|
32
57
|
/* The closed panel stays on the page (off screen): a running animation there still costs every frame */
|
|
@@ -38,9 +63,58 @@ const CHAT_PANEL_STYLES = `
|
|
|
38
63
|
.ec-chat-offline > :nth-child(2) { animation-delay: 100ms; }
|
|
39
64
|
.ec-chat-offline > :nth-child(3) { animation-delay: 220ms; }
|
|
40
65
|
.ec-chat-offline > :nth-child(4) { animation-delay: 340ms; }
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
66
|
+
/* What the assistant's doing while it works: a card with a sheen passing over it, its steps so far ticked and the
|
|
67
|
+
one it's on spinning, its words shimmering */
|
|
68
|
+
@keyframes ec-chat-sheen {
|
|
69
|
+
from { background-position: 200% 0; }
|
|
70
|
+
to { background-position: -200% 0; }
|
|
71
|
+
}
|
|
72
|
+
@keyframes ec-chat-spin { to { transform: rotate(360deg); } }
|
|
73
|
+
.ec-chat-activity {
|
|
74
|
+
position: relative;
|
|
75
|
+
overflow: hidden;
|
|
76
|
+
border: 1px solid rgb(var(--ec-accent) / .18);
|
|
77
|
+
border-radius: 14px;
|
|
78
|
+
background: rgb(var(--ec-accent) / .03);
|
|
79
|
+
box-shadow: 0 6px 20px rgb(var(--ec-accent) / .05);
|
|
80
|
+
}
|
|
81
|
+
.ec-chat-activity::before {
|
|
82
|
+
content: '';
|
|
83
|
+
position: absolute;
|
|
84
|
+
inset: 0;
|
|
85
|
+
pointer-events: none;
|
|
86
|
+
background: linear-gradient(110deg, transparent 30%, rgb(var(--ec-accent) / .09) 50%, transparent 70%);
|
|
87
|
+
background-size: 200% 100%;
|
|
88
|
+
animation: ec-chat-sheen 2.2s linear infinite;
|
|
89
|
+
}
|
|
90
|
+
.ec-chat-activity-now {
|
|
91
|
+
background: linear-gradient(90deg, rgb(var(--ec-page-text)) 0%, rgb(var(--ec-accent)) 50%, rgb(var(--ec-page-text)) 100%);
|
|
92
|
+
background-size: 200% 100%;
|
|
93
|
+
-webkit-background-clip: text;
|
|
94
|
+
background-clip: text;
|
|
95
|
+
color: transparent;
|
|
96
|
+
animation: ec-chat-sheen 2.4s linear infinite;
|
|
97
|
+
}
|
|
98
|
+
.ec-chat-spinner {
|
|
99
|
+
width: 12px;
|
|
100
|
+
height: 12px;
|
|
101
|
+
flex: none;
|
|
102
|
+
border-radius: 50%;
|
|
103
|
+
border: 1.5px solid rgb(var(--ec-accent) / .2);
|
|
104
|
+
border-top-color: rgb(var(--ec-accent));
|
|
105
|
+
animation: ec-chat-spin .8s linear infinite;
|
|
106
|
+
}
|
|
107
|
+
/* Follow-up questions on their way: pills like theirs, a sheen passing along them */
|
|
108
|
+
.ec-chat-pending-pill {
|
|
109
|
+
height: 30px;
|
|
110
|
+
border-radius: 999px;
|
|
111
|
+
background: linear-gradient(90deg, rgb(var(--ec-accent) / .05) 25%, rgb(var(--ec-accent) / .14) 50%, rgb(var(--ec-accent) / .05) 75%);
|
|
112
|
+
background-size: 200% 100%;
|
|
113
|
+
animation: fadeInUp 300ms ease-out both, ec-chat-sheen 1.6s linear infinite;
|
|
114
|
+
}
|
|
115
|
+
.ec-chat-surface[aria-hidden='true'] .ec-chat-activity,
|
|
116
|
+
.ec-chat-surface[aria-hidden='true'] .ec-chat-activity *,
|
|
117
|
+
.ec-chat-surface[aria-hidden='true'] .ec-chat-pending-pill { animation-play-state: paused; }
|
|
44
118
|
.ec-chat-composer {
|
|
45
119
|
border-radius: 20px;
|
|
46
120
|
border-color: rgb(var(--ec-accent) / .22);
|
|
@@ -49,8 +123,9 @@ const CHAT_PANEL_STYLES = `
|
|
|
49
123
|
transition: border-color 200ms, box-shadow 200ms;
|
|
50
124
|
}
|
|
51
125
|
.ec-chat-composer:focus-within { border-color: rgb(var(--ec-accent) / .5); box-shadow: 0 0 0 3px rgb(var(--ec-accent) / .09); }
|
|
52
|
-
|
|
53
|
-
.ec-chat-composer
|
|
126
|
+
/* What's typed grows the box (up to a point, then scrolls), with what it's based on and Send in a row under it */
|
|
127
|
+
.ec-chat-composer textarea { display: block; resize: none; min-height: 58px; max-height: 200px; overflow-y: auto; padding: 14px 16px 4px; border-radius: 20px 20px 0 0; }
|
|
128
|
+
.ec-chat-composer-footer { display: flex; align-items: center; gap: 8px; padding: 2px 8px 8px 16px; }
|
|
54
129
|
@media (prefers-reduced-motion: reduce) {
|
|
55
130
|
.ec-chat-surface, .ec-chat-surface *, #eventcatalog-header, #eventcatalog-application {
|
|
56
131
|
animation: none !important;
|
|
@@ -153,6 +228,8 @@ const getGreeting = () => {
|
|
|
153
228
|
interface SuggestedQuestion {
|
|
154
229
|
label: string;
|
|
155
230
|
prompt: string;
|
|
231
|
+
/** Answered here, straight away, rather than by the model: e.g. a question back to the person */
|
|
232
|
+
reply?: string;
|
|
156
233
|
}
|
|
157
234
|
|
|
158
235
|
interface QuestionConfig {
|
|
@@ -161,6 +238,33 @@ interface QuestionConfig {
|
|
|
161
238
|
}
|
|
162
239
|
|
|
163
240
|
const suggestedQuestionsConfig: QuestionConfig[] = [
|
|
241
|
+
// A Studio canvas: the assistant draws on it
|
|
242
|
+
{
|
|
243
|
+
pattern: /\/studio\/[0-9a-f-]{36}/i,
|
|
244
|
+
questions: [
|
|
245
|
+
{
|
|
246
|
+
label: 'Create a new diagram',
|
|
247
|
+
prompt: 'Create a new diagram',
|
|
248
|
+
reply: [
|
|
249
|
+
'What would you like to diagram? Describe it in a sentence or two, for example:',
|
|
250
|
+
'',
|
|
251
|
+
'- How an order flows from checkout to shipping',
|
|
252
|
+
'- One of our domains and what it talks to',
|
|
253
|
+
"- A new service you're designing, and the events it sends and receives",
|
|
254
|
+
].join('\n'),
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
label: 'Review my architecture, highlight any issues',
|
|
258
|
+
prompt:
|
|
259
|
+
'Review the architecture on this canvas and highlight any issues: flag what you find with hotspots on the nodes they are about (critical, warning or info), and add sticky notes with your review and suggestions.',
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
label: 'How could we improve this architecture?',
|
|
263
|
+
prompt:
|
|
264
|
+
'How could we improve the architecture on this canvas? Flag the problems and risks you find with hotspots on the nodes they are about, and add sticky notes with your suggested improvements.',
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
},
|
|
164
268
|
// Message pages (events, commands, queries) - most specific first
|
|
165
269
|
{
|
|
166
270
|
pattern: /^\/docs\/(events|commands|queries)\/.+/,
|
|
@@ -364,6 +468,9 @@ const suggestedQuestionsConfig: QuestionConfig[] = [
|
|
|
364
468
|
},
|
|
365
469
|
];
|
|
366
470
|
|
|
471
|
+
/** The Studio canvas the page shows, if it's one: the assistant draws on it */
|
|
472
|
+
export const getCanvasId = (pathname: string) => pathname.match(/\/studio\/([0-9a-f-]{36})\/?(?:[?#]|$)/i)?.[1];
|
|
473
|
+
|
|
367
474
|
// Get suggested questions based on current URL path
|
|
368
475
|
const getSuggestedQuestions = (pathname: string): SuggestedQuestion[] => {
|
|
369
476
|
for (const config of suggestedQuestionsConfig) {
|
|
@@ -381,7 +488,15 @@ interface ChatPanelProps {
|
|
|
381
488
|
onClose: () => void;
|
|
382
489
|
}
|
|
383
490
|
|
|
384
|
-
|
|
491
|
+
/** The window's width without its scrollbar, which the panel (fixed, on the right) sits inside of (or the window's, where
|
|
492
|
+
* that isn't known) */
|
|
493
|
+
const viewportWidth = () => document.documentElement.clientWidth || window.innerWidth;
|
|
494
|
+
|
|
495
|
+
/** What the panel pushes over to make room for it (looked up each time: ClientRouter navigations swap in new pages) */
|
|
496
|
+
const pageBesidePanel = () =>
|
|
497
|
+
[document.getElementById('eventcatalog-application'), document.getElementById('eventcatalog-header')].filter(
|
|
498
|
+
Boolean
|
|
499
|
+
) as HTMLElement[];
|
|
385
500
|
|
|
386
501
|
// Staggered fade-in animation styles (delays account for 420ms panel slide)
|
|
387
502
|
const fadeInStyles = {
|
|
@@ -446,23 +561,29 @@ const getFollowUpSuggestions = (message: { parts?: Array<any> }): string[] => {
|
|
|
446
561
|
return [];
|
|
447
562
|
};
|
|
448
563
|
|
|
449
|
-
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
564
|
+
/** What the assistant's doing, in words, while a tool runs (or is being written, for the canvas's) */
|
|
565
|
+
const TOOL_ACTIVITY: Record<string, string> = {
|
|
566
|
+
getCanvas: 'Reading the canvas…',
|
|
567
|
+
addToCanvas: 'Drawing on the canvas…',
|
|
568
|
+
updateCanvasNode: 'Updating the canvas…',
|
|
569
|
+
connectCanvasNodes: 'Connecting things on the canvas…',
|
|
570
|
+
removeFromCanvas: 'Removing things from the canvas…',
|
|
571
|
+
layoutCanvas: 'Tidying the canvas…',
|
|
572
|
+
walkThrough: 'Preparing the walkthrough…',
|
|
573
|
+
showOnCanvas: 'Showing you on the canvas…',
|
|
574
|
+
addHotspot: 'Flagging hotspots…',
|
|
575
|
+
getResources: 'Searching the catalog…',
|
|
576
|
+
getResource: 'Reading the catalog…',
|
|
577
|
+
};
|
|
578
|
+
export const activityOf = (toolName: string, state?: string) =>
|
|
579
|
+
toolName === 'addToCanvas' && state === 'input-streaming'
|
|
580
|
+
? 'Planning the diagram…'
|
|
581
|
+
: (TOOL_ACTIVITY[toolName] ?? `Using ${toolName}…`);
|
|
582
|
+
|
|
583
|
+
/** The assistant is between things it writes (thinking, or using a tool), not writing text right now */
|
|
584
|
+
export const isBetweenText = (message: { parts?: Array<any> } | undefined) => {
|
|
585
|
+
const last = message?.parts?.at(-1);
|
|
586
|
+
return !last || last.type !== 'text' || last.state === 'done';
|
|
466
587
|
};
|
|
467
588
|
|
|
468
589
|
// Helper to extract completed tools from message parts (for showing after completion)
|
|
@@ -484,28 +605,296 @@ const getCompletedTools = (message: { parts?: Array<any> }): string[] => {
|
|
|
484
605
|
};
|
|
485
606
|
|
|
486
607
|
// Skeleton loading component - memoized since it never changes
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
608
|
+
export type ActivityStep = { label: string; done: boolean; count: number };
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* What the assistant has done this turn, step by step (each tool it used, in words, repeats counted), and what it's
|
|
612
|
+
* doing now: the tool it's using, else thinking. Only for a turn that's still going.
|
|
613
|
+
*/
|
|
614
|
+
export const getActivitySteps = (message: { role?: string; parts?: Array<any> } | undefined): ActivityStep[] => {
|
|
615
|
+
const steps: ActivityStep[] = [];
|
|
616
|
+
for (const part of message?.role === 'assistant' ? (message.parts ?? []) : []) {
|
|
617
|
+
if (!part.type?.startsWith('tool-')) continue;
|
|
618
|
+
const name = part.type.replace('tool-', '');
|
|
619
|
+
if (name === 'suggestFollowUpQuestions') continue;
|
|
620
|
+
const done = part.state === 'output-available' || part.state === 'output-error';
|
|
621
|
+
const label = activityOf(name, part.state).replace(/…$/, '');
|
|
622
|
+
const last = steps.at(-1);
|
|
623
|
+
if (last && last.label === label) {
|
|
624
|
+
last.count += 1;
|
|
625
|
+
last.done = done;
|
|
626
|
+
} else steps.push({ label, done, count: 1 });
|
|
627
|
+
}
|
|
628
|
+
if (!steps.length || steps.every((step) => step.done)) steps.push({ label: 'Thinking it through', done: false, count: 1 });
|
|
629
|
+
return steps;
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
/** The card the assistant's shown working in: its steps so far, ticked, and what it's on now */
|
|
633
|
+
const ActivityCard = memo(({ steps }: { steps: ActivityStep[] }) => (
|
|
634
|
+
<div role="status" aria-live="polite" className="ec-chat-message ec-chat-activity my-2 px-4 py-3">
|
|
635
|
+
<ol className="relative space-y-1.5 text-xs">
|
|
636
|
+
{steps.map((step, index) => (
|
|
637
|
+
<li key={index} className="flex items-center gap-2.5">
|
|
638
|
+
{step.done ? (
|
|
639
|
+
<Check size={12} className="flex-none text-[rgb(var(--ec-accent))]" aria-hidden="true" />
|
|
640
|
+
) : (
|
|
641
|
+
<span className="ec-chat-spinner" aria-hidden="true" />
|
|
642
|
+
)}
|
|
643
|
+
<span className={step.done ? 'text-[rgb(var(--ec-page-text-muted))]' : 'ec-chat-activity-now font-medium'}>
|
|
644
|
+
{step.label}
|
|
645
|
+
{step.done ? '' : '…'}
|
|
646
|
+
{step.count > 1 && <span className="ml-1 opacity-60">×{step.count}</span>}
|
|
647
|
+
</span>
|
|
648
|
+
</li>
|
|
649
|
+
))}
|
|
650
|
+
</ol>
|
|
651
|
+
</div>
|
|
652
|
+
));
|
|
653
|
+
ActivityCard.displayName = 'ActivityCard';
|
|
654
|
+
|
|
655
|
+
/** It's writing follow-up questions (what it does once it's answered) */
|
|
656
|
+
export const isSuggestingFollowUps = (message: { role?: string; parts?: Array<any> } | undefined) =>
|
|
657
|
+
message?.role === 'assistant' &&
|
|
658
|
+
!!message.parts?.some((part) => part.type === 'tool-suggestFollowUpQuestions' && part.state !== 'output-available');
|
|
659
|
+
|
|
660
|
+
/** Where the follow-up questions will be, while it writes them: pills like theirs, coming in */
|
|
661
|
+
const FollowUpsLoading = memo(() => (
|
|
662
|
+
<div role="status" aria-live="polite" className="ec-chat-message mt-1 flex flex-col items-start gap-2">
|
|
663
|
+
<span className="ec-chat-activity-now text-xs font-medium">Suggesting what to ask next…</span>
|
|
664
|
+
{['72%', '58%', '66%'].map((width, index) => (
|
|
665
|
+
<span
|
|
666
|
+
key={width}
|
|
667
|
+
aria-hidden="true"
|
|
668
|
+
className="ec-chat-pending-pill block"
|
|
669
|
+
style={{ width, animationDelay: `${index * 90}ms, ${index * 150}ms` }}
|
|
670
|
+
/>
|
|
671
|
+
))}
|
|
495
672
|
</div>
|
|
496
673
|
));
|
|
497
|
-
|
|
674
|
+
FollowUpsLoading.displayName = 'FollowUpsLoading';
|
|
498
675
|
|
|
499
676
|
// Memoized markdown components to prevent re-renders
|
|
677
|
+
/** Takes the person to nodes on the Studio canvas they have open */
|
|
678
|
+
const focusCanvas = (nodeIds: string[]) =>
|
|
679
|
+
window.dispatchEvent(
|
|
680
|
+
new CustomEvent(CANVAS_FOCUS_EVENT, { detail: { canvasId: getCanvasId(window.location.pathname), nodeIds } })
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
/** Takes the person to a hotspot on the Studio canvas they have open, and opens it */
|
|
684
|
+
const showHotspot = (hotspotId: string) =>
|
|
685
|
+
window.dispatchEvent(
|
|
686
|
+
new CustomEvent(CANVAS_HOTSPOT_EVENT, { detail: { canvasId: getCanvasId(window.location.pathname), hotspotId } })
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
/** How long the pointer rests on a walkthrough step before it takes them there (so moving across the steps doesn't) */
|
|
690
|
+
const FOCUS_ON_HOVER_MS = 250;
|
|
691
|
+
|
|
692
|
+
/** Something on the canvas, from the chat: clicking it, or resting the pointer on it, takes them there */
|
|
693
|
+
function CanvasLink({ go, className, children }: { go: () => void; className: string; children: React.ReactNode }) {
|
|
694
|
+
const hover = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
695
|
+
const cancel = () => {
|
|
696
|
+
if (hover.current !== null) clearTimeout(hover.current);
|
|
697
|
+
hover.current = null;
|
|
698
|
+
};
|
|
699
|
+
useEffect(() => cancel, []);
|
|
700
|
+
return (
|
|
701
|
+
<button
|
|
702
|
+
type="button"
|
|
703
|
+
onClick={() => {
|
|
704
|
+
cancel();
|
|
705
|
+
go();
|
|
706
|
+
}}
|
|
707
|
+
onMouseEnter={() => {
|
|
708
|
+
cancel();
|
|
709
|
+
hover.current = setTimeout(go, FOCUS_ON_HOVER_MS);
|
|
710
|
+
}}
|
|
711
|
+
onMouseLeave={cancel}
|
|
712
|
+
title="Show me on the canvas"
|
|
713
|
+
className={`cursor-pointer transition-colors ${className}`}
|
|
714
|
+
>
|
|
715
|
+
{children}
|
|
716
|
+
</button>
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** A step of a walkthrough on a canvas: takes them to its nodes */
|
|
721
|
+
const FocusLink = ({ nodeIds, children }: { nodeIds: string[]; children: React.ReactNode }) => (
|
|
722
|
+
<CanvasLink
|
|
723
|
+
go={() => focusCanvas(nodeIds)}
|
|
724
|
+
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-medium text-[rgb(var(--ec-accent))] bg-[rgb(var(--ec-accent)/0.08)] hover:bg-[rgb(var(--ec-accent)/0.16)]"
|
|
725
|
+
>
|
|
726
|
+
{children}
|
|
727
|
+
<LocateFixed size={12} className="flex-none opacity-70" aria-hidden="true" />
|
|
728
|
+
</CanvasLink>
|
|
729
|
+
);
|
|
730
|
+
|
|
731
|
+
/** Something on the canvas the assistant mentions: drawn like the docs' resource references (its type's icon and colour) */
|
|
732
|
+
const CanvasRef = ({ type, nodeId, children }: { type: string; nodeId: string; children: React.ReactNode }) => {
|
|
733
|
+
const rgb = type ? tailwind500RgbByColor[getResourceReferenceColorName(type)] : undefined;
|
|
734
|
+
const style = {
|
|
735
|
+
'--ec-resource-ref-bg': rgb ? `rgb(${rgb} / 0.12)` : 'rgb(var(--ec-accent) / 0.12)',
|
|
736
|
+
'--ec-resource-ref-color': rgb ? `rgb(${rgb})` : 'rgb(var(--ec-accent))',
|
|
737
|
+
} as React.CSSProperties;
|
|
738
|
+
return (
|
|
739
|
+
<span style={style}>
|
|
740
|
+
<CanvasLink
|
|
741
|
+
go={() => focusCanvas([nodeId])}
|
|
742
|
+
className="inline-flex items-center gap-1 px-1 -mx-0.5 rounded font-medium underline decoration-dotted decoration-2 underline-offset-[3px] text-[var(--ec-resource-ref-color)] decoration-[var(--ec-resource-ref-color)] hover:bg-[var(--ec-resource-ref-bg)]"
|
|
743
|
+
>
|
|
744
|
+
<svg
|
|
745
|
+
className="w-3.5 h-3.5 flex-shrink-0"
|
|
746
|
+
fill="none"
|
|
747
|
+
viewBox="0 0 24 24"
|
|
748
|
+
stroke="currentColor"
|
|
749
|
+
strokeWidth={1.75}
|
|
750
|
+
aria-hidden="true"
|
|
751
|
+
dangerouslySetInnerHTML={{ __html: getResourceReferenceIcon(type) }}
|
|
752
|
+
/>
|
|
753
|
+
<span>{children}</span>
|
|
754
|
+
</CanvasLink>
|
|
755
|
+
</span>
|
|
756
|
+
);
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
/** Hotspots' severities, as Studio shows them */
|
|
760
|
+
const HOTSPOT_SEVERITY = {
|
|
761
|
+
critical: { label: 'Critical', color: '#dc2626', icon: OctagonAlert },
|
|
762
|
+
warning: { label: 'Warning', color: '#d97706', icon: TriangleAlert },
|
|
763
|
+
info: { label: 'Info', color: '#2563eb', icon: Info },
|
|
764
|
+
} as const;
|
|
765
|
+
export type FlaggedHotspot = { id: string; severity: keyof typeof HOTSPOT_SEVERITY; title: string };
|
|
766
|
+
|
|
767
|
+
/** The hotspots the assistant flagged on the canvas in a message */
|
|
768
|
+
export const getFlaggedHotspots = (message: { parts?: Array<any> }): FlaggedHotspot[] =>
|
|
769
|
+
(message.parts ?? []).flatMap((part) =>
|
|
770
|
+
part.type === 'tool-addHotspot' &&
|
|
771
|
+
part.state === 'output-available' &&
|
|
772
|
+
typeof part.output?.hotspotId === 'string' &&
|
|
773
|
+
part.input?.severity in HOTSPOT_SEVERITY
|
|
774
|
+
? [{ id: part.output.hotspotId, severity: part.input.severity, title: String(part.input.title ?? '') }]
|
|
775
|
+
: []
|
|
776
|
+
);
|
|
777
|
+
|
|
778
|
+
/** The hotspots it flagged, listed under what it says: each takes them to it on the canvas, opened */
|
|
779
|
+
const FlaggedHotspots = memo(({ hotspots }: { hotspots: FlaggedHotspot[] }) => (
|
|
780
|
+
<div className="mt-3 w-full overflow-hidden rounded-xl border border-[rgb(var(--ec-page-border))]">
|
|
781
|
+
<div className="px-3 py-2 text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))] bg-[rgb(var(--ec-accent)/0.03)]">
|
|
782
|
+
Hotspots flagged on the canvas ({hotspots.length})
|
|
783
|
+
</div>
|
|
784
|
+
<ul>
|
|
785
|
+
{hotspots.map((hotspot) => {
|
|
786
|
+
const { label, color, icon: Icon } = HOTSPOT_SEVERITY[hotspot.severity];
|
|
787
|
+
return (
|
|
788
|
+
<li key={hotspot.id} className="border-t border-[rgb(var(--ec-page-border))]">
|
|
789
|
+
<CanvasLink
|
|
790
|
+
go={() => showHotspot(hotspot.id)}
|
|
791
|
+
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-xs text-[rgb(var(--ec-page-text))] hover:bg-[rgb(var(--ec-accent)/0.06)]"
|
|
792
|
+
>
|
|
793
|
+
<Icon size={14} className="flex-none" style={{ color }} aria-label={label} />
|
|
794
|
+
<span className="min-w-0 flex-1">{hotspot.title}</span>
|
|
795
|
+
<LocateFixed size={12} className="flex-none text-[rgb(var(--ec-icon-color))]" aria-hidden="true" />
|
|
796
|
+
</CanvasLink>
|
|
797
|
+
</li>
|
|
798
|
+
);
|
|
799
|
+
})}
|
|
800
|
+
</ul>
|
|
801
|
+
</div>
|
|
802
|
+
));
|
|
803
|
+
FlaggedHotspots.displayName = 'FlaggedHotspots';
|
|
804
|
+
|
|
805
|
+
/** The panel's left edge: dragged to make it wider or narrower (or with the arrow keys), double-clicked back to the default */
|
|
806
|
+
const PanelResizeHandle = memo(
|
|
807
|
+
({ panel, width, onResize }: { panel: RefObject<HTMLDivElement | null>; width: number; onResize: (width: number) => void }) => {
|
|
808
|
+
// Pressed, and whether it's moved since (a click, or each click of a double click, changes nothing)
|
|
809
|
+
const dragging = useRef<{ moved: boolean } | null>(null);
|
|
810
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
811
|
+
const widthAt = (clientX: number) => clampPanelWidth(viewportWidth() - clientX, viewportWidth());
|
|
812
|
+
// While dragging, the panel and the page beside it are sized here, outside React, so the messages don't re-render
|
|
813
|
+
// on every move (and the page follows the pointer, without its slide)
|
|
814
|
+
const show = (next: number) => {
|
|
815
|
+
if (panel.current) panel.current.style.width = `min(${next}px, 100vw)`;
|
|
816
|
+
pageBesidePanel().forEach((el) => (el.style.paddingRight = `${next}px`));
|
|
817
|
+
};
|
|
818
|
+
const stop = (next: number) => {
|
|
819
|
+
const moved = dragging.current?.moved;
|
|
820
|
+
dragging.current = null;
|
|
821
|
+
setIsDragging(false);
|
|
822
|
+
show(moved ? next : width);
|
|
823
|
+
pageBesidePanel().forEach((el) => (el.style.transition = ''));
|
|
824
|
+
if (moved) onResize(next);
|
|
825
|
+
};
|
|
826
|
+
return (
|
|
827
|
+
<div
|
|
828
|
+
role="separator"
|
|
829
|
+
aria-orientation="vertical"
|
|
830
|
+
aria-label="Resize the assistant"
|
|
831
|
+
aria-valuemin={MIN_PANEL_WIDTH}
|
|
832
|
+
aria-valuemax={MAX_PANEL_WIDTH}
|
|
833
|
+
aria-valuenow={width}
|
|
834
|
+
tabIndex={0}
|
|
835
|
+
title="Drag to resize"
|
|
836
|
+
onPointerDown={(event) => {
|
|
837
|
+
if (event.button !== 0) return;
|
|
838
|
+
event.preventDefault();
|
|
839
|
+
// Keeps pointer events coming while the pointer is over the page (throws if the pointer has gone)
|
|
840
|
+
try {
|
|
841
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
842
|
+
} catch {}
|
|
843
|
+
dragging.current = { moved: false };
|
|
844
|
+
setIsDragging(true);
|
|
845
|
+
pageBesidePanel().forEach((el) => (el.style.transition = 'none'));
|
|
846
|
+
}}
|
|
847
|
+
onPointerMove={(event) => {
|
|
848
|
+
if (!dragging.current) return;
|
|
849
|
+
dragging.current.moved = true;
|
|
850
|
+
show(widthAt(event.clientX));
|
|
851
|
+
}}
|
|
852
|
+
onPointerUp={(event) => dragging.current && stop(widthAt(event.clientX))}
|
|
853
|
+
onPointerCancel={() => {
|
|
854
|
+
if (!dragging.current) return;
|
|
855
|
+
// Put back as it was
|
|
856
|
+
dragging.current.moved = false;
|
|
857
|
+
stop(width);
|
|
858
|
+
}}
|
|
859
|
+
onDoubleClick={() => onResize(PANEL_WIDTH)}
|
|
860
|
+
onKeyDown={(event) => {
|
|
861
|
+
const step = event.shiftKey ? 64 : 16;
|
|
862
|
+
if (event.key === 'ArrowLeft') onResize(clampPanelWidth(width + step, viewportWidth()));
|
|
863
|
+
else if (event.key === 'ArrowRight') onResize(clampPanelWidth(width - step, viewportWidth()));
|
|
864
|
+
else return;
|
|
865
|
+
event.preventDefault();
|
|
866
|
+
}}
|
|
867
|
+
className="group absolute inset-y-0 left-0 z-10 hidden w-1.5 cursor-col-resize outline-hidden lg:block"
|
|
868
|
+
>
|
|
869
|
+
<span
|
|
870
|
+
aria-hidden="true"
|
|
871
|
+
className={`absolute inset-y-0 left-0 w-0.5 transition-colors ${
|
|
872
|
+
isDragging
|
|
873
|
+
? 'bg-[rgb(var(--ec-accent))]'
|
|
874
|
+
: 'group-hover:bg-[rgb(var(--ec-accent)/0.6)] group-focus-visible:bg-[rgb(var(--ec-accent))]'
|
|
875
|
+
}`}
|
|
876
|
+
/>
|
|
877
|
+
</div>
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
);
|
|
881
|
+
PanelResizeHandle.displayName = 'PanelResizeHandle';
|
|
882
|
+
|
|
500
883
|
const markdownComponents = {
|
|
501
|
-
a: ({ ...props }: any) =>
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
884
|
+
a: ({ node: _node, ...props }: any) => {
|
|
885
|
+
const nodeIds = nodesInFocusLink(props.href);
|
|
886
|
+
if (nodeIds) return <FocusLink nodeIds={nodeIds}>{props.children}</FocusLink>;
|
|
887
|
+
const ref = canvasRefIn(props.href);
|
|
888
|
+
if (ref) return <CanvasRef {...ref}>{props.children}</CanvasRef>;
|
|
889
|
+
return (
|
|
890
|
+
<a
|
|
891
|
+
{...props}
|
|
892
|
+
target="_blank"
|
|
893
|
+
rel="noopener noreferrer"
|
|
894
|
+
className="text-[rgb(var(--ec-accent))] hover:text-[rgb(var(--ec-accent-hover))] underline"
|
|
895
|
+
/>
|
|
896
|
+
);
|
|
897
|
+
},
|
|
509
898
|
code: ({ children, className, ...props }: any) => {
|
|
510
899
|
const isInline = !className;
|
|
511
900
|
const match = /language-(\w+)/.exec(className || '');
|
|
@@ -541,6 +930,104 @@ const markdownComponents = {
|
|
|
541
930
|
};
|
|
542
931
|
|
|
543
932
|
// Modal version with slightly different code styling
|
|
933
|
+
export type WalkthroughStep = { title: string; description: string; nodeIds: string[] };
|
|
934
|
+
export type Walkthrough = { id: string; title: string; steps: WalkthroughStep[] };
|
|
935
|
+
|
|
936
|
+
/** The walkthroughs the assistant gave in a message (walkThrough), as the canvas had them */
|
|
937
|
+
export const getWalkthroughs = (message: { parts?: Array<any> }): Walkthrough[] =>
|
|
938
|
+
(message.parts ?? []).flatMap((part) =>
|
|
939
|
+
part.type === 'tool-walkThrough' && part.state === 'output-available' && Array.isArray(part.output?.steps)
|
|
940
|
+
? [{ id: String(part.toolCallId), title: String(part.output.title ?? ''), steps: part.output.steps as WalkthroughStep[] }]
|
|
941
|
+
: []
|
|
942
|
+
);
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* A walkthrough, step by step: each step takes them to its nodes on the canvas (clicked, or the pointer resting on it),
|
|
946
|
+
* and Previous and Next go through them in turn
|
|
947
|
+
*/
|
|
948
|
+
const WalkthroughSteps = memo(({ walkthrough }: { walkthrough: Walkthrough }) => {
|
|
949
|
+
const [current, setCurrent] = useState<number | null>(null);
|
|
950
|
+
const goTo = useCallback(
|
|
951
|
+
(index: number) => {
|
|
952
|
+
const step = walkthrough.steps[index];
|
|
953
|
+
if (!step) return;
|
|
954
|
+
setCurrent(index);
|
|
955
|
+
if (step.nodeIds.length) focusCanvas(step.nodeIds);
|
|
956
|
+
},
|
|
957
|
+
[walkthrough]
|
|
958
|
+
);
|
|
959
|
+
const count = walkthrough.steps.length;
|
|
960
|
+
return (
|
|
961
|
+
<div className="mt-3 w-full overflow-hidden rounded-xl border border-[rgb(var(--ec-page-border))]">
|
|
962
|
+
<div className="flex items-center gap-2 px-3 py-2 bg-[rgb(var(--ec-accent)/0.03)] border-b border-[rgb(var(--ec-page-border))]">
|
|
963
|
+
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-[rgb(var(--ec-page-text))]">{walkthrough.title}</span>
|
|
964
|
+
<span className="flex-none text-[11px] text-[rgb(var(--ec-page-text-muted))]">
|
|
965
|
+
{current === null ? `${count} step${count === 1 ? '' : 's'}` : `Step ${current + 1} of ${count}`}
|
|
966
|
+
</span>
|
|
967
|
+
<button
|
|
968
|
+
type="button"
|
|
969
|
+
aria-label="Previous step"
|
|
970
|
+
disabled={current === null || current === 0}
|
|
971
|
+
onClick={() => goTo((current ?? 0) - 1)}
|
|
972
|
+
className="flex-none cursor-pointer rounded p-0.5 text-[rgb(var(--ec-icon-color))] hover:bg-[rgb(var(--ec-accent)/0.1)] disabled:cursor-default disabled:opacity-30"
|
|
973
|
+
>
|
|
974
|
+
<ChevronLeft size={14} />
|
|
975
|
+
</button>
|
|
976
|
+
<button
|
|
977
|
+
type="button"
|
|
978
|
+
aria-label="Next step"
|
|
979
|
+
disabled={current === count - 1}
|
|
980
|
+
onClick={() => goTo(current === null ? 0 : current + 1)}
|
|
981
|
+
className="flex-none cursor-pointer rounded p-0.5 text-[rgb(var(--ec-icon-color))] hover:bg-[rgb(var(--ec-accent)/0.1)] disabled:cursor-default disabled:opacity-30"
|
|
982
|
+
>
|
|
983
|
+
<ChevronRight size={14} />
|
|
984
|
+
</button>
|
|
985
|
+
</div>
|
|
986
|
+
<ol className="py-1">
|
|
987
|
+
{walkthrough.steps.map((step, index) => {
|
|
988
|
+
const active = index === current;
|
|
989
|
+
return (
|
|
990
|
+
<li key={index} className="relative">
|
|
991
|
+
{/* The line down to the next step */}
|
|
992
|
+
{index < count - 1 && (
|
|
993
|
+
<span aria-hidden="true" className="absolute left-[23px] top-8 bottom-0 w-px bg-[rgb(var(--ec-accent)/0.2)]" />
|
|
994
|
+
)}
|
|
995
|
+
<CanvasLink
|
|
996
|
+
go={() => goTo(index)}
|
|
997
|
+
className={`flex w-full items-start gap-3 px-3 py-2 text-left ${
|
|
998
|
+
active ? 'bg-[rgb(var(--ec-accent)/0.08)]' : 'hover:bg-[rgb(var(--ec-accent)/0.05)]'
|
|
999
|
+
}`}
|
|
1000
|
+
>
|
|
1001
|
+
<span
|
|
1002
|
+
className={`relative z-10 flex h-[22px] w-[22px] flex-none items-center justify-center rounded-full text-[11px] font-semibold ${
|
|
1003
|
+
active
|
|
1004
|
+
? 'bg-[rgb(var(--ec-accent))] text-white'
|
|
1005
|
+
: 'bg-[rgb(var(--ec-card-bg))] text-[rgb(var(--ec-accent))] ring-1 ring-inset ring-[rgb(var(--ec-accent)/0.35)]'
|
|
1006
|
+
}`}
|
|
1007
|
+
>
|
|
1008
|
+
{index + 1}
|
|
1009
|
+
</span>
|
|
1010
|
+
<span className="min-w-0 flex-1">
|
|
1011
|
+
<span className="flex items-center gap-1.5 text-xs font-medium text-[rgb(var(--ec-page-text))]">
|
|
1012
|
+
{step.title}
|
|
1013
|
+
{step.nodeIds.length > 0 && (
|
|
1014
|
+
<LocateFixed size={11} className="flex-none text-[rgb(var(--ec-accent))] opacity-70" aria-hidden="true" />
|
|
1015
|
+
)}
|
|
1016
|
+
</span>
|
|
1017
|
+
<span className="mt-0.5 block text-xs leading-relaxed text-[rgb(var(--ec-page-text-muted))]">
|
|
1018
|
+
{step.description}
|
|
1019
|
+
</span>
|
|
1020
|
+
</span>
|
|
1021
|
+
</CanvasLink>
|
|
1022
|
+
</li>
|
|
1023
|
+
);
|
|
1024
|
+
})}
|
|
1025
|
+
</ol>
|
|
1026
|
+
</div>
|
|
1027
|
+
);
|
|
1028
|
+
});
|
|
1029
|
+
WalkthroughSteps.displayName = 'WalkthroughSteps';
|
|
1030
|
+
|
|
544
1031
|
const modalMarkdownComponents = {
|
|
545
1032
|
...markdownComponents,
|
|
546
1033
|
code: ({ children, className, ...props }: any) => {
|
|
@@ -589,6 +1076,24 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
|
|
|
589
1076
|
const [isWaitingForResponse, setIsWaitingForResponse] = useState(false);
|
|
590
1077
|
const [pathname, setPathname] = useState('');
|
|
591
1078
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
1079
|
+
// How wide they made the panel: the default until the width they left it at is read, after hydrating (within the
|
|
1080
|
+
// panel's own limits: the window's are applied as it's shown, so it's back to that width when the window's wide again)
|
|
1081
|
+
const panelRef = useRef<HTMLDivElement | null>(null);
|
|
1082
|
+
const [panelWidth, setPanelWidth] = useState(PANEL_WIDTH);
|
|
1083
|
+
useEffect(() => setPanelWidth(readPanelWidth(Infinity)), []);
|
|
1084
|
+
// How wide it's shown: that, leaving room for the page in this window (checked again as the window's resized; it only
|
|
1085
|
+
// re-renders when this changes)
|
|
1086
|
+
const [shownWidth, setShownWidth] = useState(PANEL_WIDTH);
|
|
1087
|
+
useEffect(() => {
|
|
1088
|
+
const fit = () => setShownWidth(clampPanelWidth(panelWidth, viewportWidth()));
|
|
1089
|
+
fit();
|
|
1090
|
+
window.addEventListener('resize', fit);
|
|
1091
|
+
return () => window.removeEventListener('resize', fit);
|
|
1092
|
+
}, [panelWidth]);
|
|
1093
|
+
const resizePanel = useCallback((width: number) => {
|
|
1094
|
+
setPanelWidth(width);
|
|
1095
|
+
savePanelWidth(width);
|
|
1096
|
+
}, []);
|
|
592
1097
|
const [tools, setTools] = useState<ToolMetadata[]>([]);
|
|
593
1098
|
const [showScrollButton, setShowScrollButton] = useState(false);
|
|
594
1099
|
const chatApiUrl = useMemo(() => buildUrl('/api/chat', true), []);
|
|
@@ -631,6 +1136,7 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
|
|
|
631
1136
|
|
|
632
1137
|
// Memoize suggested questions to avoid recalculating on every render
|
|
633
1138
|
const suggestedQuestions = useMemo(() => getSuggestedQuestions(pathname), [pathname]);
|
|
1139
|
+
const canvasId = useMemo(() => getCanvasId(pathname), [pathname]);
|
|
634
1140
|
|
|
635
1141
|
// Memoize page context to avoid recalculating on every render
|
|
636
1142
|
const pageContext = useMemo(() => {
|
|
@@ -699,9 +1205,31 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
|
|
|
699
1205
|
isWaitingForResponse ||
|
|
700
1206
|
(messages.length > 0 && (status === 'submitted' || status === 'streaming') && !assistantHasContent);
|
|
701
1207
|
const isLoading = isThinking || isStreaming;
|
|
1208
|
+
// Working, and not writing text right now (e.g. drawing on a canvas after saying what it'll do): still shown working
|
|
1209
|
+
const isWorking = isThinking || (isStreaming && isBetweenText(lastAssistantMessage));
|
|
1210
|
+
|
|
1211
|
+
// What it's done and is doing in the turn going on (the last message, once the assistant's started it)
|
|
1212
|
+
const lastMessage = messages.at(-1);
|
|
1213
|
+
const activitySteps = useMemo(() => getActivitySteps(lastMessage), [lastMessage]);
|
|
1214
|
+
const suggestingFollowUps = useMemo(() => isSuggestingFollowUps(lastMessage), [lastMessage]);
|
|
702
1215
|
|
|
703
|
-
//
|
|
704
|
-
const
|
|
1216
|
+
// The assistant showing them something on the canvas (showOnCanvas, a walkthrough's start): taken there, once
|
|
1217
|
+
const shownOnCanvas = useRef(new Set<string>());
|
|
1218
|
+
useEffect(() => {
|
|
1219
|
+
for (const part of (lastAssistantMessage?.parts ?? []) as Array<any>) {
|
|
1220
|
+
if (part.state !== 'output-available' || shownOnCanvas.current.has(part.toolCallId)) continue;
|
|
1221
|
+
// What it showed them, or a walkthrough's first step
|
|
1222
|
+
const nodeIds: string[] | undefined =
|
|
1223
|
+
part.type === 'tool-showOnCanvas'
|
|
1224
|
+
? part.output?.nodeIds
|
|
1225
|
+
: part.type === 'tool-walkThrough'
|
|
1226
|
+
? part.output?.steps?.[0]?.nodeIds
|
|
1227
|
+
: undefined;
|
|
1228
|
+
if (!nodeIds?.length) continue;
|
|
1229
|
+
shownOnCanvas.current.add(part.toolCallId);
|
|
1230
|
+
focusCanvas(nodeIds);
|
|
1231
|
+
}
|
|
1232
|
+
}, [lastAssistantMessage]);
|
|
705
1233
|
|
|
706
1234
|
// Jump the messages (in the panel and the fullscreen modal) to the latest. Instantly: a smooth scroll restarted on
|
|
707
1235
|
// every streamed token lands short of the growing answer and scrolls back and forth.
|
|
@@ -763,9 +1291,7 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
|
|
|
763
1291
|
// Looked up each time: the panel stays open across ClientRouter navigations (the header persists), but each one
|
|
764
1292
|
// swaps in a new page that needs pushing over too
|
|
765
1293
|
const getElements = () => ({
|
|
766
|
-
elements:
|
|
767
|
-
Boolean
|
|
768
|
-
) as HTMLElement[],
|
|
1294
|
+
elements: pageBesidePanel(),
|
|
769
1295
|
docsSidebarEl: document.getElementById('eventcatalog-docs-sidebar'),
|
|
770
1296
|
});
|
|
771
1297
|
|
|
@@ -777,7 +1303,7 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
|
|
|
777
1303
|
if (!el.style.transition) {
|
|
778
1304
|
el.style.transition = 'padding-right 420ms cubic-bezier(0.16, 1, 0.3, 1)';
|
|
779
1305
|
}
|
|
780
|
-
el.style.paddingRight = pushContent ? `${
|
|
1306
|
+
el.style.paddingRight = pushContent ? `${shownWidth}px` : '0';
|
|
781
1307
|
});
|
|
782
1308
|
if (docsSidebarEl) docsSidebarEl.style.display = pushContent ? 'none' : '';
|
|
783
1309
|
};
|
|
@@ -798,7 +1324,7 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
|
|
|
798
1324
|
docsSidebarEl.style.display = '';
|
|
799
1325
|
}
|
|
800
1326
|
};
|
|
801
|
-
}, [isOpen, isFullscreen]);
|
|
1327
|
+
}, [isOpen, isFullscreen, shownWidth]);
|
|
802
1328
|
|
|
803
1329
|
// Submit message handler
|
|
804
1330
|
const submitMessage = useCallback(
|
|
@@ -829,9 +1355,11 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
|
|
|
829
1355
|
return;
|
|
830
1356
|
}
|
|
831
1357
|
setIsWaitingForResponse(true);
|
|
832
|
-
|
|
1358
|
+
// The canvas follows the assistant while it draws
|
|
1359
|
+
if (canvasId) window.dispatchEvent(new CustomEvent(ASSISTANT_DRAWING_EVENT, { detail: { canvasId } }));
|
|
1360
|
+
sendMessage({ text }, canvasId ? { body: { canvasId } } : undefined);
|
|
833
1361
|
},
|
|
834
|
-
[configured, isLoading, sendMessage, setMessages]
|
|
1362
|
+
[configured, isLoading, sendMessage, setMessages, canvasId]
|
|
835
1363
|
);
|
|
836
1364
|
|
|
837
1365
|
const stopResponse = useCallback(() => {
|
|
@@ -857,6 +1385,30 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
|
|
|
857
1385
|
[submitMessage]
|
|
858
1386
|
);
|
|
859
1387
|
|
|
1388
|
+
// A suggested question: asked, or answered here straight away (no need to wait on the model to ask something back)
|
|
1389
|
+
const handleSuggestedQuestion = useCallback(
|
|
1390
|
+
({ prompt, reply }: SuggestedQuestion) => {
|
|
1391
|
+
if (!reply || isLoading) return handleSuggestedAction(prompt);
|
|
1392
|
+
followLatest.current = true;
|
|
1393
|
+
setMessages((previous) => [
|
|
1394
|
+
...previous,
|
|
1395
|
+
{ id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: prompt }] },
|
|
1396
|
+
{ id: crypto.randomUUID(), role: 'assistant', parts: [{ type: 'text', text: reply }] },
|
|
1397
|
+
]);
|
|
1398
|
+
(isFullscreen ? modalInputRef : inputRef).current?.focus();
|
|
1399
|
+
},
|
|
1400
|
+
[handleSuggestedAction, isLoading, isFullscreen, setMessages]
|
|
1401
|
+
);
|
|
1402
|
+
|
|
1403
|
+
// The box grows with what's typed in it (up to its max-height, then scrolls), and shrinks back once it's sent
|
|
1404
|
+
useEffect(() => {
|
|
1405
|
+
for (const input of [inputRef.current, modalInputRef.current]) {
|
|
1406
|
+
if (!input) continue;
|
|
1407
|
+
input.style.height = 'auto';
|
|
1408
|
+
input.style.height = `${input.scrollHeight}px`;
|
|
1409
|
+
}
|
|
1410
|
+
}, [inputValue, isFullscreen, isOpen]);
|
|
1411
|
+
|
|
860
1412
|
// Handle input enter key
|
|
861
1413
|
const handleInputKeyDown = useCallback(
|
|
862
1414
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
@@ -893,12 +1445,13 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
|
|
|
893
1445
|
{!isFullscreen && (
|
|
894
1446
|
<div
|
|
895
1447
|
ref={(element) => {
|
|
1448
|
+
panelRef.current = element;
|
|
896
1449
|
element?.toggleAttribute('inert', !isOpen);
|
|
897
1450
|
}}
|
|
898
1451
|
aria-hidden={!isOpen}
|
|
899
1452
|
className="ec-chat-surface fixed top-0 right-0 h-[100dvh] z-[200] border-l border-[rgb(var(--ec-page-border))] flex flex-col overflow-hidden"
|
|
900
1453
|
style={{
|
|
901
|
-
width: `min(${
|
|
1454
|
+
width: `min(${shownWidth}px, 100vw)`,
|
|
902
1455
|
transform: isOpen ? 'translateX(0)' : 'translateX(100%)',
|
|
903
1456
|
transition: 'transform 420ms cubic-bezier(0.16, 1, 0.3, 1)',
|
|
904
1457
|
background: `
|
|
@@ -907,6 +1460,7 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
|
|
|
907
1460
|
`,
|
|
908
1461
|
}}
|
|
909
1462
|
>
|
|
1463
|
+
<PanelResizeHandle panel={panelRef} width={shownWidth} onResize={resizePanel} />
|
|
910
1464
|
{/* Header */}
|
|
911
1465
|
<div className="flex-none shrink-0 border-b border-[rgb(var(--ec-page-border))]">
|
|
912
1466
|
<div className="flex items-center justify-between px-4 py-3">
|
|
@@ -1004,7 +1558,9 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
|
|
|
1004
1558
|
</div>
|
|
1005
1559
|
<h2 className="text-lg font-medium text-[rgb(var(--ec-page-text))] mb-1">{greeting}</h2>
|
|
1006
1560
|
<p className="text-sm font-normal text-[rgb(var(--ec-content-text))]">
|
|
1007
|
-
|
|
1561
|
+
{canvasId
|
|
1562
|
+
? "Tell me what to diagram and I'll draw it on the canvas"
|
|
1563
|
+
: "I'm here to help with your architecture"}
|
|
1008
1564
|
</p>
|
|
1009
1565
|
</div>
|
|
1010
1566
|
|
|
@@ -1013,7 +1569,7 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
|
|
|
1013
1569
|
{suggestedQuestions.map((question, index) => (
|
|
1014
1570
|
<button
|
|
1015
1571
|
key={index}
|
|
1016
|
-
onClick={() =>
|
|
1572
|
+
onClick={() => handleSuggestedQuestion(question)}
|
|
1017
1573
|
className="ec-chat-suggestion text-left px-3 py-2 text-xs text-[rgb(var(--ec-page-text-muted))] hover:text-[rgb(var(--ec-page-text))] bg-[rgb(var(--ec-accent)/0.04)] hover:bg-[rgb(var(--ec-accent)/0.09)] rounded-full transition-all duration-200"
|
|
1018
1574
|
style={isOpen ? fadeInStyles.getQuestionStyle(index) : undefined}
|
|
1019
1575
|
>
|
|
@@ -1029,6 +1585,8 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
|
|
|
1029
1585
|
const content = getMessageContent(message);
|
|
1030
1586
|
const followUpSuggestions = message.role === 'assistant' ? getFollowUpSuggestions(message) : [];
|
|
1031
1587
|
const completedTools = message.role === 'assistant' ? getCompletedTools(message) : [];
|
|
1588
|
+
const flaggedHotspots = message.role === 'assistant' ? getFlaggedHotspots(message) : [];
|
|
1589
|
+
const walkthroughs = message.role === 'assistant' ? getWalkthroughs(message) : [];
|
|
1032
1590
|
const isLastMessage = messageIndex === messages.length - 1;
|
|
1033
1591
|
return (
|
|
1034
1592
|
<div
|
|
@@ -1067,6 +1625,10 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
|
|
|
1067
1625
|
</div>
|
|
1068
1626
|
</div>
|
|
1069
1627
|
{/* Follow-up suggestions - only show for last assistant message when not loading */}
|
|
1628
|
+
{walkthroughs.map((walkthrough) => (
|
|
1629
|
+
<WalkthroughSteps key={walkthrough.id} walkthrough={walkthrough} />
|
|
1630
|
+
))}
|
|
1631
|
+
{flaggedHotspots.length > 0 && <FlaggedHotspots hotspots={flaggedHotspots} />}
|
|
1070
1632
|
{isLastMessage && followUpSuggestions.length > 0 && !isLoading && (
|
|
1071
1633
|
<div className="flex flex-wrap gap-2 mt-3 w-full">
|
|
1072
1634
|
{followUpSuggestions.map((suggestion, index) => (
|
|
@@ -1088,9 +1650,9 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
|
|
|
1088
1650
|
})}
|
|
1089
1651
|
|
|
1090
1652
|
{/* Skeleton loading indicator */}
|
|
1091
|
-
{
|
|
1653
|
+
{isWorking && (
|
|
1092
1654
|
<div className="w-full">
|
|
1093
|
-
|
|
1655
|
+
{suggestingFollowUps ? <FollowUpsLoading /> : <ActivityCard steps={activitySteps} />}
|
|
1094
1656
|
</div>
|
|
1095
1657
|
)}
|
|
1096
1658
|
|
|
@@ -1136,40 +1698,42 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
|
|
|
1136
1698
|
value={inputValue}
|
|
1137
1699
|
onChange={(e) => setInputValue(e.target.value)}
|
|
1138
1700
|
onKeyDown={handleInputKeyDown}
|
|
1139
|
-
placeholder=
|
|
1701
|
+
placeholder={canvasId ? 'Describe what to diagram...' : 'Ask, search, or explain...'}
|
|
1140
1702
|
disabled={isLoading}
|
|
1141
|
-
className="w-full px-4
|
|
1703
|
+
className="w-full px-4 bg-transparent text-[rgb(var(--ec-input-text))] placeholder-[rgb(var(--ec-input-placeholder))] focus:outline-hidden text-sm disabled:opacity-50 rounded-xl"
|
|
1142
1704
|
/>
|
|
1143
|
-
<div className="
|
|
1144
|
-
<
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
?
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1705
|
+
<div className="ec-chat-composer-footer">
|
|
1706
|
+
<div className="min-w-0 flex-1 truncate text-[10px] text-[rgb(var(--ec-page-text-muted))]">
|
|
1707
|
+
<span className="mr-1.5 rounded bg-[rgb(var(--ec-accent)/0.08)] px-1 py-0.5 font-medium text-[rgb(var(--ec-accent))]">
|
|
1708
|
+
AI
|
|
1709
|
+
</span>
|
|
1710
|
+
{configured
|
|
1711
|
+
? pageContext
|
|
1712
|
+
? `Based on ${pageContext.name}`
|
|
1713
|
+
: 'Your architecture, connected'
|
|
1714
|
+
: 'Explore your catalog with AI'}
|
|
1715
|
+
</div>
|
|
1716
|
+
<div className="flex-none">
|
|
1717
|
+
{isStreaming || isSetupThinking ? (
|
|
1718
|
+
<button
|
|
1719
|
+
type="button"
|
|
1720
|
+
onClick={stopResponse}
|
|
1721
|
+
className="p-2 text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
|
|
1722
|
+
aria-label="Stop generating"
|
|
1723
|
+
>
|
|
1724
|
+
<Square size={14} fill="currentColor" />
|
|
1725
|
+
</button>
|
|
1726
|
+
) : (
|
|
1727
|
+
<button
|
|
1728
|
+
type="submit"
|
|
1729
|
+
disabled={!inputValue.trim() || isLoading}
|
|
1730
|
+
className="px-3 py-1.5 bg-[rgb(var(--ec-accent))] text-white text-xs font-medium rounded-lg hover:bg-[rgb(var(--ec-accent-hover))] disabled:bg-transparent disabled:text-[rgb(var(--ec-icon-color))] transition-colors"
|
|
1731
|
+
aria-label="Send message"
|
|
1732
|
+
>
|
|
1733
|
+
Send
|
|
1734
|
+
</button>
|
|
1735
|
+
)}
|
|
1736
|
+
</div>
|
|
1173
1737
|
</div>
|
|
1174
1738
|
</div>
|
|
1175
1739
|
</form>
|
|
@@ -1312,7 +1876,9 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
|
|
|
1312
1876
|
</div>
|
|
1313
1877
|
<h2 className="text-2xl font-medium text-[rgb(var(--ec-page-text))] mb-2">{greeting}</h2>
|
|
1314
1878
|
<p className="font-normal text-[rgb(var(--ec-content-text))] text-center">
|
|
1315
|
-
|
|
1879
|
+
{canvasId
|
|
1880
|
+
? "Tell me what to diagram and I'll draw it on the canvas"
|
|
1881
|
+
: "I'm here to help with your architecture"}
|
|
1316
1882
|
</p>
|
|
1317
1883
|
</div>
|
|
1318
1884
|
|
|
@@ -1321,7 +1887,7 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
|
|
|
1321
1887
|
{suggestedQuestions.map((question, index) => (
|
|
1322
1888
|
<button
|
|
1323
1889
|
key={index}
|
|
1324
|
-
onClick={() =>
|
|
1890
|
+
onClick={() => handleSuggestedQuestion(question)}
|
|
1325
1891
|
className="ec-chat-suggestion px-3 py-2 text-xs text-[rgb(var(--ec-page-text-muted))] hover:text-[rgb(var(--ec-page-text))] bg-[rgb(var(--ec-accent)/0.04)] hover:bg-[rgb(var(--ec-accent)/0.09)] rounded-full transition-all duration-200 text-left"
|
|
1326
1892
|
>
|
|
1327
1893
|
{question.label}
|
|
@@ -1336,6 +1902,8 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
|
|
|
1336
1902
|
const content = getMessageContent(message);
|
|
1337
1903
|
const followUpSuggestions = message.role === 'assistant' ? getFollowUpSuggestions(message) : [];
|
|
1338
1904
|
const completedTools = message.role === 'assistant' ? getCompletedTools(message) : [];
|
|
1905
|
+
const flaggedHotspots = message.role === 'assistant' ? getFlaggedHotspots(message) : [];
|
|
1906
|
+
const walkthroughs = message.role === 'assistant' ? getWalkthroughs(message) : [];
|
|
1339
1907
|
const isLastMessage = messageIndex === messages.length - 1;
|
|
1340
1908
|
return (
|
|
1341
1909
|
<div
|
|
@@ -1374,6 +1942,10 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
|
|
|
1374
1942
|
</div>
|
|
1375
1943
|
</div>
|
|
1376
1944
|
{/* Follow-up suggestions - only show for last assistant message when not loading */}
|
|
1945
|
+
{walkthroughs.map((walkthrough) => (
|
|
1946
|
+
<WalkthroughSteps key={walkthrough.id} walkthrough={walkthrough} />
|
|
1947
|
+
))}
|
|
1948
|
+
{flaggedHotspots.length > 0 && <FlaggedHotspots hotspots={flaggedHotspots} />}
|
|
1377
1949
|
{isLastMessage && followUpSuggestions.length > 0 && !isLoading && (
|
|
1378
1950
|
<div className="flex flex-wrap gap-2 mt-3 w-full">
|
|
1379
1951
|
{followUpSuggestions.map((suggestion, index) => (
|
|
@@ -1394,9 +1966,9 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
|
|
|
1394
1966
|
);
|
|
1395
1967
|
})}
|
|
1396
1968
|
|
|
1397
|
-
{
|
|
1969
|
+
{isWorking && (
|
|
1398
1970
|
<div className="w-full max-w-md">
|
|
1399
|
-
|
|
1971
|
+
{suggestingFollowUps ? <FollowUpsLoading /> : <ActivityCard steps={activitySteps} />}
|
|
1400
1972
|
</div>
|
|
1401
1973
|
)}
|
|
1402
1974
|
|
|
@@ -1431,40 +2003,42 @@ const ChatPanel = ({ isOpen, onClose, configured = false }: ChatPanelProps) => {
|
|
|
1431
2003
|
value={inputValue}
|
|
1432
2004
|
onChange={(e) => setInputValue(e.target.value)}
|
|
1433
2005
|
onKeyDown={handleInputKeyDown}
|
|
1434
|
-
placeholder=
|
|
2006
|
+
placeholder={canvasId ? 'Describe what to diagram...' : 'Ask, search, or explain...'}
|
|
1435
2007
|
disabled={isLoading}
|
|
1436
|
-
className="w-full px-4
|
|
2008
|
+
className="w-full px-4 bg-transparent text-[rgb(var(--ec-input-text))] placeholder-[rgb(var(--ec-input-placeholder))] focus:outline-hidden text-sm disabled:opacity-50 rounded-xl"
|
|
1437
2009
|
/>
|
|
1438
|
-
<div className="
|
|
1439
|
-
<
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
?
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
2010
|
+
<div className="ec-chat-composer-footer">
|
|
2011
|
+
<div className="min-w-0 flex-1 truncate text-[10px] text-[rgb(var(--ec-page-text-muted))]">
|
|
2012
|
+
<span className="mr-1.5 rounded bg-[rgb(var(--ec-accent)/0.08)] px-1 py-0.5 font-medium text-[rgb(var(--ec-accent))]">
|
|
2013
|
+
AI
|
|
2014
|
+
</span>
|
|
2015
|
+
{configured
|
|
2016
|
+
? pageContext
|
|
2017
|
+
? `Based on ${pageContext.name}`
|
|
2018
|
+
: 'Your architecture, connected'
|
|
2019
|
+
: 'Explore your catalog with AI'}
|
|
2020
|
+
</div>
|
|
2021
|
+
<div className="flex-none">
|
|
2022
|
+
{isStreaming || isSetupThinking ? (
|
|
2023
|
+
<button
|
|
2024
|
+
type="button"
|
|
2025
|
+
onClick={stopResponse}
|
|
2026
|
+
className="p-2 text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
|
|
2027
|
+
aria-label="Stop generating"
|
|
2028
|
+
>
|
|
2029
|
+
<Square size={14} fill="currentColor" />
|
|
2030
|
+
</button>
|
|
2031
|
+
) : (
|
|
2032
|
+
<button
|
|
2033
|
+
type="submit"
|
|
2034
|
+
disabled={!inputValue.trim() || isLoading}
|
|
2035
|
+
className="px-4 py-2 bg-[rgb(var(--ec-accent))] text-white text-sm font-medium rounded-lg hover:bg-[rgb(var(--ec-accent-hover))] disabled:bg-transparent disabled:text-[rgb(var(--ec-icon-color))] disabled:cursor-not-allowed transition-colors"
|
|
2036
|
+
aria-label="Send message"
|
|
2037
|
+
>
|
|
2038
|
+
Send
|
|
2039
|
+
</button>
|
|
2040
|
+
)}
|
|
2041
|
+
</div>
|
|
1468
2042
|
</div>
|
|
1469
2043
|
</div>
|
|
1470
2044
|
</form>
|