@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,639 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
import type { ChatActivityStep } from "./chat-activity.ts";
|
|
3
|
+
import { summarizeActivityResult } from "./chat-activity.ts";
|
|
4
|
+
import { createChatHistoryHelpers } from "./chat-history.ts";
|
|
5
|
+
import {
|
|
6
|
+
rejectAllInteractiveToolWaiters,
|
|
7
|
+
resolveInteractiveToolResult,
|
|
8
|
+
} from "./interactive-tools/index.ts";
|
|
9
|
+
import {
|
|
10
|
+
buildLlmHistory,
|
|
11
|
+
ChatAbortedError,
|
|
12
|
+
fetchLlmStatus,
|
|
13
|
+
runLlmAgent,
|
|
14
|
+
} from "./llm-chat.ts";
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_ASSISTANT_SYSTEM_PROMPT,
|
|
17
|
+
DEFAULT_LLM_MODEL,
|
|
18
|
+
isLocalLlmBaseUrl,
|
|
19
|
+
LLM_UNAVAILABLE_MESSAGE,
|
|
20
|
+
} from "./llm-config.ts";
|
|
21
|
+
import {
|
|
22
|
+
configureAssistantLlm,
|
|
23
|
+
resolveAssistantLlmSettings,
|
|
24
|
+
testLlmConnection,
|
|
25
|
+
} from "./llm-provider.ts";
|
|
26
|
+
import {
|
|
27
|
+
buildDefaultLlmSettings,
|
|
28
|
+
createDefaultStoredSettings,
|
|
29
|
+
createLlmSettingsFormState,
|
|
30
|
+
createLlmSettingsFormStateFromStored,
|
|
31
|
+
createLlmSettingsStorage,
|
|
32
|
+
type LlmSettingsFormState,
|
|
33
|
+
type LlmSettingsFormValues,
|
|
34
|
+
mergeLlmSettings,
|
|
35
|
+
migrateLegacyModelStorage,
|
|
36
|
+
normalizeStoredSystemPrompt,
|
|
37
|
+
peekStoredModel,
|
|
38
|
+
persistStoredModelSelection,
|
|
39
|
+
resolveSelectedModel,
|
|
40
|
+
storedSettingsHaveOverrides,
|
|
41
|
+
toStoredSettings,
|
|
42
|
+
} from "./llm-settings-storage.ts";
|
|
43
|
+
import type {
|
|
44
|
+
AssistantLlmSettings,
|
|
45
|
+
AssistantMessage,
|
|
46
|
+
AssistantStoreDependencies,
|
|
47
|
+
} from "./types.ts";
|
|
48
|
+
|
|
49
|
+
export interface AssistantState {
|
|
50
|
+
messages: AssistantMessage[];
|
|
51
|
+
chatLoading: boolean;
|
|
52
|
+
llmEnabled: boolean;
|
|
53
|
+
llmModel: string | null;
|
|
54
|
+
llmModels: string[];
|
|
55
|
+
llmModelsLoading: boolean;
|
|
56
|
+
selectedModel: string | null;
|
|
57
|
+
|
|
58
|
+
loadLlmStatus: () => Promise<void>;
|
|
59
|
+
getLlmSettingsForm: () => Promise<LlmSettingsFormState>;
|
|
60
|
+
saveLlmSettings: (values: LlmSettingsFormValues) => Promise<void>;
|
|
61
|
+
clearLlmSettings: () => Promise<void>;
|
|
62
|
+
testLlmSettings: (
|
|
63
|
+
values: LlmSettingsFormValues,
|
|
64
|
+
) => Promise<{ ok: true; model: string } | { ok: false; error: string }>;
|
|
65
|
+
llmSettingsHasOverrides: () => Promise<boolean>;
|
|
66
|
+
setSelectedModel: (model: string) => void;
|
|
67
|
+
sendChat: (message: string) => Promise<void>;
|
|
68
|
+
retryLastChat: () => Promise<void>;
|
|
69
|
+
clearChatHistory: () => void;
|
|
70
|
+
stopChat: () => void;
|
|
71
|
+
submitInteractiveToolResult: (callId: string, result: unknown) => void;
|
|
72
|
+
cancelInteractiveToolResult: (callId: string) => void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function patchWelcomeMessage(
|
|
76
|
+
messages: AssistantMessage[],
|
|
77
|
+
welcomeMessage: AssistantStoreDependencies["welcomeMessage"],
|
|
78
|
+
model: string | null,
|
|
79
|
+
llmEnabled: boolean,
|
|
80
|
+
): AssistantMessage[] {
|
|
81
|
+
const welcome = welcomeMessage({ llmEnabled, model });
|
|
82
|
+
return messages.map((message) =>
|
|
83
|
+
message.id === "welcome"
|
|
84
|
+
? { ...message, content: welcome.content }
|
|
85
|
+
: message,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function patchTurnMessage(
|
|
90
|
+
messages: AssistantMessage[],
|
|
91
|
+
turnId: string,
|
|
92
|
+
patch: (message: AssistantMessage) => AssistantMessage,
|
|
93
|
+
): AssistantMessage[] {
|
|
94
|
+
return messages.map((message) =>
|
|
95
|
+
message.id === turnId ? patch(message) : message,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function createTurnMessage(turnId: string): AssistantMessage {
|
|
100
|
+
return {
|
|
101
|
+
id: turnId,
|
|
102
|
+
role: "assistant",
|
|
103
|
+
content: "",
|
|
104
|
+
activity: [],
|
|
105
|
+
streaming: true,
|
|
106
|
+
timestamp: Date.now(),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function appendActivityStep(
|
|
111
|
+
messages: AssistantMessage[],
|
|
112
|
+
turnId: string,
|
|
113
|
+
step: ChatActivityStep,
|
|
114
|
+
): AssistantMessage[] {
|
|
115
|
+
return patchTurnMessage(messages, turnId, (message) => ({
|
|
116
|
+
...message,
|
|
117
|
+
activity: [...(message.activity ?? []), step],
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function finishActivityStep(
|
|
122
|
+
messages: AssistantMessage[],
|
|
123
|
+
turnId: string,
|
|
124
|
+
stepId: string,
|
|
125
|
+
update: Pick<ChatActivityStep, "status" | "result" | "error">,
|
|
126
|
+
): AssistantMessage[] {
|
|
127
|
+
return patchTurnMessage(messages, turnId, (message) => ({
|
|
128
|
+
...message,
|
|
129
|
+
activity: (message.activity ?? []).map((step) =>
|
|
130
|
+
step.id === stepId ? { ...step, ...update } : step,
|
|
131
|
+
),
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function finalizeStoppedMessages(
|
|
136
|
+
messages: AssistantMessage[],
|
|
137
|
+
): AssistantMessage[] {
|
|
138
|
+
return messages.map((message) => {
|
|
139
|
+
if (!message.streaming) return message;
|
|
140
|
+
const activity = (message.activity ?? []).map((step) =>
|
|
141
|
+
step.status === "active"
|
|
142
|
+
? { ...step, status: "error" as const, error: "Stopped" }
|
|
143
|
+
: step,
|
|
144
|
+
);
|
|
145
|
+
return {
|
|
146
|
+
...message,
|
|
147
|
+
streaming: false,
|
|
148
|
+
activity,
|
|
149
|
+
content: message.content.trim() || "_(stopped)_",
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function finalizeErroredMessages(
|
|
155
|
+
messages: AssistantMessage[],
|
|
156
|
+
errorMessage: string,
|
|
157
|
+
): AssistantMessage[] {
|
|
158
|
+
const hasStreaming = messages.some((message) => message.streaming);
|
|
159
|
+
if (!hasStreaming) {
|
|
160
|
+
return [
|
|
161
|
+
...messages,
|
|
162
|
+
{
|
|
163
|
+
id: crypto.randomUUID(),
|
|
164
|
+
role: "assistant",
|
|
165
|
+
content: errorMessage,
|
|
166
|
+
isError: true,
|
|
167
|
+
timestamp: Date.now(),
|
|
168
|
+
},
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return messages.map((message) => {
|
|
173
|
+
if (!message.streaming) return message;
|
|
174
|
+
const activity = (message.activity ?? []).map((step) =>
|
|
175
|
+
step.status === "active"
|
|
176
|
+
? { ...step, status: "error" as const, error: errorMessage }
|
|
177
|
+
: step,
|
|
178
|
+
);
|
|
179
|
+
return {
|
|
180
|
+
...message,
|
|
181
|
+
streaming: false,
|
|
182
|
+
isError: true,
|
|
183
|
+
activity,
|
|
184
|
+
content: errorMessage,
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function clearReplySuggestions(
|
|
190
|
+
messages: AssistantMessage[],
|
|
191
|
+
): AssistantMessage[] {
|
|
192
|
+
return messages.map((message) =>
|
|
193
|
+
message.replySuggestions
|
|
194
|
+
? { ...message, replySuggestions: undefined }
|
|
195
|
+
: message,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function createAssistantStore(deps: AssistantStoreDependencies) {
|
|
200
|
+
const historyKey = deps.storageKeys?.history ?? "assistant-chat-history";
|
|
201
|
+
const llmSettingsKey =
|
|
202
|
+
deps.storageKeys?.llmSettings ?? "assistant-llm-settings";
|
|
203
|
+
const llmSettingsStorage = createLlmSettingsStorage(llmSettingsKey);
|
|
204
|
+
|
|
205
|
+
async function resolveBaseLlmSettings(): Promise<AssistantLlmSettings> {
|
|
206
|
+
if (!deps.llm) return buildDefaultLlmSettings();
|
|
207
|
+
return typeof deps.llm === "function" ? await deps.llm() : deps.llm;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function ensureLlmSettingsStored(): Promise<void> {
|
|
211
|
+
const base = await resolveBaseLlmSettings();
|
|
212
|
+
migrateLegacyModelStorage(llmSettingsKey, deps.storageKeys?.model, base);
|
|
213
|
+
if (llmSettingsStorage.hasStored()) return;
|
|
214
|
+
llmSettingsStorage.save(createDefaultStoredSettings(base));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function resolveMergedLlmSettings(): Promise<AssistantLlmSettings> {
|
|
218
|
+
const base = await resolveBaseLlmSettings();
|
|
219
|
+
const stored = llmSettingsStorage.load(base);
|
|
220
|
+
return mergeLlmSettings(base, stored);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
configureAssistantLlm(resolveMergedLlmSettings);
|
|
224
|
+
|
|
225
|
+
const history = createChatHistoryHelpers({
|
|
226
|
+
storageKey: historyKey,
|
|
227
|
+
welcomeMessage: deps.welcomeMessage,
|
|
228
|
+
});
|
|
229
|
+
const initialSelectedModel = peekStoredModel(llmSettingsKey);
|
|
230
|
+
|
|
231
|
+
let chatAbortController: AbortController | null = null;
|
|
232
|
+
const messages = history.loadInitial({
|
|
233
|
+
llmEnabled: false,
|
|
234
|
+
model: initialSelectedModel,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const store = create<AssistantState>((set, get) => {
|
|
238
|
+
async function runLlmChatTurn(message: string) {
|
|
239
|
+
const tools = await deps.listTools();
|
|
240
|
+
const storedMessages = get().messages.filter((m) => m.id !== "welcome");
|
|
241
|
+
// sendChat already appended the current user message; runLlmAgent adds it again.
|
|
242
|
+
const priorMessages =
|
|
243
|
+
storedMessages.at(-1)?.role === "user"
|
|
244
|
+
? storedMessages.slice(0, -1)
|
|
245
|
+
: storedMessages;
|
|
246
|
+
const llmHistory = buildLlmHistory(priorMessages);
|
|
247
|
+
const turnId = crypto.randomUUID();
|
|
248
|
+
|
|
249
|
+
set((state) => ({
|
|
250
|
+
messages: [...state.messages, createTurnMessage(turnId)],
|
|
251
|
+
}));
|
|
252
|
+
|
|
253
|
+
await runLlmAgent({
|
|
254
|
+
userMessage: message,
|
|
255
|
+
history: llmHistory,
|
|
256
|
+
tools,
|
|
257
|
+
model: get().selectedModel ?? undefined,
|
|
258
|
+
signal: chatAbortController?.signal,
|
|
259
|
+
stream: {
|
|
260
|
+
turnId,
|
|
261
|
+
onUpdate: (content) => {
|
|
262
|
+
set((state) => ({
|
|
263
|
+
messages: patchTurnMessage(state.messages, turnId, (msg) => ({
|
|
264
|
+
...msg,
|
|
265
|
+
content,
|
|
266
|
+
})),
|
|
267
|
+
}));
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
toolHandlers: {
|
|
271
|
+
onStart: ({ id, name, args, callId }) => {
|
|
272
|
+
set((state) => ({
|
|
273
|
+
messages: appendActivityStep(state.messages, turnId, {
|
|
274
|
+
id,
|
|
275
|
+
kind: "tool",
|
|
276
|
+
name,
|
|
277
|
+
args,
|
|
278
|
+
callId,
|
|
279
|
+
status: "active",
|
|
280
|
+
}),
|
|
281
|
+
}));
|
|
282
|
+
},
|
|
283
|
+
onFinish: (stepId, update) => {
|
|
284
|
+
set((state) => ({
|
|
285
|
+
messages: finishActivityStep(state.messages, turnId, stepId, {
|
|
286
|
+
status: update.status,
|
|
287
|
+
result: summarizeActivityResult(update.result),
|
|
288
|
+
error: update.error,
|
|
289
|
+
}),
|
|
290
|
+
}));
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
onPostResponseTool: (suggestions) => {
|
|
294
|
+
set((state) => ({
|
|
295
|
+
messages: patchTurnMessage(state.messages, turnId, (msg) => ({
|
|
296
|
+
...msg,
|
|
297
|
+
replySuggestions: suggestions,
|
|
298
|
+
})),
|
|
299
|
+
}));
|
|
300
|
+
},
|
|
301
|
+
invokeTool: async (name, args) => {
|
|
302
|
+
const result = await deps.invokeTool(name, args);
|
|
303
|
+
deps.onToolInvoked?.(result);
|
|
304
|
+
return result;
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
set((state) => ({
|
|
309
|
+
messages: state.messages.map((msg) =>
|
|
310
|
+
msg.id === turnId
|
|
311
|
+
? {
|
|
312
|
+
...msg,
|
|
313
|
+
streaming: false,
|
|
314
|
+
content:
|
|
315
|
+
msg.content.trim() ||
|
|
316
|
+
(msg.activity?.length
|
|
317
|
+
? ""
|
|
318
|
+
: "I couldn't produce a response."),
|
|
319
|
+
}
|
|
320
|
+
: msg,
|
|
321
|
+
),
|
|
322
|
+
chatLoading: false,
|
|
323
|
+
}));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
messages,
|
|
328
|
+
chatLoading: false,
|
|
329
|
+
llmEnabled: false,
|
|
330
|
+
llmModel: null,
|
|
331
|
+
llmModels: [],
|
|
332
|
+
llmModelsLoading: false,
|
|
333
|
+
selectedModel: initialSelectedModel,
|
|
334
|
+
|
|
335
|
+
loadLlmStatus: async () => {
|
|
336
|
+
set({ llmModelsLoading: true });
|
|
337
|
+
try {
|
|
338
|
+
await ensureLlmSettingsStored();
|
|
339
|
+
const base = await resolveBaseLlmSettings();
|
|
340
|
+
const stored =
|
|
341
|
+
llmSettingsStorage.load(base) ?? createDefaultStoredSettings(base);
|
|
342
|
+
const status = await fetchLlmStatus();
|
|
343
|
+
const defaultModel =
|
|
344
|
+
status.model ?? stored.model ?? DEFAULT_LLM_MODEL;
|
|
345
|
+
const baseModels = status.models.length
|
|
346
|
+
? status.models
|
|
347
|
+
: [defaultModel];
|
|
348
|
+
const selectedModel = resolveSelectedModel(
|
|
349
|
+
stored.model,
|
|
350
|
+
defaultModel,
|
|
351
|
+
baseModels,
|
|
352
|
+
);
|
|
353
|
+
const llmModels =
|
|
354
|
+
selectedModel && !baseModels.includes(selectedModel)
|
|
355
|
+
? [selectedModel, ...baseModels]
|
|
356
|
+
: baseModels;
|
|
357
|
+
set((state) => ({
|
|
358
|
+
llmEnabled: status.enabled,
|
|
359
|
+
llmModel: defaultModel,
|
|
360
|
+
llmModels,
|
|
361
|
+
selectedModel,
|
|
362
|
+
messages: patchWelcomeMessage(
|
|
363
|
+
state.messages,
|
|
364
|
+
deps.welcomeMessage,
|
|
365
|
+
selectedModel,
|
|
366
|
+
status.enabled,
|
|
367
|
+
),
|
|
368
|
+
}));
|
|
369
|
+
} finally {
|
|
370
|
+
set({ llmModelsLoading: false });
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
getLlmSettingsForm: async () => {
|
|
375
|
+
await ensureLlmSettingsStored();
|
|
376
|
+
const base = await resolveBaseLlmSettings();
|
|
377
|
+
const defaultSystemPrompt =
|
|
378
|
+
base.systemPrompt?.trim() || DEFAULT_ASSISTANT_SYSTEM_PROMPT;
|
|
379
|
+
const stored = llmSettingsStorage.load(base);
|
|
380
|
+
if (stored) {
|
|
381
|
+
return createLlmSettingsFormStateFromStored(
|
|
382
|
+
{
|
|
383
|
+
...stored,
|
|
384
|
+
systemPrompt: normalizeStoredSystemPrompt(
|
|
385
|
+
stored.systemPrompt,
|
|
386
|
+
base,
|
|
387
|
+
),
|
|
388
|
+
},
|
|
389
|
+
defaultSystemPrompt,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
return createLlmSettingsFormState(
|
|
393
|
+
base,
|
|
394
|
+
Boolean(base.apiKey),
|
|
395
|
+
defaultSystemPrompt,
|
|
396
|
+
);
|
|
397
|
+
},
|
|
398
|
+
|
|
399
|
+
saveLlmSettings: async (values) => {
|
|
400
|
+
const base = await resolveBaseLlmSettings();
|
|
401
|
+
const defaultSystemPrompt =
|
|
402
|
+
base.systemPrompt?.trim() || DEFAULT_ASSISTANT_SYSTEM_PROMPT;
|
|
403
|
+
const current = await resolveAssistantLlmSettings();
|
|
404
|
+
const normalizedValues = {
|
|
405
|
+
...values,
|
|
406
|
+
systemPrompt:
|
|
407
|
+
values.systemPrompt.trim() === defaultSystemPrompt
|
|
408
|
+
? ""
|
|
409
|
+
: values.systemPrompt,
|
|
410
|
+
};
|
|
411
|
+
llmSettingsStorage.save(
|
|
412
|
+
toStoredSettings(normalizedValues, current.apiKey),
|
|
413
|
+
);
|
|
414
|
+
await get().loadLlmStatus();
|
|
415
|
+
},
|
|
416
|
+
|
|
417
|
+
clearLlmSettings: async () => {
|
|
418
|
+
llmSettingsStorage.clear();
|
|
419
|
+
const base = await resolveBaseLlmSettings();
|
|
420
|
+
llmSettingsStorage.save(createDefaultStoredSettings(base));
|
|
421
|
+
await get().loadLlmStatus();
|
|
422
|
+
},
|
|
423
|
+
|
|
424
|
+
testLlmSettings: async (values) => {
|
|
425
|
+
const current = await resolveAssistantLlmSettings();
|
|
426
|
+
const stored = toStoredSettings(values, current.apiKey);
|
|
427
|
+
if (!stored.apiKey && !isLocalLlmBaseUrl(stored.baseUrl)) {
|
|
428
|
+
return {
|
|
429
|
+
ok: false,
|
|
430
|
+
error: "API key is required for remote LLM providers.",
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
return testLlmConnection({
|
|
434
|
+
baseUrl: stored.baseUrl,
|
|
435
|
+
apiKey: stored.apiKey,
|
|
436
|
+
model: stored.model,
|
|
437
|
+
});
|
|
438
|
+
},
|
|
439
|
+
|
|
440
|
+
llmSettingsHasOverrides: async () => {
|
|
441
|
+
await ensureLlmSettingsStored();
|
|
442
|
+
const base = await resolveBaseLlmSettings();
|
|
443
|
+
const stored = llmSettingsStorage.load(base);
|
|
444
|
+
if (!stored) return false;
|
|
445
|
+
return storedSettingsHaveOverrides(stored, base);
|
|
446
|
+
},
|
|
447
|
+
|
|
448
|
+
setSelectedModel: (model) => {
|
|
449
|
+
const trimmed = model.trim();
|
|
450
|
+
if (!trimmed) return;
|
|
451
|
+
const { llmEnabled, llmModels } = get();
|
|
452
|
+
const models = llmModels.includes(trimmed)
|
|
453
|
+
? llmModels
|
|
454
|
+
: [trimmed, ...llmModels];
|
|
455
|
+
persistStoredModelSelection(llmSettingsKey, trimmed);
|
|
456
|
+
set((state) => ({
|
|
457
|
+
selectedModel: trimmed,
|
|
458
|
+
llmModels: models,
|
|
459
|
+
messages: patchWelcomeMessage(
|
|
460
|
+
state.messages,
|
|
461
|
+
deps.welcomeMessage,
|
|
462
|
+
trimmed,
|
|
463
|
+
llmEnabled,
|
|
464
|
+
),
|
|
465
|
+
}));
|
|
466
|
+
},
|
|
467
|
+
|
|
468
|
+
clearChatHistory: () => {
|
|
469
|
+
if (get().chatLoading) return;
|
|
470
|
+
const { llmEnabled, selectedModel } = get();
|
|
471
|
+
history.clear();
|
|
472
|
+
set({
|
|
473
|
+
messages: [deps.welcomeMessage({ llmEnabled, model: selectedModel })],
|
|
474
|
+
});
|
|
475
|
+
},
|
|
476
|
+
|
|
477
|
+
stopChat: () => {
|
|
478
|
+
chatAbortController?.abort();
|
|
479
|
+
rejectAllInteractiveToolWaiters(
|
|
480
|
+
new DOMException("Aborted", "AbortError"),
|
|
481
|
+
);
|
|
482
|
+
set((state) => ({
|
|
483
|
+
messages: finalizeStoppedMessages(state.messages),
|
|
484
|
+
chatLoading: false,
|
|
485
|
+
}));
|
|
486
|
+
},
|
|
487
|
+
|
|
488
|
+
submitInteractiveToolResult: (callId, result) => {
|
|
489
|
+
resolveInteractiveToolResult(callId, result);
|
|
490
|
+
},
|
|
491
|
+
|
|
492
|
+
cancelInteractiveToolResult: (callId) => {
|
|
493
|
+
resolveInteractiveToolResult(callId, {
|
|
494
|
+
cancelled: true,
|
|
495
|
+
confirmed: false,
|
|
496
|
+
selected: [],
|
|
497
|
+
});
|
|
498
|
+
},
|
|
499
|
+
|
|
500
|
+
sendChat: async (message) => {
|
|
501
|
+
const userMsg: AssistantMessage = {
|
|
502
|
+
id: crypto.randomUUID(),
|
|
503
|
+
role: "user",
|
|
504
|
+
content: message,
|
|
505
|
+
timestamp: Date.now(),
|
|
506
|
+
};
|
|
507
|
+
chatAbortController?.abort();
|
|
508
|
+
chatAbortController = new AbortController();
|
|
509
|
+
set((state) => ({
|
|
510
|
+
messages: [...clearReplySuggestions(state.messages), userMsg],
|
|
511
|
+
chatLoading: true,
|
|
512
|
+
}));
|
|
513
|
+
|
|
514
|
+
try {
|
|
515
|
+
if (!get().llmEnabled) {
|
|
516
|
+
set((state) => ({
|
|
517
|
+
messages: [
|
|
518
|
+
...state.messages,
|
|
519
|
+
{
|
|
520
|
+
id: crypto.randomUUID(),
|
|
521
|
+
role: "assistant",
|
|
522
|
+
content: LLM_UNAVAILABLE_MESSAGE,
|
|
523
|
+
llmSetupRequired: true,
|
|
524
|
+
timestamp: Date.now(),
|
|
525
|
+
},
|
|
526
|
+
],
|
|
527
|
+
chatLoading: false,
|
|
528
|
+
}));
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
await runLlmChatTurn(message);
|
|
532
|
+
} catch (error) {
|
|
533
|
+
if (
|
|
534
|
+
error instanceof ChatAbortedError ||
|
|
535
|
+
(error instanceof DOMException && error.name === "AbortError") ||
|
|
536
|
+
chatAbortController?.signal.aborted
|
|
537
|
+
) {
|
|
538
|
+
set((state) => ({
|
|
539
|
+
messages: finalizeStoppedMessages(state.messages),
|
|
540
|
+
chatLoading: false,
|
|
541
|
+
}));
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
set((state) => ({
|
|
545
|
+
messages: finalizeErroredMessages(
|
|
546
|
+
state.messages,
|
|
547
|
+
`Chat failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
548
|
+
),
|
|
549
|
+
chatLoading: false,
|
|
550
|
+
}));
|
|
551
|
+
} finally {
|
|
552
|
+
chatAbortController = null;
|
|
553
|
+
}
|
|
554
|
+
},
|
|
555
|
+
|
|
556
|
+
retryLastChat: async () => {
|
|
557
|
+
if (get().chatLoading) return;
|
|
558
|
+
|
|
559
|
+
const { messages } = get();
|
|
560
|
+
let errorIndex = -1;
|
|
561
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
562
|
+
const message = messages[i];
|
|
563
|
+
if (message?.role === "assistant" && message.isError) {
|
|
564
|
+
errorIndex = i;
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if (errorIndex < 0) return;
|
|
569
|
+
|
|
570
|
+
let userContent: string | null = null;
|
|
571
|
+
for (let i = errorIndex - 1; i >= 0; i--) {
|
|
572
|
+
const message = messages[i];
|
|
573
|
+
if (message?.role === "user") {
|
|
574
|
+
userContent = message.content;
|
|
575
|
+
break;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (!userContent) return;
|
|
579
|
+
|
|
580
|
+
chatAbortController?.abort();
|
|
581
|
+
chatAbortController = new AbortController();
|
|
582
|
+
set((state) => ({
|
|
583
|
+
messages: state.messages.filter((_, index) => index !== errorIndex),
|
|
584
|
+
chatLoading: true,
|
|
585
|
+
}));
|
|
586
|
+
|
|
587
|
+
try {
|
|
588
|
+
if (!get().llmEnabled) {
|
|
589
|
+
set((state) => ({
|
|
590
|
+
messages: [
|
|
591
|
+
...state.messages,
|
|
592
|
+
{
|
|
593
|
+
id: crypto.randomUUID(),
|
|
594
|
+
role: "assistant",
|
|
595
|
+
content: LLM_UNAVAILABLE_MESSAGE,
|
|
596
|
+
llmSetupRequired: true,
|
|
597
|
+
timestamp: Date.now(),
|
|
598
|
+
},
|
|
599
|
+
],
|
|
600
|
+
chatLoading: false,
|
|
601
|
+
}));
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
await runLlmChatTurn(userContent);
|
|
605
|
+
} catch (error) {
|
|
606
|
+
if (
|
|
607
|
+
error instanceof ChatAbortedError ||
|
|
608
|
+
(error instanceof DOMException && error.name === "AbortError") ||
|
|
609
|
+
chatAbortController?.signal.aborted
|
|
610
|
+
) {
|
|
611
|
+
set((state) => ({
|
|
612
|
+
messages: finalizeStoppedMessages(state.messages),
|
|
613
|
+
chatLoading: false,
|
|
614
|
+
}));
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
set((state) => ({
|
|
618
|
+
messages: finalizeErroredMessages(
|
|
619
|
+
state.messages,
|
|
620
|
+
`Chat failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
621
|
+
),
|
|
622
|
+
chatLoading: false,
|
|
623
|
+
}));
|
|
624
|
+
} finally {
|
|
625
|
+
chatAbortController = null;
|
|
626
|
+
}
|
|
627
|
+
},
|
|
628
|
+
};
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
store.subscribe((state, previousState) => {
|
|
632
|
+
if (state.messages === previousState.messages) return;
|
|
633
|
+
history.persist(state.messages);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
return store;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
export type AssistantStore = ReturnType<typeof createAssistantStore>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export interface SuggestedPromptsResponse {
|
|
2
|
+
prompts: Array<{
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
prompt: string;
|
|
7
|
+
icon?: string;
|
|
8
|
+
}>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function parseSuggestedPromptsResponse(
|
|
12
|
+
data: unknown,
|
|
13
|
+
): SuggestedPromptsResponse["prompts"] {
|
|
14
|
+
if (!data || typeof data !== "object") return [];
|
|
15
|
+
const root = data as Record<string, unknown>;
|
|
16
|
+
const raw = Array.isArray(root.prompts)
|
|
17
|
+
? root.prompts
|
|
18
|
+
: Array.isArray(root.suggestions)
|
|
19
|
+
? root.suggestions
|
|
20
|
+
: [];
|
|
21
|
+
|
|
22
|
+
const prompts: SuggestedPromptsResponse["prompts"] = [];
|
|
23
|
+
for (const [index, entry] of raw.entries()) {
|
|
24
|
+
if (!entry || typeof entry !== "object") continue;
|
|
25
|
+
const item = entry as Record<string, unknown>;
|
|
26
|
+
const label = typeof item.label === "string" ? item.label.trim() : "";
|
|
27
|
+
const prompt =
|
|
28
|
+
typeof item.prompt === "string"
|
|
29
|
+
? item.prompt.trim()
|
|
30
|
+
: typeof item.message === "string"
|
|
31
|
+
? item.message.trim()
|
|
32
|
+
: "";
|
|
33
|
+
if (!label || !prompt) continue;
|
|
34
|
+
|
|
35
|
+
prompts.push({
|
|
36
|
+
id:
|
|
37
|
+
typeof item.id === "string" && item.id.trim()
|
|
38
|
+
? item.id.trim()
|
|
39
|
+
: `prompt-${index + 1}`,
|
|
40
|
+
label,
|
|
41
|
+
description:
|
|
42
|
+
typeof item.description === "string"
|
|
43
|
+
? item.description.trim()
|
|
44
|
+
: typeof item.hint === "string"
|
|
45
|
+
? item.hint.trim()
|
|
46
|
+
: undefined,
|
|
47
|
+
prompt,
|
|
48
|
+
icon: typeof item.icon === "string" ? item.icon.trim() : undefined,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return prompts.slice(0, 6);
|
|
53
|
+
}
|