@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,348 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChevronDown,
|
|
3
|
+
Loader2,
|
|
4
|
+
PlugZap,
|
|
5
|
+
RotateCcw,
|
|
6
|
+
Settings2,
|
|
7
|
+
X,
|
|
8
|
+
} from "lucide-react";
|
|
9
|
+
import { type FormEvent, useCallback, useEffect, useState } from "react";
|
|
10
|
+
import type { LlmSettingsFormState } from "../../../core/llm-settings-storage.ts";
|
|
11
|
+
import { isLlmSettingsFormDirty } from "../../../core/llm-settings-storage.ts";
|
|
12
|
+
import { useAssistantActions } from "../../context.tsx";
|
|
13
|
+
import { SystemPromptField } from "./SystemPromptField.tsx";
|
|
14
|
+
|
|
15
|
+
interface LlmSettingsStripProps {
|
|
16
|
+
open: boolean;
|
|
17
|
+
onClose: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function LlmSettingsStrip({ open, onClose }: LlmSettingsStripProps) {
|
|
21
|
+
const {
|
|
22
|
+
getLlmSettingsForm,
|
|
23
|
+
saveLlmSettings,
|
|
24
|
+
clearLlmSettings,
|
|
25
|
+
testLlmSettings,
|
|
26
|
+
llmSettingsHasOverrides,
|
|
27
|
+
} = useAssistantActions();
|
|
28
|
+
|
|
29
|
+
const [form, setForm] = useState<LlmSettingsFormState | null>(null);
|
|
30
|
+
const [savedForm, setSavedForm] = useState<LlmSettingsFormState | null>(null);
|
|
31
|
+
const [loading, setLoading] = useState(false);
|
|
32
|
+
const [testing, setTesting] = useState(false);
|
|
33
|
+
const [saving, setSaving] = useState(false);
|
|
34
|
+
const [error, setError] = useState<string | null>(null);
|
|
35
|
+
const [testResult, setTestResult] = useState<string | null>(null);
|
|
36
|
+
const [hasOverrides, setHasOverrides] = useState(false);
|
|
37
|
+
const [advancedOpen, setAdvancedOpen] = useState(false);
|
|
38
|
+
|
|
39
|
+
const loadForm = useCallback(async () => {
|
|
40
|
+
setLoading(true);
|
|
41
|
+
setError(null);
|
|
42
|
+
setTestResult(null);
|
|
43
|
+
try {
|
|
44
|
+
const next = await getLlmSettingsForm();
|
|
45
|
+
setForm(next);
|
|
46
|
+
setSavedForm(next);
|
|
47
|
+
setHasOverrides(await llmSettingsHasOverrides());
|
|
48
|
+
setAdvancedOpen(false);
|
|
49
|
+
} catch (loadError) {
|
|
50
|
+
setError(
|
|
51
|
+
loadError instanceof Error
|
|
52
|
+
? loadError.message
|
|
53
|
+
: "Failed to load LLM settings",
|
|
54
|
+
);
|
|
55
|
+
} finally {
|
|
56
|
+
setLoading(false);
|
|
57
|
+
}
|
|
58
|
+
}, [getLlmSettingsForm, llmSettingsHasOverrides]);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (open) {
|
|
62
|
+
void loadForm();
|
|
63
|
+
}
|
|
64
|
+
}, [open, loadForm]);
|
|
65
|
+
|
|
66
|
+
if (!open) return null;
|
|
67
|
+
|
|
68
|
+
function updateField<K extends keyof LlmSettingsFormState>(
|
|
69
|
+
key: K,
|
|
70
|
+
value: LlmSettingsFormState[K],
|
|
71
|
+
) {
|
|
72
|
+
setForm((current) => (current ? { ...current, [key]: value } : current));
|
|
73
|
+
setTestResult(null);
|
|
74
|
+
setError(null);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function handleTest() {
|
|
78
|
+
if (!form) return;
|
|
79
|
+
setTesting(true);
|
|
80
|
+
setError(null);
|
|
81
|
+
setTestResult(null);
|
|
82
|
+
try {
|
|
83
|
+
const result = await testLlmSettings(form);
|
|
84
|
+
if (result.ok) {
|
|
85
|
+
setTestResult(`Connected · ${result.model}`);
|
|
86
|
+
} else {
|
|
87
|
+
setError(result.error);
|
|
88
|
+
}
|
|
89
|
+
} finally {
|
|
90
|
+
setTesting(false);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function handleSave() {
|
|
95
|
+
if (!form || saving) return;
|
|
96
|
+
setSaving(true);
|
|
97
|
+
setError(null);
|
|
98
|
+
try {
|
|
99
|
+
await saveLlmSettings(form);
|
|
100
|
+
onClose();
|
|
101
|
+
} catch (saveError) {
|
|
102
|
+
setError(
|
|
103
|
+
saveError instanceof Error
|
|
104
|
+
? saveError.message
|
|
105
|
+
: "Failed to save LLM settings",
|
|
106
|
+
);
|
|
107
|
+
} finally {
|
|
108
|
+
setSaving(false);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function handleSubmit(event: FormEvent) {
|
|
113
|
+
event.preventDefault();
|
|
114
|
+
await handleSave();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function handleReset() {
|
|
118
|
+
if (!form) return;
|
|
119
|
+
setSaving(true);
|
|
120
|
+
setError(null);
|
|
121
|
+
setTestResult(null);
|
|
122
|
+
try {
|
|
123
|
+
if (hasOverrides) {
|
|
124
|
+
await clearLlmSettings();
|
|
125
|
+
}
|
|
126
|
+
await loadForm();
|
|
127
|
+
} catch (resetError) {
|
|
128
|
+
setError(
|
|
129
|
+
resetError instanceof Error
|
|
130
|
+
? resetError.message
|
|
131
|
+
: "Failed to reset LLM settings",
|
|
132
|
+
);
|
|
133
|
+
} finally {
|
|
134
|
+
setSaving(false);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const isDirty =
|
|
139
|
+
form && savedForm ? isLlmSettingsFormDirty(form, savedForm) : false;
|
|
140
|
+
const canReset = Boolean(hasOverrides || isDirty);
|
|
141
|
+
|
|
142
|
+
const statusMessage = error ?? testResult;
|
|
143
|
+
const statusTone = error ? "error" : testResult ? "success" : null;
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<section className="assistant-llm-settings-strip" aria-label="LLM settings">
|
|
147
|
+
<div className="assistant-llm-settings-strip__header">
|
|
148
|
+
<div className="assistant-llm-settings-strip__title">
|
|
149
|
+
<Settings2 size={14} aria-hidden />
|
|
150
|
+
<span>LLM settings</span>
|
|
151
|
+
</div>
|
|
152
|
+
<div className="assistant-llm-settings-strip__header-actions">
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
className="assistant-btn assistant-btn--ghost assistant-llm-settings-strip__header-btn"
|
|
156
|
+
onClick={() => void handleTest()}
|
|
157
|
+
disabled={!form || saving || testing}
|
|
158
|
+
aria-label="Test connection"
|
|
159
|
+
title="Test connection"
|
|
160
|
+
>
|
|
161
|
+
{testing ? (
|
|
162
|
+
<Loader2 size={14} className="assistant-icon-spin" aria-hidden />
|
|
163
|
+
) : (
|
|
164
|
+
<PlugZap size={14} aria-hidden />
|
|
165
|
+
)}
|
|
166
|
+
</button>
|
|
167
|
+
{canReset ? (
|
|
168
|
+
<button
|
|
169
|
+
type="button"
|
|
170
|
+
className="assistant-btn assistant-btn--ghost assistant-llm-settings-strip__header-btn"
|
|
171
|
+
onClick={() => void handleReset()}
|
|
172
|
+
disabled={saving || testing}
|
|
173
|
+
aria-label="Reset all settings"
|
|
174
|
+
title="Reset all settings"
|
|
175
|
+
>
|
|
176
|
+
<RotateCcw size={14} aria-hidden />
|
|
177
|
+
</button>
|
|
178
|
+
) : null}
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
className="assistant-btn assistant-btn--ghost assistant-llm-settings-strip__header-btn"
|
|
182
|
+
onClick={onClose}
|
|
183
|
+
aria-label="Close LLM settings"
|
|
184
|
+
>
|
|
185
|
+
<X size={14} aria-hidden />
|
|
186
|
+
</button>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
{loading ? (
|
|
191
|
+
<div className="assistant-llm-settings-strip__loading" role="status">
|
|
192
|
+
<Loader2 size={16} className="assistant-icon-spin" aria-hidden />
|
|
193
|
+
Loading…
|
|
194
|
+
</div>
|
|
195
|
+
) : form ? (
|
|
196
|
+
<form
|
|
197
|
+
className="assistant-llm-settings-strip__form"
|
|
198
|
+
onSubmit={handleSubmit}
|
|
199
|
+
>
|
|
200
|
+
<div className="assistant-llm-settings-strip__fields">
|
|
201
|
+
<label className="assistant-llm-settings-strip__field">
|
|
202
|
+
<span className="assistant-llm-settings-strip__label">
|
|
203
|
+
Base URL
|
|
204
|
+
</span>
|
|
205
|
+
<input
|
|
206
|
+
className="assistant-input assistant-llm-settings-strip__input assistant-llm-settings-strip__input--mono"
|
|
207
|
+
type="url"
|
|
208
|
+
value={form.baseUrl}
|
|
209
|
+
onChange={(event) => updateField("baseUrl", event.target.value)}
|
|
210
|
+
placeholder="https://api.openai.com/v1"
|
|
211
|
+
autoComplete="off"
|
|
212
|
+
spellCheck={false}
|
|
213
|
+
/>
|
|
214
|
+
</label>
|
|
215
|
+
|
|
216
|
+
<label className="assistant-llm-settings-strip__field">
|
|
217
|
+
<span className="assistant-llm-settings-strip__label">
|
|
218
|
+
API key
|
|
219
|
+
</span>
|
|
220
|
+
<input
|
|
221
|
+
className="assistant-input assistant-llm-settings-strip__input"
|
|
222
|
+
type="password"
|
|
223
|
+
value={form.apiKey}
|
|
224
|
+
onChange={(event) => updateField("apiKey", event.target.value)}
|
|
225
|
+
placeholder={
|
|
226
|
+
form.hasStoredApiKey
|
|
227
|
+
? "Configured — leave blank to keep"
|
|
228
|
+
: "sk-…"
|
|
229
|
+
}
|
|
230
|
+
autoComplete="off"
|
|
231
|
+
spellCheck={false}
|
|
232
|
+
/>
|
|
233
|
+
</label>
|
|
234
|
+
|
|
235
|
+
<label className="assistant-llm-settings-strip__field">
|
|
236
|
+
<span className="assistant-llm-settings-strip__label">Model</span>
|
|
237
|
+
<input
|
|
238
|
+
className="assistant-input assistant-llm-settings-strip__input"
|
|
239
|
+
type="text"
|
|
240
|
+
value={form.model}
|
|
241
|
+
onChange={(event) => updateField("model", event.target.value)}
|
|
242
|
+
placeholder="gpt-4o-mini"
|
|
243
|
+
autoComplete="off"
|
|
244
|
+
spellCheck={false}
|
|
245
|
+
/>
|
|
246
|
+
</label>
|
|
247
|
+
|
|
248
|
+
<button
|
|
249
|
+
type="button"
|
|
250
|
+
className="assistant-llm-settings-strip__advanced-toggle"
|
|
251
|
+
onClick={() => setAdvancedOpen((value) => !value)}
|
|
252
|
+
aria-expanded={advancedOpen}
|
|
253
|
+
aria-controls="assistant-llm-settings-advanced"
|
|
254
|
+
>
|
|
255
|
+
<ChevronDown
|
|
256
|
+
size={14}
|
|
257
|
+
className={`assistant-llm-settings-strip__chevron ${advancedOpen ? "assistant-llm-settings-strip__chevron--open" : ""}`.trim()}
|
|
258
|
+
aria-hidden
|
|
259
|
+
/>
|
|
260
|
+
Advanced
|
|
261
|
+
</button>
|
|
262
|
+
|
|
263
|
+
{advancedOpen ? (
|
|
264
|
+
<div
|
|
265
|
+
id="assistant-llm-settings-advanced"
|
|
266
|
+
className="assistant-llm-settings-strip__advanced"
|
|
267
|
+
>
|
|
268
|
+
<label className="assistant-llm-settings-strip__field">
|
|
269
|
+
<span className="assistant-llm-settings-strip__label">
|
|
270
|
+
Model list
|
|
271
|
+
</span>
|
|
272
|
+
<input
|
|
273
|
+
className="assistant-input assistant-llm-settings-strip__input"
|
|
274
|
+
type="text"
|
|
275
|
+
value={form.modelsText}
|
|
276
|
+
onChange={(event) =>
|
|
277
|
+
updateField("modelsText", event.target.value)
|
|
278
|
+
}
|
|
279
|
+
placeholder="Optional — comma-separated"
|
|
280
|
+
autoComplete="off"
|
|
281
|
+
spellCheck={false}
|
|
282
|
+
/>
|
|
283
|
+
</label>
|
|
284
|
+
|
|
285
|
+
<SystemPromptField
|
|
286
|
+
value={form.systemPrompt}
|
|
287
|
+
defaultPrompt={form.defaultSystemPrompt}
|
|
288
|
+
onChange={(next) => updateField("systemPrompt", next)}
|
|
289
|
+
onReset={() => updateField("systemPrompt", "")}
|
|
290
|
+
disabled={saving || testing}
|
|
291
|
+
/>
|
|
292
|
+
</div>
|
|
293
|
+
) : null}
|
|
294
|
+
|
|
295
|
+
{statusMessage ? (
|
|
296
|
+
<p
|
|
297
|
+
className={`assistant-llm-settings-strip__status assistant-llm-settings-strip__status--${statusTone}`.trim()}
|
|
298
|
+
role={error ? "alert" : "status"}
|
|
299
|
+
>
|
|
300
|
+
{statusMessage}
|
|
301
|
+
</p>
|
|
302
|
+
) : null}
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
<div className="assistant-llm-settings-strip__footer">
|
|
306
|
+
{canReset ? (
|
|
307
|
+
<button
|
|
308
|
+
type="button"
|
|
309
|
+
className="assistant-btn assistant-btn--ghost assistant-llm-settings-strip__footer-btn assistant-llm-settings-strip__footer-btn--reset"
|
|
310
|
+
onClick={() => void handleReset()}
|
|
311
|
+
disabled={saving || testing}
|
|
312
|
+
>
|
|
313
|
+
<RotateCcw size={14} aria-hidden />
|
|
314
|
+
Reset all
|
|
315
|
+
</button>
|
|
316
|
+
) : null}
|
|
317
|
+
<button
|
|
318
|
+
type="button"
|
|
319
|
+
className="assistant-btn assistant-btn--ghost assistant-llm-settings-strip__footer-btn"
|
|
320
|
+
onClick={onClose}
|
|
321
|
+
disabled={saving}
|
|
322
|
+
>
|
|
323
|
+
Cancel
|
|
324
|
+
</button>
|
|
325
|
+
<button
|
|
326
|
+
type="submit"
|
|
327
|
+
className="assistant-btn assistant-btn--primary assistant-llm-settings-strip__footer-btn"
|
|
328
|
+
disabled={saving || testing}
|
|
329
|
+
>
|
|
330
|
+
{saving ? (
|
|
331
|
+
<>
|
|
332
|
+
<Loader2
|
|
333
|
+
size={14}
|
|
334
|
+
className="assistant-icon-spin"
|
|
335
|
+
aria-hidden
|
|
336
|
+
/>
|
|
337
|
+
Saving
|
|
338
|
+
</>
|
|
339
|
+
) : (
|
|
340
|
+
"Save"
|
|
341
|
+
)}
|
|
342
|
+
</button>
|
|
343
|
+
</div>
|
|
344
|
+
</form>
|
|
345
|
+
) : null}
|
|
346
|
+
</section>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Settings2, Sparkles } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
interface LlmSetupPromptProps {
|
|
4
|
+
variant: "banner" | "inline";
|
|
5
|
+
onConfigure: () => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function LlmSetupPrompt({ variant, onConfigure }: LlmSetupPromptProps) {
|
|
9
|
+
if (variant === "inline") {
|
|
10
|
+
return (
|
|
11
|
+
<div
|
|
12
|
+
className="assistant-llm-setup assistant-llm-setup--inline"
|
|
13
|
+
role="status"
|
|
14
|
+
>
|
|
15
|
+
<p className="assistant-llm-setup__copy">
|
|
16
|
+
<span className="assistant-llm-setup__label">LLM not connected.</span>{" "}
|
|
17
|
+
Add a cloud or local provider in{" "}
|
|
18
|
+
<button
|
|
19
|
+
type="button"
|
|
20
|
+
className="assistant-llm-setup__action"
|
|
21
|
+
onClick={onConfigure}
|
|
22
|
+
>
|
|
23
|
+
LLM settings
|
|
24
|
+
</button>
|
|
25
|
+
.
|
|
26
|
+
</p>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
className="assistant-llm-setup assistant-llm-setup--banner"
|
|
34
|
+
role="status"
|
|
35
|
+
>
|
|
36
|
+
<div className="assistant-llm-setup__row">
|
|
37
|
+
<span className="assistant-llm-setup__badge" aria-hidden>
|
|
38
|
+
<Sparkles size={12} strokeWidth={2} />
|
|
39
|
+
</span>
|
|
40
|
+
<p className="assistant-llm-setup__copy">
|
|
41
|
+
<strong>Connect an LLM</strong>
|
|
42
|
+
<span className="assistant-llm-setup__sep" aria-hidden>
|
|
43
|
+
·
|
|
44
|
+
</span>
|
|
45
|
+
OpenAI-compatible endpoint — cloud or local
|
|
46
|
+
</p>
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
className="assistant-llm-setup__chip"
|
|
50
|
+
onClick={onConfigure}
|
|
51
|
+
>
|
|
52
|
+
<Settings2 size={12} strokeWidth={2} aria-hidden />
|
|
53
|
+
Configure
|
|
54
|
+
</button>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { LlmSetupPrompt } from "./LlmSetupPrompt.tsx";
|
|
2
|
+
|
|
3
|
+
interface LlmUnavailableBannerProps {
|
|
4
|
+
onConfigure: () => void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function LlmUnavailableBanner({
|
|
8
|
+
onConfigure,
|
|
9
|
+
}: LlmUnavailableBannerProps) {
|
|
10
|
+
return <LlmSetupPrompt variant="banner" onConfigure={onConfigure} />;
|
|
11
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { ChevronRight } from "lucide-react";
|
|
2
|
+
import { DEFAULT_PROMPT_ICON } from "../../lib/prompt-icons.ts";
|
|
3
|
+
import type { AssistantSuggestedPromptWithIcon } from "../../types.ts";
|
|
4
|
+
import { AssistantErrorCallout } from "./AssistantErrorCallout.tsx";
|
|
5
|
+
|
|
6
|
+
function PromptSkeleton({
|
|
7
|
+
index,
|
|
8
|
+
compact,
|
|
9
|
+
}: {
|
|
10
|
+
index: number;
|
|
11
|
+
compact?: boolean;
|
|
12
|
+
}) {
|
|
13
|
+
return (
|
|
14
|
+
<div
|
|
15
|
+
className={`assistant-empty-state__prompt assistant-empty-state__prompt--skeleton ${compact ? "assistant-empty-state__prompt--compact" : ""}`.trim()}
|
|
16
|
+
style={{ animationDelay: `${index * 45}ms` }}
|
|
17
|
+
aria-hidden
|
|
18
|
+
>
|
|
19
|
+
<span className="assistant-empty-state__icon assistant-empty-state__icon--skeleton" />
|
|
20
|
+
<span className="assistant-empty-state__prompt-text">
|
|
21
|
+
<span className="assistant-empty-state__skeleton-line assistant-empty-state__skeleton-line--title" />
|
|
22
|
+
<span className="assistant-empty-state__skeleton-line assistant-empty-state__skeleton-line--hint" />
|
|
23
|
+
</span>
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface SuggestedPromptsListProps {
|
|
29
|
+
prompts: AssistantSuggestedPromptWithIcon[];
|
|
30
|
+
loading?: boolean;
|
|
31
|
+
error?: string | null;
|
|
32
|
+
onSelect: (prompt: string) => void;
|
|
33
|
+
onRetry?: () => void;
|
|
34
|
+
retryLoading?: boolean;
|
|
35
|
+
className?: string;
|
|
36
|
+
compact?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function SuggestedPromptsList({
|
|
40
|
+
prompts,
|
|
41
|
+
loading,
|
|
42
|
+
error,
|
|
43
|
+
onSelect,
|
|
44
|
+
onRetry,
|
|
45
|
+
retryLoading,
|
|
46
|
+
className,
|
|
47
|
+
compact,
|
|
48
|
+
}: SuggestedPromptsListProps) {
|
|
49
|
+
if (loading) {
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
className={`assistant-empty-state__grid ${compact ? "assistant-empty-state__grid--compact" : ""} ${className ?? ""}`.trim()}
|
|
53
|
+
role="status"
|
|
54
|
+
aria-live="polite"
|
|
55
|
+
aria-busy="true"
|
|
56
|
+
>
|
|
57
|
+
<span className="sr-only">Loading suggestions</span>
|
|
58
|
+
{(compact ? ["a", "b", "c"] : ["a", "b", "c", "d", "e", "f"]).map(
|
|
59
|
+
(key, index) => (
|
|
60
|
+
<PromptSkeleton key={key} index={index} compact={compact} />
|
|
61
|
+
),
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (error) {
|
|
68
|
+
return (
|
|
69
|
+
<AssistantErrorCallout
|
|
70
|
+
error={error}
|
|
71
|
+
context="suggestions"
|
|
72
|
+
variant="panel"
|
|
73
|
+
onRetry={onRetry}
|
|
74
|
+
retryLoading={retryLoading}
|
|
75
|
+
retryLabel="Regenerate"
|
|
76
|
+
/>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (prompts.length === 0) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div
|
|
86
|
+
className={`assistant-empty-state__grid ${compact ? "assistant-empty-state__grid--compact" : ""} ${className ?? ""}`.trim()}
|
|
87
|
+
>
|
|
88
|
+
{prompts.map(({ id, icon: Icon, label, hint, prompt }, index) => {
|
|
89
|
+
const PromptIcon = Icon ?? DEFAULT_PROMPT_ICON;
|
|
90
|
+
return (
|
|
91
|
+
<button
|
|
92
|
+
key={id}
|
|
93
|
+
type="button"
|
|
94
|
+
className={`assistant-empty-state__prompt ${compact ? "assistant-empty-state__prompt--compact" : ""}`.trim()}
|
|
95
|
+
style={{ animationDelay: `${index * 45}ms` }}
|
|
96
|
+
onClick={() => onSelect(prompt)}
|
|
97
|
+
>
|
|
98
|
+
<span className="assistant-empty-state__icon" aria-hidden>
|
|
99
|
+
<PromptIcon size={14} strokeWidth={2} />
|
|
100
|
+
</span>
|
|
101
|
+
<span className="assistant-empty-state__prompt-text">
|
|
102
|
+
<span className="assistant-empty-state__prompt-label">
|
|
103
|
+
{label}
|
|
104
|
+
</span>
|
|
105
|
+
{hint ? (
|
|
106
|
+
<span className="assistant-empty-state__prompt-hint">
|
|
107
|
+
{hint}
|
|
108
|
+
</span>
|
|
109
|
+
) : null}
|
|
110
|
+
</span>
|
|
111
|
+
<ChevronRight
|
|
112
|
+
size={14}
|
|
113
|
+
className="assistant-empty-state__prompt-arrow"
|
|
114
|
+
aria-hidden
|
|
115
|
+
/>
|
|
116
|
+
</button>
|
|
117
|
+
);
|
|
118
|
+
})}
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Loader2, Sparkles, X } from "lucide-react";
|
|
2
|
+
import type { useSuggestedPrompts } from "../../hooks/use-suggested-prompts.ts";
|
|
3
|
+
import { SuggestedPromptsList } from "./SuggestedPromptsList.tsx";
|
|
4
|
+
|
|
5
|
+
interface SuggestedPromptsStripProps {
|
|
6
|
+
open: boolean;
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
onRefresh: () => void;
|
|
9
|
+
onSelect: (prompt: string) => void;
|
|
10
|
+
suggestions: ReturnType<typeof useSuggestedPrompts>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function SuggestedPromptsStrip({
|
|
14
|
+
open,
|
|
15
|
+
onClose,
|
|
16
|
+
onRefresh,
|
|
17
|
+
onSelect,
|
|
18
|
+
suggestions,
|
|
19
|
+
}: SuggestedPromptsStripProps) {
|
|
20
|
+
if (!open) return null;
|
|
21
|
+
|
|
22
|
+
const { prompts, loading, error, hasFetched } = suggestions;
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<section
|
|
26
|
+
className="assistant-suggestions-strip"
|
|
27
|
+
aria-label="Suggested prompts"
|
|
28
|
+
>
|
|
29
|
+
<div className="assistant-suggestions-strip__header">
|
|
30
|
+
<div className="assistant-suggestions-strip__title">
|
|
31
|
+
<Sparkles size={14} aria-hidden />
|
|
32
|
+
<span>Suggestions</span>
|
|
33
|
+
</div>
|
|
34
|
+
<div className="assistant-suggestions-strip__actions">
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
className="assistant-btn assistant-btn--ghost assistant-suggestions-strip__refresh"
|
|
38
|
+
onClick={onRefresh}
|
|
39
|
+
disabled={loading}
|
|
40
|
+
>
|
|
41
|
+
{loading ? (
|
|
42
|
+
<Loader2 size={14} className="assistant-icon-spin" aria-hidden />
|
|
43
|
+
) : (
|
|
44
|
+
<Sparkles size={14} aria-hidden />
|
|
45
|
+
)}
|
|
46
|
+
{hasFetched ? "Regenerate" : "Generate"}
|
|
47
|
+
</button>
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
className="assistant-btn assistant-btn--ghost assistant-suggestions-strip__close"
|
|
51
|
+
onClick={onClose}
|
|
52
|
+
aria-label="Close suggestions"
|
|
53
|
+
>
|
|
54
|
+
<X size={14} aria-hidden />
|
|
55
|
+
</button>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
<SuggestedPromptsList
|
|
59
|
+
prompts={prompts}
|
|
60
|
+
loading={loading}
|
|
61
|
+
error={error}
|
|
62
|
+
onRetry={onRefresh}
|
|
63
|
+
retryLoading={loading}
|
|
64
|
+
onSelect={(prompt) => {
|
|
65
|
+
onSelect(prompt);
|
|
66
|
+
onClose();
|
|
67
|
+
}}
|
|
68
|
+
compact
|
|
69
|
+
/>
|
|
70
|
+
</section>
|
|
71
|
+
);
|
|
72
|
+
}
|