@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,116 @@
1
+ import { ArrowDown } from "lucide-react";
2
+ import {
3
+ type ReactNode,
4
+ useCallback,
5
+ useEffect,
6
+ useRef,
7
+ useState,
8
+ } from "react";
9
+
10
+ const SCROLL_BOTTOM_THRESHOLD = 48;
11
+
12
+ function isNearBottom(viewport: HTMLDivElement): boolean {
13
+ return (
14
+ viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <=
15
+ SCROLL_BOTTOM_THRESHOLD
16
+ );
17
+ }
18
+
19
+ interface ChatMessageScrollProps {
20
+ children: ReactNode;
21
+ dependencyKey: string;
22
+ }
23
+
24
+ export function ChatMessageScroll({
25
+ children,
26
+ dependencyKey,
27
+ }: ChatMessageScrollProps) {
28
+ const scrollContainerRef = useRef<HTMLDivElement>(null);
29
+ const contentRef = useRef<HTMLDivElement>(null);
30
+ const stickToBottomRef = useRef(true);
31
+ const [showScrollDown, setShowScrollDown] = useState(false);
32
+
33
+ const updateScrollState = useCallback(() => {
34
+ const viewport = scrollContainerRef.current;
35
+ if (!viewport) return;
36
+ const atBottom = isNearBottom(viewport);
37
+ setShowScrollDown(!atBottom);
38
+ stickToBottomRef.current = atBottom;
39
+ }, []);
40
+
41
+ const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
42
+ const viewport = scrollContainerRef.current;
43
+ if (!viewport) return;
44
+ viewport.scrollTo({ top: viewport.scrollHeight, behavior });
45
+ stickToBottomRef.current = true;
46
+ setShowScrollDown(false);
47
+ }, []);
48
+
49
+ const handleContentResize = useCallback(() => {
50
+ const viewport = scrollContainerRef.current;
51
+ if (!viewport) return;
52
+ if (stickToBottomRef.current) {
53
+ viewport.scrollTo({ top: viewport.scrollHeight, behavior: "auto" });
54
+ setShowScrollDown(false);
55
+ return;
56
+ }
57
+ updateScrollState();
58
+ }, [updateScrollState]);
59
+
60
+ useEffect(() => {
61
+ const content = contentRef.current;
62
+ if (!content) return;
63
+ const resizeObserver = new ResizeObserver(handleContentResize);
64
+ resizeObserver.observe(content);
65
+ return () => resizeObserver.disconnect();
66
+ }, [handleContentResize]);
67
+
68
+ useEffect(() => {
69
+ const viewport = scrollContainerRef.current;
70
+ if (!viewport) return;
71
+ updateScrollState();
72
+ viewport.addEventListener("scroll", updateScrollState, { passive: true });
73
+ return () => viewport.removeEventListener("scroll", updateScrollState);
74
+ }, [updateScrollState]);
75
+
76
+ useEffect(() => {
77
+ const viewport = scrollContainerRef.current;
78
+ if (!viewport) return;
79
+ void dependencyKey;
80
+ if (!stickToBottomRef.current) {
81
+ updateScrollState();
82
+ return;
83
+ }
84
+ viewport.scrollTo({
85
+ top: viewport.scrollHeight,
86
+ behavior: "auto",
87
+ });
88
+ setShowScrollDown(false);
89
+ }, [dependencyKey, updateScrollState]);
90
+
91
+ return (
92
+ <div className="relative h-0 min-h-0 flex-1">
93
+ <div
94
+ ref={scrollContainerRef}
95
+ className="h-full overflow-x-hidden overflow-y-auto overscroll-contain bg-[var(--overlay-subtle)]"
96
+ aria-live="polite"
97
+ >
98
+ <div ref={contentRef} className="assistant-message-list">
99
+ {children}
100
+ <div aria-hidden className="h-px shrink-0" />
101
+ </div>
102
+ </div>
103
+ {showScrollDown ? (
104
+ <button
105
+ type="button"
106
+ className="assistant-scroll-down"
107
+ aria-label="Scroll to bottom"
108
+ title="Scroll to bottom"
109
+ onClick={() => scrollToBottom("smooth")}
110
+ >
111
+ <ArrowDown size={16} aria-hidden />
112
+ </button>
113
+ ) : null}
114
+ </div>
115
+ );
116
+ }
@@ -0,0 +1,231 @@
1
+ import { ArrowUpRight, PenLine } from "lucide-react";
2
+ import { useEffect } from "react";
3
+ import {
4
+ type ParsedSuggestion,
5
+ parseSuggestionText,
6
+ splitToolValues,
7
+ } from "../../../core/chat-reply-suggestions-parse.ts";
8
+ import type { ReplySuggestions } from "../../../core/interactive-tools/index.ts";
9
+ import { cn } from "../../utils/cn.ts";
10
+
11
+ type ChatReplySuggestionsProps = {
12
+ suggestions: ReplySuggestions;
13
+ disabled?: boolean;
14
+ onSelect: (reply: string) => void;
15
+ };
16
+
17
+ function fieldBadgeLabel(label: string): string {
18
+ switch (label.toLowerCase()) {
19
+ case "model":
20
+ return "Model";
21
+ case "enabled":
22
+ return "Status";
23
+ case "tools":
24
+ return "Tools";
25
+ case "dataclass":
26
+ return "Dataclass";
27
+ case "filter":
28
+ return "Filter";
29
+ default:
30
+ return label;
31
+ }
32
+ }
33
+
34
+ function StructuredSuggestionRow({
35
+ suggestion,
36
+ index,
37
+ disabled,
38
+ onSelect,
39
+ }: {
40
+ suggestion: Extract<ParsedSuggestion, { kind: "structured" }>;
41
+ index: number;
42
+ disabled?: boolean;
43
+ onSelect: () => void;
44
+ }) {
45
+ const toolsField = suggestion.fields.find(
46
+ (field) => field.label.toLowerCase() === "tools",
47
+ );
48
+ const metaFields = suggestion.fields.filter(
49
+ (field) => field.label.toLowerCase() !== "tools",
50
+ );
51
+ const toolNames = toolsField ? splitToolValues(toolsField.value) : [];
52
+
53
+ return (
54
+ <button
55
+ type="button"
56
+ disabled={disabled}
57
+ className="assistant-reply-option assistant-reply-option--structured group"
58
+ onClick={onSelect}
59
+ >
60
+ <span className="assistant-reply-option__index" aria-hidden>
61
+ {index + 1}
62
+ </span>
63
+ <span className="assistant-reply-option__body">
64
+ <span className="assistant-reply-option__label">
65
+ {suggestion.title}
66
+ </span>
67
+ {suggestion.description ? (
68
+ <span className="assistant-reply-option__meta">
69
+ {suggestion.description}
70
+ </span>
71
+ ) : null}
72
+ {(metaFields.length > 0 || toolNames.length > 0) && (
73
+ <span className="assistant-reply-option__badges">
74
+ {metaFields.map((field) => (
75
+ <span
76
+ key={`${field.label}-${field.value}`}
77
+ className="assistant-reply-badge"
78
+ >
79
+ <span className="assistant-reply-badge__label">
80
+ {fieldBadgeLabel(field.label)}
81
+ </span>
82
+ <span className="assistant-reply-badge__value">
83
+ {field.value}
84
+ </span>
85
+ </span>
86
+ ))}
87
+ {toolNames.map((tool) => (
88
+ <span key={tool} className="assistant-reply-tool">
89
+ {tool}
90
+ </span>
91
+ ))}
92
+ </span>
93
+ )}
94
+ </span>
95
+ <ArrowUpRight
96
+ className="assistant-reply-option__arrow size-3.5 shrink-0"
97
+ aria-hidden
98
+ />
99
+ </button>
100
+ );
101
+ }
102
+
103
+ function CompactSuggestionRow({
104
+ text,
105
+ index,
106
+ disabled,
107
+ custom,
108
+ onSelect,
109
+ }: {
110
+ text: string;
111
+ index: number;
112
+ disabled?: boolean;
113
+ custom?: boolean;
114
+ onSelect: () => void;
115
+ }) {
116
+ return (
117
+ <button
118
+ type="button"
119
+ disabled={disabled}
120
+ className={cn(
121
+ "assistant-reply-option group",
122
+ custom && "assistant-reply-option--custom",
123
+ )}
124
+ onClick={onSelect}
125
+ >
126
+ <span className="assistant-reply-option__index" aria-hidden>
127
+ {index + 1}
128
+ </span>
129
+ <span className="assistant-reply-option__body">
130
+ {custom ? (
131
+ <span className="assistant-reply-option__custom">
132
+ <PenLine className="size-3 shrink-0" aria-hidden />
133
+ <span>{text}</span>
134
+ </span>
135
+ ) : (
136
+ <span className="assistant-reply-option__label">{text}</span>
137
+ )}
138
+ </span>
139
+ <ArrowUpRight
140
+ className="assistant-reply-option__arrow size-3.5 shrink-0"
141
+ aria-hidden
142
+ />
143
+ </button>
144
+ );
145
+ }
146
+
147
+ export function ChatReplySuggestions({
148
+ suggestions,
149
+ disabled,
150
+ onSelect,
151
+ }: ChatReplySuggestionsProps) {
152
+ const parsed = suggestions.suggestions.map((text) => ({
153
+ raw: text,
154
+ parsed: parseSuggestionText(text),
155
+ }));
156
+
157
+ useEffect(() => {
158
+ if (disabled) return;
159
+
160
+ function onKeyDown(event: KeyboardEvent) {
161
+ if (!/^[1-9]$/.test(event.key)) return;
162
+
163
+ const index = Number(event.key) - 1;
164
+ const target = suggestions.suggestions[index];
165
+ if (!target) return;
166
+
167
+ const active = document.activeElement;
168
+ if (
169
+ active instanceof HTMLElement &&
170
+ (active.tagName === "INPUT" ||
171
+ active.tagName === "TEXTAREA" ||
172
+ active.isContentEditable)
173
+ ) {
174
+ return;
175
+ }
176
+
177
+ event.preventDefault();
178
+ onSelect(target);
179
+ }
180
+
181
+ window.addEventListener("keydown", onKeyDown);
182
+ return () => window.removeEventListener("keydown", onKeyDown);
183
+ }, [disabled, onSelect, suggestions.suggestions]);
184
+
185
+ return (
186
+ <fieldset className="assistant-reply-suggestions border-0 p-0 m-0 min-w-0">
187
+ <legend className="sr-only">Suggested replies</legend>
188
+ <div className="assistant-reply-suggestions__panel">
189
+ <div className="assistant-reply-suggestions__list">
190
+ {parsed.map((entry, index) => {
191
+ if (entry.parsed.kind === "structured") {
192
+ return (
193
+ <StructuredSuggestionRow
194
+ key={entry.raw}
195
+ suggestion={
196
+ entry.parsed as Extract<
197
+ ParsedSuggestion,
198
+ { kind: "structured" }
199
+ >
200
+ }
201
+ index={index}
202
+ disabled={disabled}
203
+ onSelect={() => onSelect(entry.raw)}
204
+ />
205
+ );
206
+ }
207
+
208
+ return (
209
+ <CompactSuggestionRow
210
+ key={entry.raw}
211
+ text={entry.raw}
212
+ index={index}
213
+ disabled={disabled}
214
+ custom={entry.parsed.kind === "custom"}
215
+ onSelect={() => onSelect(entry.raw)}
216
+ />
217
+ );
218
+ })}
219
+ </div>
220
+ <p className="assistant-reply-suggestions__hint">
221
+ <kbd className="assistant-reply-kbd">1</kbd>
222
+ <span aria-hidden>–</span>
223
+ <kbd className="assistant-reply-kbd">
224
+ {suggestions.suggestions.length}
225
+ </kbd>
226
+ <span>to pick · or type below</span>
227
+ </p>
228
+ </div>
229
+ </fieldset>
230
+ );
231
+ }
@@ -0,0 +1,69 @@
1
+ import { Terminal } from "lucide-react";
2
+ import type { ChatCommandSuggestion } from "../../../core/chat-commands.ts";
3
+ import { cn } from "../../utils/cn.ts";
4
+
5
+ interface ComposerCommandMenuProps {
6
+ commands: ChatCommandSuggestion[];
7
+ selectedIndex: number;
8
+ onSelect: (command: ChatCommandSuggestion) => void;
9
+ className?: string;
10
+ }
11
+
12
+ export function ComposerCommandMenu({
13
+ commands,
14
+ selectedIndex,
15
+ onSelect,
16
+ className,
17
+ }: ComposerCommandMenuProps) {
18
+ if (commands.length === 0) {
19
+ return null;
20
+ }
21
+
22
+ return (
23
+ <div
24
+ className={cn("composer-command-menu", className)}
25
+ role="listbox"
26
+ aria-label="Composer commands"
27
+ >
28
+ <div className="composer-command-menu__header">Commands</div>
29
+ <ul className="composer-command-menu__list">
30
+ {commands.map((command, index) => {
31
+ const active = index === selectedIndex;
32
+
33
+ return (
34
+ <li key={command.name} role="presentation">
35
+ <button
36
+ type="button"
37
+ role="option"
38
+ aria-selected={active}
39
+ className={cn(
40
+ "composer-command-menu__item",
41
+ active && "composer-command-menu__item--active",
42
+ )}
43
+ onMouseDown={(event) => {
44
+ event.preventDefault();
45
+ onSelect(command);
46
+ }}
47
+ >
48
+ <Terminal
49
+ className="composer-command-menu__icon"
50
+ size={14}
51
+ strokeWidth={2}
52
+ aria-hidden
53
+ />
54
+ <span className="min-w-0">
55
+ <span className="composer-command-menu__usage">
56
+ {command.usage}
57
+ </span>
58
+ <span className="composer-command-menu__description">
59
+ {command.description}
60
+ </span>
61
+ </span>
62
+ </button>
63
+ </li>
64
+ );
65
+ })}
66
+ </ul>
67
+ </div>
68
+ );
69
+ }