@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.
- package/README.md +322 -0
- package/package.json +41 -0
- package/src/core/chat-activity.ts +107 -0
- package/src/core/chat-commands.ts +173 -0
- package/src/core/chat-history.ts +113 -0
- package/src/core/chat-reply-suggestions-parse.ts +119 -0
- package/src/core/code-highlight.ts +20 -0
- package/src/core/create-assistant-store.ts +639 -0
- package/src/core/fetch-suggested-prompts.ts +53 -0
- package/src/core/index.ts +125 -0
- package/src/core/interactive-tools/choices.ts +155 -0
- package/src/core/interactive-tools/confirmation.ts +63 -0
- package/src/core/interactive-tools/constants.ts +22 -0
- package/src/core/interactive-tools/execute.ts +70 -0
- package/src/core/interactive-tools/index.ts +41 -0
- package/src/core/interactive-tools/suggestions.ts +87 -0
- package/src/core/interactive-tools/waiters.ts +55 -0
- package/src/core/llm-chat.ts +686 -0
- package/src/core/llm-config.ts +101 -0
- package/src/core/llm-models.ts +96 -0
- package/src/core/llm-provider.ts +99 -0
- package/src/core/llm-settings-storage.ts +331 -0
- package/src/core/llm-sse.ts +166 -0
- package/src/core/llm-types.ts +52 -0
- package/src/core/markdown-utils.ts +11 -0
- package/src/core/prepare-markdown.ts +38 -0
- package/src/core/types.ts +86 -0
- package/src/css.d.ts +1 -0
- package/src/react/Assistant.tsx +358 -0
- package/src/react/components/HighlightedJsonCode.tsx +24 -0
- package/src/react/components/MarkdownContent.tsx +98 -0
- package/src/react/components/MarkdownEditor.tsx +60 -0
- package/src/react/components/MermaidDiagram.tsx +139 -0
- package/src/react/components/ModelSelector.tsx +243 -0
- package/src/react/components/chat/AssistantErrorCallout.tsx +79 -0
- package/src/react/components/chat/ChatActivity.tsx +274 -0
- package/src/react/components/chat/ChatComposer.tsx +189 -0
- package/src/react/components/chat/ChatEmptyState.tsx +145 -0
- package/src/react/components/chat/ChatInteractivePrompt/choices-prompt.tsx +262 -0
- package/src/react/components/chat/ChatInteractivePrompt/confirmation-prompt.tsx +97 -0
- package/src/react/components/chat/ChatInteractivePrompt/index.tsx +60 -0
- package/src/react/components/chat/ChatInteractivePrompt/shell.tsx +60 -0
- package/src/react/components/chat/ChatInteractivePrompt/utils.ts +14 -0
- package/src/react/components/chat/ChatMessage.tsx +150 -0
- package/src/react/components/chat/ChatMessageScroll.tsx +116 -0
- package/src/react/components/chat/ChatReplySuggestions.tsx +231 -0
- package/src/react/components/chat/ComposerCommandMenu.tsx +69 -0
- package/src/react/components/chat/LlmSettingsStrip.tsx +348 -0
- package/src/react/components/chat/LlmSetupPrompt.tsx +58 -0
- package/src/react/components/chat/LlmUnavailableBanner.tsx +11 -0
- package/src/react/components/chat/SuggestedPromptsList.tsx +121 -0
- package/src/react/components/chat/SuggestedPromptsStrip.tsx +72 -0
- package/src/react/components/chat/SystemPromptField.tsx +107 -0
- package/src/react/components/highlighted-code.tsx +107 -0
- package/src/react/context.tsx +72 -0
- package/src/react/hooks/use-composer-commands.ts +129 -0
- package/src/react/hooks/use-suggested-prompts.ts +128 -0
- package/src/react/index.ts +39 -0
- package/src/react/lib/parse-assistant-error.ts +96 -0
- package/src/react/lib/prompt-icons.ts +40 -0
- package/src/react/types.ts +83 -0
- package/src/react/utils/cn.ts +5 -0
- package/src/styles/assistant.css +3009 -0
- package/test/buildLlmHistory.test.ts +95 -0
- package/test/llm-config.test.ts +72 -0
- package/test/llmSettingsStorage.test.ts +121 -0
- package/test/parse-assistant-error.test.ts +24 -0
- 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
|
+
}
|