@4djs/assistant 0.0.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 (68) hide show
  1. package/README.md +322 -0
  2. package/package.json +41 -0
  3. package/src/core/chat-activity.ts +107 -0
  4. package/src/core/chat-commands.ts +173 -0
  5. package/src/core/chat-history.ts +113 -0
  6. package/src/core/chat-reply-suggestions-parse.ts +119 -0
  7. package/src/core/code-highlight.ts +20 -0
  8. package/src/core/create-assistant-store.ts +639 -0
  9. package/src/core/fetch-suggested-prompts.ts +53 -0
  10. package/src/core/index.ts +125 -0
  11. package/src/core/interactive-tools/choices.ts +155 -0
  12. package/src/core/interactive-tools/confirmation.ts +63 -0
  13. package/src/core/interactive-tools/constants.ts +22 -0
  14. package/src/core/interactive-tools/execute.ts +70 -0
  15. package/src/core/interactive-tools/index.ts +41 -0
  16. package/src/core/interactive-tools/suggestions.ts +87 -0
  17. package/src/core/interactive-tools/waiters.ts +55 -0
  18. package/src/core/llm-chat.ts +686 -0
  19. package/src/core/llm-config.ts +101 -0
  20. package/src/core/llm-models.ts +96 -0
  21. package/src/core/llm-provider.ts +99 -0
  22. package/src/core/llm-settings-storage.ts +331 -0
  23. package/src/core/llm-sse.ts +166 -0
  24. package/src/core/llm-types.ts +52 -0
  25. package/src/core/markdown-utils.ts +11 -0
  26. package/src/core/prepare-markdown.ts +38 -0
  27. package/src/core/types.ts +86 -0
  28. package/src/css.d.ts +1 -0
  29. package/src/react/Assistant.tsx +358 -0
  30. package/src/react/components/HighlightedJsonCode.tsx +24 -0
  31. package/src/react/components/MarkdownContent.tsx +98 -0
  32. package/src/react/components/MarkdownEditor.tsx +60 -0
  33. package/src/react/components/MermaidDiagram.tsx +139 -0
  34. package/src/react/components/ModelSelector.tsx +243 -0
  35. package/src/react/components/chat/AssistantErrorCallout.tsx +79 -0
  36. package/src/react/components/chat/ChatActivity.tsx +274 -0
  37. package/src/react/components/chat/ChatComposer.tsx +189 -0
  38. package/src/react/components/chat/ChatEmptyState.tsx +145 -0
  39. package/src/react/components/chat/ChatInteractivePrompt/choices-prompt.tsx +262 -0
  40. package/src/react/components/chat/ChatInteractivePrompt/confirmation-prompt.tsx +97 -0
  41. package/src/react/components/chat/ChatInteractivePrompt/index.tsx +60 -0
  42. package/src/react/components/chat/ChatInteractivePrompt/shell.tsx +60 -0
  43. package/src/react/components/chat/ChatInteractivePrompt/utils.ts +14 -0
  44. package/src/react/components/chat/ChatMessage.tsx +150 -0
  45. package/src/react/components/chat/ChatMessageScroll.tsx +116 -0
  46. package/src/react/components/chat/ChatReplySuggestions.tsx +231 -0
  47. package/src/react/components/chat/ComposerCommandMenu.tsx +69 -0
  48. package/src/react/components/chat/LlmSettingsStrip.tsx +348 -0
  49. package/src/react/components/chat/LlmSetupPrompt.tsx +58 -0
  50. package/src/react/components/chat/LlmUnavailableBanner.tsx +11 -0
  51. package/src/react/components/chat/SuggestedPromptsList.tsx +121 -0
  52. package/src/react/components/chat/SuggestedPromptsStrip.tsx +72 -0
  53. package/src/react/components/chat/SystemPromptField.tsx +107 -0
  54. package/src/react/components/highlighted-code.tsx +107 -0
  55. package/src/react/context.tsx +72 -0
  56. package/src/react/hooks/use-composer-commands.ts +129 -0
  57. package/src/react/hooks/use-suggested-prompts.ts +128 -0
  58. package/src/react/index.ts +39 -0
  59. package/src/react/lib/parse-assistant-error.ts +96 -0
  60. package/src/react/lib/prompt-icons.ts +40 -0
  61. package/src/react/types.ts +83 -0
  62. package/src/react/utils/cn.ts +5 -0
  63. package/src/styles/assistant.css +3009 -0
  64. package/test/buildLlmHistory.test.ts +95 -0
  65. package/test/llm-config.test.ts +72 -0
  66. package/test/llmSettingsStorage.test.ts +121 -0
  67. package/test/parse-assistant-error.test.ts +24 -0
  68. package/tsconfig.json +8 -0
@@ -0,0 +1,24 @@
1
+ import type { ComponentPropsWithoutRef } from "react";
2
+ import { formatJsonIfLarge } from "../../core/chat-activity.ts";
3
+ import { cn } from "../utils/cn.ts";
4
+ import { HighlightedCodeBlock } from "./highlighted-code.tsx";
5
+
6
+ export function HighlightedJsonCode({
7
+ code,
8
+ className,
9
+ ...props
10
+ }: {
11
+ code: string;
12
+ className?: string;
13
+ } & ComponentPropsWithoutRef<"code">) {
14
+ const formatted = formatJsonIfLarge(code);
15
+
16
+ return (
17
+ <HighlightedCodeBlock
18
+ className={cn("language-json", className)}
19
+ code={formatted}
20
+ language="json"
21
+ {...props}
22
+ />
23
+ );
24
+ }
@@ -0,0 +1,98 @@
1
+ import { useMemo } from "react";
2
+ import type { Components } from "react-markdown";
3
+ import ReactMarkdown from "react-markdown";
4
+ import rehypeKatex from "rehype-katex";
5
+ import remarkGfm from "remark-gfm";
6
+ import remarkMath from "remark-math";
7
+ import { childrenToText } from "../../core/markdown-utils.ts";
8
+ import { prepareMarkdown } from "../../core/prepare-markdown.ts";
9
+ import { cn } from "../utils/cn.ts";
10
+ import { HighlightedCodeBlock } from "./highlighted-code.tsx";
11
+ import { MermaidDiagram } from "./MermaidDiagram.tsx";
12
+
13
+ import "katex/dist/katex.min.css";
14
+
15
+ interface MarkdownContentProps {
16
+ content: string;
17
+ className?: string;
18
+ invert?: boolean;
19
+ streaming?: boolean;
20
+ }
21
+
22
+ function blockLanguage(className?: string): string | null {
23
+ const match = /language-([\w-]+)/.exec(className ?? "");
24
+ return match?.[1] ?? null;
25
+ }
26
+
27
+ function createMarkdownComponents(streaming: boolean): Components {
28
+ return {
29
+ a: ({ href, children }) => (
30
+ <a href={href} target="_blank" rel="noopener noreferrer">
31
+ {children}
32
+ </a>
33
+ ),
34
+ pre: ({ children }) => <pre className="markdown-pre">{children}</pre>,
35
+ code: ({ className, children, ...props }) => {
36
+ const raw = childrenToText(children).replace(/\n$/, "");
37
+ const language = blockLanguage(className);
38
+ const isBlock = Boolean(language) || raw.includes("\n");
39
+
40
+ if (language === "mermaid") {
41
+ return <MermaidDiagram chart={raw} streaming={streaming} />;
42
+ }
43
+
44
+ if (isBlock) {
45
+ return (
46
+ <HighlightedCodeBlock
47
+ className={className}
48
+ code={raw}
49
+ language={language}
50
+ {...props}
51
+ />
52
+ );
53
+ }
54
+
55
+ return (
56
+ <code className="markdown-inline-code" {...props}>
57
+ {children}
58
+ </code>
59
+ );
60
+ },
61
+ };
62
+ }
63
+
64
+ export function MarkdownContent({
65
+ content,
66
+ className = "",
67
+ invert = false,
68
+ streaming = false,
69
+ }: MarkdownContentProps) {
70
+ const prepared = useMemo(() => prepareMarkdown(content), [content]);
71
+ const components = useMemo(
72
+ () => createMarkdownComponents(streaming),
73
+ [streaming],
74
+ );
75
+
76
+ return (
77
+ <div
78
+ className={cn(
79
+ "markdown-body",
80
+ invert && "markdown-body--invert",
81
+ className,
82
+ )}
83
+ >
84
+ <ReactMarkdown
85
+ remarkPlugins={[
86
+ remarkGfm,
87
+ [remarkMath, { singleDollarTextMath: true }],
88
+ ]}
89
+ rehypePlugins={[
90
+ [rehypeKatex, { strict: "ignore", throwOnError: false }],
91
+ ]}
92
+ components={components}
93
+ >
94
+ {prepared}
95
+ </ReactMarkdown>
96
+ </div>
97
+ );
98
+ }
@@ -0,0 +1,60 @@
1
+ import { useCallback, useMemo, useRef } from "react";
2
+ import { highlightNodes } from "./highlighted-code.tsx";
3
+
4
+ interface MarkdownEditorProps {
5
+ value: string;
6
+ onChange: (value: string) => void;
7
+ placeholder?: string;
8
+ disabled?: boolean;
9
+ className?: string;
10
+ "aria-label"?: string;
11
+ }
12
+
13
+ export function MarkdownEditor({
14
+ value,
15
+ onChange,
16
+ placeholder,
17
+ disabled = false,
18
+ className = "",
19
+ "aria-label": ariaLabel,
20
+ }: MarkdownEditorProps) {
21
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
22
+ const highlightRef = useRef<HTMLPreElement>(null);
23
+
24
+ const highlighted = useMemo(() => {
25
+ if (!value) return null;
26
+ const source = value.endsWith("\n") ? `${value}\n` : value;
27
+ return highlightNodes(source, "markdown");
28
+ }, [value]);
29
+
30
+ const syncScroll = useCallback(() => {
31
+ const textarea = textareaRef.current;
32
+ const highlight = highlightRef.current;
33
+ if (!textarea || !highlight) return;
34
+ highlight.scrollTop = textarea.scrollTop;
35
+ highlight.scrollLeft = textarea.scrollLeft;
36
+ }, []);
37
+
38
+ return (
39
+ <div className={`assistant-markdown-editor ${className}`.trim()}>
40
+ <pre
41
+ ref={highlightRef}
42
+ className="assistant-markdown-editor__highlight"
43
+ aria-hidden
44
+ >
45
+ <code className="hljs language-markdown">{highlighted}</code>
46
+ </pre>
47
+ <textarea
48
+ ref={textareaRef}
49
+ className="assistant-markdown-editor__input"
50
+ value={value}
51
+ onChange={(event) => onChange(event.target.value)}
52
+ onScroll={syncScroll}
53
+ placeholder={placeholder}
54
+ spellCheck={false}
55
+ disabled={disabled}
56
+ aria-label={ariaLabel}
57
+ />
58
+ </div>
59
+ );
60
+ }
@@ -0,0 +1,139 @@
1
+ import mermaid from "mermaid";
2
+ import { useEffect, useId, useRef, useState } from "react";
3
+ import { cn } from "../utils/cn.ts";
4
+ import { HighlightedCodeBlock } from "./highlighted-code.tsx";
5
+
6
+ const MERMAID_FONT = "ui-sans-serif, system-ui, -apple-system, sans-serif";
7
+
8
+ function getMermaidTheme(): "dark" | "default" {
9
+ if (typeof document === "undefined") return "dark";
10
+ return document.documentElement.dataset.theme === "light"
11
+ ? "default"
12
+ : "dark";
13
+ }
14
+
15
+ function buildMermaidConfig(theme: "dark" | "default") {
16
+ return {
17
+ startOnLoad: false,
18
+ theme,
19
+ securityLevel: "strict" as const,
20
+ fontFamily: MERMAID_FONT,
21
+ htmlLabels: false,
22
+ useMaxWidth: false,
23
+ class: {
24
+ useMaxWidth: false,
25
+ },
26
+ flowchart: {
27
+ useMaxWidth: false,
28
+ },
29
+ sequence: {
30
+ useMaxWidth: false,
31
+ },
32
+ };
33
+ }
34
+
35
+ function normalizeMermaidSvg(svg: string): string {
36
+ return svg.replace(/<svg\b([^>]*)>/, (_match, attrs: string) => {
37
+ let next = attrs.replace(/\swidth="100%"/, "");
38
+ next = next.replace(/\sstyle="([^"]*)"/, (_styleMatch, style: string) => {
39
+ const cleaned = style
40
+ .replace(/max-width:\s*[^;]+;?/gi, "")
41
+ .replace(/;\s*;/g, ";")
42
+ .trim();
43
+ return cleaned ? ` style="${cleaned}"` : "";
44
+ });
45
+ return `<svg${next}>`;
46
+ });
47
+ }
48
+
49
+ function MermaidSourceCode({ chart }: { chart: string }) {
50
+ return (
51
+ <HighlightedCodeBlock
52
+ className="language-mermaid"
53
+ code={chart.trim()}
54
+ language="mermaid"
55
+ />
56
+ );
57
+ }
58
+
59
+ interface MermaidDiagramProps {
60
+ chart: string;
61
+ streaming?: boolean;
62
+ className?: string;
63
+ }
64
+
65
+ export function MermaidDiagram({
66
+ chart,
67
+ streaming = false,
68
+ className,
69
+ }: MermaidDiagramProps) {
70
+ const reactId = useId();
71
+ const svgHostRef = useRef<HTMLDivElement>(null);
72
+ const [svg, setSvg] = useState<string | null>(null);
73
+ const [theme, setTheme] = useState(getMermaidTheme);
74
+ const source = chart.trim();
75
+
76
+ useEffect(() => {
77
+ const root = document.documentElement;
78
+ const observer = new MutationObserver(() => {
79
+ setTheme(getMermaidTheme());
80
+ });
81
+ observer.observe(root, {
82
+ attributes: true,
83
+ attributeFilter: ["data-theme"],
84
+ });
85
+ return () => observer.disconnect();
86
+ }, []);
87
+
88
+ useEffect(() => {
89
+ if (streaming || !source) {
90
+ setSvg(null);
91
+ return;
92
+ }
93
+
94
+ let cancelled = false;
95
+ setSvg(null);
96
+
97
+ const renderId = `mermaid-${reactId.replace(/:/g, "")}-${Math.random().toString(36).slice(2, 8)}`;
98
+
99
+ async function render() {
100
+ try {
101
+ mermaid.initialize(buildMermaidConfig(theme));
102
+ const { svg: rendered } = await mermaid.render(renderId, source);
103
+ if (!cancelled) {
104
+ setSvg(normalizeMermaidSvg(rendered));
105
+ }
106
+ } catch {
107
+ if (!cancelled) {
108
+ setSvg(null);
109
+ }
110
+ }
111
+ }
112
+
113
+ void render();
114
+ return () => {
115
+ cancelled = true;
116
+ };
117
+ }, [source, reactId, theme, streaming]);
118
+
119
+ useEffect(() => {
120
+ const host = svgHostRef.current;
121
+ if (!host) return;
122
+
123
+ if (svg) {
124
+ host.innerHTML = svg;
125
+ } else {
126
+ host.textContent = "";
127
+ }
128
+
129
+ return () => {
130
+ host.textContent = "";
131
+ };
132
+ }, [svg]);
133
+
134
+ if (streaming || !svg) {
135
+ return <MermaidSourceCode chart={chart} />;
136
+ }
137
+
138
+ return <div ref={svgHostRef} className={cn("markdown-mermaid", className)} />;
139
+ }
@@ -0,0 +1,243 @@
1
+ import {
2
+ type KeyboardEvent,
3
+ useEffect,
4
+ useId,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ } from "react";
9
+ import { useAssistant, useAssistantActions } from "../context.tsx";
10
+
11
+ function filterModels(models: string[], query: string): string[] {
12
+ const needle = query.trim().toLowerCase();
13
+ if (!needle) return models;
14
+ return models.filter((model) => model.toLowerCase().includes(needle));
15
+ }
16
+
17
+ interface ModelSelectorProps {
18
+ disabled?: boolean;
19
+ variant?: "header" | "footer";
20
+ dropUp?: boolean;
21
+ }
22
+
23
+ export function ModelSelector({
24
+ disabled,
25
+ variant = "header",
26
+ dropUp = false,
27
+ }: ModelSelectorProps) {
28
+ const llmModels = useAssistant((s) => s.llmModels);
29
+ const llmModelsLoading = useAssistant((s) => s.llmModelsLoading);
30
+ const selectedModel = useAssistant((s) => s.selectedModel);
31
+ const { setSelectedModel } = useAssistantActions();
32
+
33
+ const listboxId = useId();
34
+ const rootRef = useRef<HTMLDivElement>(null);
35
+ const inputRef = useRef<HTMLInputElement>(null);
36
+ const skipBlurCommitRef = useRef(false);
37
+
38
+ const [query, setQuery] = useState(selectedModel ?? "");
39
+ const [open, setOpen] = useState(false);
40
+ const [activeIndex, setActiveIndex] = useState(0);
41
+
42
+ const suggestions = useMemo(() => {
43
+ const filtered = filterModels(llmModels, query);
44
+ const trimmed = query.trim();
45
+ if (
46
+ trimmed &&
47
+ !llmModels.some((model) => model.toLowerCase() === trimmed.toLowerCase())
48
+ ) {
49
+ return [trimmed, ...filtered.filter((model) => model !== trimmed)];
50
+ }
51
+ return filtered;
52
+ }, [llmModels, query]);
53
+
54
+ useEffect(() => {
55
+ setQuery(selectedModel ?? "");
56
+ }, [selectedModel]);
57
+
58
+ useEffect(() => {
59
+ if (!open) return;
60
+ const onPointerDown = (event: MouseEvent) => {
61
+ if (!rootRef.current?.contains(event.target as Node)) {
62
+ setOpen(false);
63
+ }
64
+ };
65
+ document.addEventListener("pointerdown", onPointerDown);
66
+ return () => document.removeEventListener("pointerdown", onPointerDown);
67
+ }, [open]);
68
+
69
+ function commitModel(value: string) {
70
+ const trimmed = value.trim();
71
+ if (!trimmed) {
72
+ setQuery(selectedModel ?? "");
73
+ return;
74
+ }
75
+ setSelectedModel(trimmed);
76
+ setQuery(trimmed);
77
+ setOpen(false);
78
+ }
79
+
80
+ function selectSuggestion(index: number) {
81
+ const model = suggestions[index];
82
+ if (!model) return;
83
+ skipBlurCommitRef.current = true;
84
+ commitModel(model);
85
+ inputRef.current?.blur();
86
+ }
87
+
88
+ function resolveEnterSelection(): string | null {
89
+ const trimmed = query.trim();
90
+ if (!trimmed) return null;
91
+
92
+ if (open && suggestions.length > 0) {
93
+ return suggestions[activeIndex] ?? trimmed;
94
+ }
95
+
96
+ const exact = llmModels.find(
97
+ (model) => model.toLowerCase() === trimmed.toLowerCase(),
98
+ );
99
+ if (exact) return exact;
100
+
101
+ if (suggestions.length > 0) {
102
+ return suggestions[0] ?? trimmed;
103
+ }
104
+
105
+ return trimmed;
106
+ }
107
+
108
+ function onKeyDown(event: KeyboardEvent<HTMLInputElement>) {
109
+ if (event.key === "Enter") {
110
+ event.preventDefault();
111
+ const model = resolveEnterSelection();
112
+ if (model) {
113
+ skipBlurCommitRef.current = true;
114
+ commitModel(model);
115
+ inputRef.current?.blur();
116
+ }
117
+ return;
118
+ }
119
+
120
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
121
+ event.preventDefault();
122
+ if (!open) {
123
+ setOpen(true);
124
+ return;
125
+ }
126
+ setActiveIndex((index) => {
127
+ if (!suggestions.length) return 0;
128
+ return event.key === "ArrowDown"
129
+ ? (index + 1) % suggestions.length
130
+ : (index - 1 + suggestions.length) % suggestions.length;
131
+ });
132
+ return;
133
+ }
134
+
135
+ if (!open) return;
136
+
137
+ switch (event.key) {
138
+ case "Escape":
139
+ event.preventDefault();
140
+ setQuery(selectedModel ?? "");
141
+ setOpen(false);
142
+ break;
143
+ case "Tab":
144
+ setOpen(false);
145
+ break;
146
+ }
147
+ }
148
+
149
+ const showList = open && suggestions.length > 0;
150
+
151
+ return (
152
+ <div
153
+ ref={rootRef}
154
+ className={`model-selector ${variant === "footer" ? "model-selector--footer" : ""}`}
155
+ >
156
+ <label className="model-selector__label">
157
+ {variant === "header" ? (
158
+ <span className="model-selector__label-text">Model</span>
159
+ ) : null}
160
+ <div className="model-selector__field">
161
+ <input
162
+ ref={inputRef}
163
+ type="text"
164
+ className="assistant-input model-selector__input"
165
+ value={query}
166
+ onChange={(event) => {
167
+ setQuery(event.target.value);
168
+ setActiveIndex(0);
169
+ setOpen(true);
170
+ }}
171
+ onFocus={() => setOpen(true)}
172
+ onBlur={() => {
173
+ window.setTimeout(() => {
174
+ if (skipBlurCommitRef.current) {
175
+ skipBlurCommitRef.current = false;
176
+ return;
177
+ }
178
+ if (!rootRef.current?.contains(document.activeElement)) {
179
+ commitModel(query);
180
+ }
181
+ }, 0);
182
+ }}
183
+ onKeyDown={onKeyDown}
184
+ disabled={disabled || llmModelsLoading}
185
+ role="combobox"
186
+ aria-expanded={showList}
187
+ aria-controls={showList ? listboxId : undefined}
188
+ aria-autocomplete="list"
189
+ aria-activedescendant={
190
+ showList ? `${listboxId}-option-${activeIndex}` : undefined
191
+ }
192
+ aria-label="LLM model"
193
+ aria-busy={llmModelsLoading}
194
+ placeholder={
195
+ llmModelsLoading ? "Loading models…" : "Search models…"
196
+ }
197
+ autoComplete="off"
198
+ spellCheck={false}
199
+ />
200
+ {showList && (
201
+ <div
202
+ id={listboxId}
203
+ role="listbox"
204
+ className={`model-selector__list ${dropUp ? "model-selector__list--drop-up" : ""}`}
205
+ aria-label="Model suggestions"
206
+ >
207
+ {suggestions.map((model, index) => {
208
+ const isCustom =
209
+ model === query.trim() &&
210
+ !llmModels.some(
211
+ (entry) => entry.toLowerCase() === model.toLowerCase(),
212
+ );
213
+
214
+ return (
215
+ <button
216
+ key={isCustom ? `custom:${model}` : model}
217
+ type="button"
218
+ id={`${listboxId}-option-${index}`}
219
+ role="option"
220
+ aria-selected={index === activeIndex}
221
+ className={`model-selector__option ${index === activeIndex ? "model-selector__option--active" : ""}`}
222
+ onMouseDown={(event) => event.preventDefault()}
223
+ onClick={() => selectSuggestion(index)}
224
+ onMouseEnter={() => setActiveIndex(index)}
225
+ >
226
+ <span className="model-selector__option-label">
227
+ {model}
228
+ </span>
229
+ {isCustom && (
230
+ <span className="model-selector__option-hint">
231
+ Custom
232
+ </span>
233
+ )}
234
+ </button>
235
+ );
236
+ })}
237
+ </div>
238
+ )}
239
+ </div>
240
+ </label>
241
+ </div>
242
+ );
243
+ }
@@ -0,0 +1,79 @@
1
+ import {
2
+ CircleAlert,
3
+ Clock3,
4
+ Gauge,
5
+ KeyRound,
6
+ RefreshCw,
7
+ WifiOff,
8
+ } from "lucide-react";
9
+ import type { ComponentType } from "react";
10
+ import {
11
+ type AssistantErrorKind,
12
+ parseAssistantError,
13
+ } from "../../lib/parse-assistant-error.ts";
14
+
15
+ const ERROR_ICONS: Record<
16
+ AssistantErrorKind,
17
+ ComponentType<{ size?: number; strokeWidth?: number }>
18
+ > = {
19
+ network: WifiOff,
20
+ auth: KeyRound,
21
+ timeout: Clock3,
22
+ "rate-limit": Gauge,
23
+ unknown: CircleAlert,
24
+ };
25
+
26
+ interface AssistantErrorCalloutProps {
27
+ error: string;
28
+ context?: "chat" | "suggestions";
29
+ variant?: "embedded" | "panel";
30
+ onRetry?: () => void;
31
+ retryLabel?: string;
32
+ retryLoading?: boolean;
33
+ }
34
+
35
+ export function AssistantErrorCallout({
36
+ error,
37
+ context = "chat",
38
+ variant = "embedded",
39
+ onRetry,
40
+ retryLabel = "Try again",
41
+ retryLoading = false,
42
+ }: AssistantErrorCalloutProps) {
43
+ const parsed = parseAssistantError(error, context);
44
+ const Icon = ERROR_ICONS[parsed.kind];
45
+
46
+ return (
47
+ <div
48
+ className={`assistant-error-callout assistant-error-callout--${variant}`}
49
+ role="alert"
50
+ >
51
+ <span className="assistant-error-callout__badge" aria-hidden>
52
+ <Icon size={12} strokeWidth={2.25} />
53
+ </span>
54
+ <div className="assistant-error-callout__body">
55
+ <p className="assistant-error-callout__title">{parsed.title}</p>
56
+ <p className="assistant-error-callout__detail">{parsed.detail}</p>
57
+ {parsed.hint ? (
58
+ <p className="assistant-error-callout__hint">{parsed.hint}</p>
59
+ ) : null}
60
+ </div>
61
+ {onRetry ? (
62
+ <button
63
+ type="button"
64
+ className="assistant-error-callout__chip"
65
+ onClick={onRetry}
66
+ disabled={retryLoading}
67
+ >
68
+ <RefreshCw
69
+ size={12}
70
+ strokeWidth={2.25}
71
+ className={retryLoading ? "assistant-icon-spin" : undefined}
72
+ aria-hidden
73
+ />
74
+ {retryLabel}
75
+ </button>
76
+ ) : null}
77
+ </div>
78
+ );
79
+ }