@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,101 @@
|
|
|
1
|
+
export const DEFAULT_LLM_BASE_URL = "https://api.openai.com/v1";
|
|
2
|
+
export const DEFAULT_LLM_MODEL = "gpt-4o-mini";
|
|
3
|
+
export const DEFAULT_ASSISTANT_SYSTEM_PROMPT =
|
|
4
|
+
"You are a helpful assistant with access to tools. Use tools when they help answer the user.";
|
|
5
|
+
|
|
6
|
+
const FALLBACK_MODEL_OPTIONS = [
|
|
7
|
+
DEFAULT_LLM_MODEL,
|
|
8
|
+
"gpt-4o",
|
|
9
|
+
"gpt-4.1",
|
|
10
|
+
"gpt-4.1-mini",
|
|
11
|
+
"gpt-4.1-nano",
|
|
12
|
+
"o3-mini",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export interface ResolvedLlmSettings {
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
baseUrl: string;
|
|
18
|
+
apiKey: string | null;
|
|
19
|
+
model: string;
|
|
20
|
+
models?: string[];
|
|
21
|
+
systemPrompt?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getFallbackModels(defaultModel: string): string[] {
|
|
25
|
+
return [...new Set([defaultModel, ...FALLBACK_MODEL_OPTIONS])];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveRequestModel(
|
|
29
|
+
requested: string | undefined,
|
|
30
|
+
config: Pick<ResolvedLlmSettings, "model">,
|
|
31
|
+
): string {
|
|
32
|
+
const candidate = requested?.trim();
|
|
33
|
+
if (candidate) return candidate;
|
|
34
|
+
return config.model;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildCompletionsUrl(baseUrl: string): string {
|
|
38
|
+
const base = baseUrl.replace(/\/+$/, "");
|
|
39
|
+
if (base.endsWith("/v1")) return `${base}/chat/completions`;
|
|
40
|
+
return `${base}/v1/chat/completions`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function buildModelsUrl(baseUrl: string): string {
|
|
44
|
+
const base = baseUrl.replace(/\/+$/, "");
|
|
45
|
+
if (base.endsWith("/v1")) return `${base}/models`;
|
|
46
|
+
return `${base}/v1/models`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isLocalLlmBaseUrl(baseUrl: string): boolean {
|
|
50
|
+
try {
|
|
51
|
+
const normalized = baseUrl.includes("://") ? baseUrl : `http://${baseUrl}`;
|
|
52
|
+
const hostname = new URL(normalized).hostname.toLowerCase();
|
|
53
|
+
return (
|
|
54
|
+
hostname === "localhost" ||
|
|
55
|
+
hostname === "127.0.0.1" ||
|
|
56
|
+
hostname === "0.0.0.0" ||
|
|
57
|
+
hostname === "[::1]" ||
|
|
58
|
+
hostname.endsWith(".local")
|
|
59
|
+
);
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isLlmConfigured(
|
|
66
|
+
settings: Pick<
|
|
67
|
+
ResolvedLlmSettings,
|
|
68
|
+
"enabled" | "baseUrl" | "apiKey" | "model"
|
|
69
|
+
>,
|
|
70
|
+
): boolean {
|
|
71
|
+
if (settings.enabled === false) return false;
|
|
72
|
+
if (!settings.baseUrl?.trim() || !settings.model?.trim()) return false;
|
|
73
|
+
if (settings.apiKey?.trim()) return true;
|
|
74
|
+
return isLocalLlmBaseUrl(settings.baseUrl);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const LLM_UNAVAILABLE_MESSAGE =
|
|
78
|
+
"Chat requires an LLM. Open LLM settings to add your provider base URL, model, and API key (optional for local servers).";
|
|
79
|
+
|
|
80
|
+
export function isLlmUnavailableMessage(message: {
|
|
81
|
+
content: string;
|
|
82
|
+
llmSetupRequired?: boolean;
|
|
83
|
+
}): boolean {
|
|
84
|
+
return (
|
|
85
|
+
message.llmSetupRequired === true ||
|
|
86
|
+
message.content.trim() === LLM_UNAVAILABLE_MESSAGE
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function buildLlmRequestHeaders(
|
|
91
|
+
apiKey: string | null | undefined,
|
|
92
|
+
): Record<string, string> {
|
|
93
|
+
const headers: Record<string, string> = {
|
|
94
|
+
"Content-Type": "application/json",
|
|
95
|
+
};
|
|
96
|
+
const token = apiKey?.trim();
|
|
97
|
+
if (token) {
|
|
98
|
+
headers.Authorization = `Bearer ${token}`;
|
|
99
|
+
}
|
|
100
|
+
return headers;
|
|
101
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildLlmRequestHeaders,
|
|
3
|
+
buildModelsUrl,
|
|
4
|
+
getFallbackModels,
|
|
5
|
+
isLlmConfigured,
|
|
6
|
+
type ResolvedLlmSettings,
|
|
7
|
+
} from "./llm-config.ts";
|
|
8
|
+
|
|
9
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
const EXCLUDED_MODEL_PATTERNS = [
|
|
12
|
+
/embed/i,
|
|
13
|
+
/whisper/i,
|
|
14
|
+
/tts/i,
|
|
15
|
+
/dall-e/i,
|
|
16
|
+
/moderation/i,
|
|
17
|
+
/transcribe/i,
|
|
18
|
+
/realtime/i,
|
|
19
|
+
/audio/i,
|
|
20
|
+
/speech/i,
|
|
21
|
+
/image/i,
|
|
22
|
+
/completion$/i,
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
interface ProviderModelEntry {
|
|
26
|
+
id?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ProviderModelsResponse {
|
|
30
|
+
data?: ProviderModelEntry[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let modelCache: { key: string; models: string[]; fetchedAt: number } | null =
|
|
34
|
+
null;
|
|
35
|
+
|
|
36
|
+
export async function fetchProviderModels(
|
|
37
|
+
config: Pick<ResolvedLlmSettings, "baseUrl" | "apiKey" | "model">,
|
|
38
|
+
): Promise<string[]> {
|
|
39
|
+
const cacheKey = `${config.baseUrl}:${config.apiKey?.slice(-6) ?? "local"}`;
|
|
40
|
+
if (
|
|
41
|
+
modelCache &&
|
|
42
|
+
modelCache.key === cacheKey &&
|
|
43
|
+
Date.now() - modelCache.fetchedAt < CACHE_TTL_MS
|
|
44
|
+
) {
|
|
45
|
+
return modelCache.models;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!config.apiKey?.trim()) {
|
|
49
|
+
if (!isLlmConfigured({ enabled: true, ...config })) {
|
|
50
|
+
return getFallbackModels(config.model);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const response = await fetch(buildModelsUrl(config.baseUrl), {
|
|
56
|
+
headers: buildLlmRequestHeaders(config.apiKey),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (!response.ok) {
|
|
60
|
+
return getFallbackModels(config.model);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const body = (await response.json()) as ProviderModelsResponse;
|
|
64
|
+
const models = normalizeProviderModels(body, config.model);
|
|
65
|
+
modelCache = { key: cacheKey, models, fetchedAt: Date.now() };
|
|
66
|
+
return models;
|
|
67
|
+
} catch {
|
|
68
|
+
return getFallbackModels(config.model);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function normalizeProviderModels(
|
|
73
|
+
body: ProviderModelsResponse,
|
|
74
|
+
defaultModel: string,
|
|
75
|
+
): string[] {
|
|
76
|
+
const ids = (body.data ?? [])
|
|
77
|
+
.map((entry) => entry.id?.trim())
|
|
78
|
+
.filter((id): id is string => Boolean(id))
|
|
79
|
+
.filter(isChatModel);
|
|
80
|
+
|
|
81
|
+
return mergeModels(defaultModel, ids);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function isChatModel(modelId: string): boolean {
|
|
85
|
+
return !EXCLUDED_MODEL_PATTERNS.some((pattern) => pattern.test(modelId));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function mergeModels(defaultModel: string, models: string[]): string[] {
|
|
89
|
+
return [...new Set([defaultModel, ...models])].sort((a, b) =>
|
|
90
|
+
a.localeCompare(b),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function clearProviderModelCache() {
|
|
95
|
+
modelCache = null;
|
|
96
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildCompletionsUrl,
|
|
3
|
+
buildLlmRequestHeaders,
|
|
4
|
+
DEFAULT_LLM_BASE_URL,
|
|
5
|
+
DEFAULT_LLM_MODEL,
|
|
6
|
+
isLocalLlmBaseUrl,
|
|
7
|
+
type ResolvedLlmSettings,
|
|
8
|
+
} from "./llm-config.ts";
|
|
9
|
+
import type { AssistantLlmConfig, AssistantLlmSettings } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
const DISABLED_SETTINGS: ResolvedLlmSettings = {
|
|
12
|
+
enabled: false,
|
|
13
|
+
baseUrl: DEFAULT_LLM_BASE_URL,
|
|
14
|
+
apiKey: null,
|
|
15
|
+
model: DEFAULT_LLM_MODEL,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
let resolveSettings: (() => Promise<ResolvedLlmSettings>) | null = null;
|
|
19
|
+
|
|
20
|
+
function normalizeSettings(input: AssistantLlmSettings): ResolvedLlmSettings {
|
|
21
|
+
return {
|
|
22
|
+
enabled: input.enabled,
|
|
23
|
+
baseUrl: input.baseUrl?.trim() || DEFAULT_LLM_BASE_URL,
|
|
24
|
+
apiKey: input.apiKey?.trim() || null,
|
|
25
|
+
model: input.model?.trim() || DEFAULT_LLM_MODEL,
|
|
26
|
+
models: input.models?.length ? [...input.models] : undefined,
|
|
27
|
+
systemPrompt: input.systemPrompt?.trim() || undefined,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function configureAssistantLlm(config: AssistantLlmConfig) {
|
|
32
|
+
if (typeof config === "function") {
|
|
33
|
+
resolveSettings = async () => normalizeSettings(await config());
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const settings = normalizeSettings(config);
|
|
38
|
+
resolveSettings = async () => settings;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function resolveAssistantLlmSettings(): Promise<ResolvedLlmSettings> {
|
|
42
|
+
if (!resolveSettings) return DISABLED_SETTINGS;
|
|
43
|
+
return resolveSettings();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resetAssistantLlm() {
|
|
47
|
+
resolveSettings = null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function testLlmConnection(
|
|
51
|
+
settings: Pick<ResolvedLlmSettings, "baseUrl" | "apiKey" | "model">,
|
|
52
|
+
): Promise<{ ok: true; model: string } | { ok: false; error: string }> {
|
|
53
|
+
if (!settings.apiKey?.trim() && !isLocalLlmBaseUrl(settings.baseUrl)) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
error: "API key is required for remote LLM providers.",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const response = await fetch(buildCompletionsUrl(settings.baseUrl), {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: buildLlmRequestHeaders(settings.apiKey),
|
|
64
|
+
body: JSON.stringify({
|
|
65
|
+
model: settings.model,
|
|
66
|
+
messages: [{ role: "user", content: "ping" }],
|
|
67
|
+
max_tokens: 1,
|
|
68
|
+
}),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
const raw = await response.text();
|
|
73
|
+
let message = `Connection failed (${response.status})`;
|
|
74
|
+
try {
|
|
75
|
+
const body = JSON.parse(raw) as {
|
|
76
|
+
error?: { message?: string } | string;
|
|
77
|
+
message?: string;
|
|
78
|
+
};
|
|
79
|
+
if (typeof body.error === "string") message = body.error;
|
|
80
|
+
else if (typeof body.error?.message === "string") {
|
|
81
|
+
message = body.error.message;
|
|
82
|
+
} else if (typeof body.message === "string") {
|
|
83
|
+
message = body.message;
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
if (raw.trim()) message = raw.trim();
|
|
87
|
+
}
|
|
88
|
+
return { ok: false, error: message };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const body = (await response.json()) as { model?: string };
|
|
92
|
+
return { ok: true, model: body.model ?? settings.model };
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
error: error instanceof Error ? error.message : "Connection failed",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_ASSISTANT_SYSTEM_PROMPT,
|
|
3
|
+
DEFAULT_LLM_BASE_URL,
|
|
4
|
+
DEFAULT_LLM_MODEL,
|
|
5
|
+
isLlmConfigured,
|
|
6
|
+
} from "./llm-config.ts";
|
|
7
|
+
import type { AssistantLlmSettings } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_LLM_SETTINGS_STORAGE_KEY = "assistant-llm-settings";
|
|
10
|
+
|
|
11
|
+
/** Full LLM configuration persisted in localStorage */
|
|
12
|
+
export interface StoredLlmUserSettings {
|
|
13
|
+
baseUrl: string;
|
|
14
|
+
apiKey: string | null;
|
|
15
|
+
model: string;
|
|
16
|
+
models: string[];
|
|
17
|
+
systemPrompt: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface LlmSettingsFormValues {
|
|
21
|
+
baseUrl: string;
|
|
22
|
+
apiKey: string;
|
|
23
|
+
model: string;
|
|
24
|
+
modelsText: string;
|
|
25
|
+
systemPrompt: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type LlmSettingsFormState = LlmSettingsFormValues & {
|
|
29
|
+
hasStoredApiKey: boolean;
|
|
30
|
+
defaultSystemPrompt: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function buildDefaultLlmSettings(): AssistantLlmSettings {
|
|
34
|
+
return {
|
|
35
|
+
enabled: false,
|
|
36
|
+
baseUrl: DEFAULT_LLM_BASE_URL,
|
|
37
|
+
apiKey: null,
|
|
38
|
+
model: DEFAULT_LLM_MODEL,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function assistantToStored(
|
|
43
|
+
settings: AssistantLlmSettings,
|
|
44
|
+
): StoredLlmUserSettings {
|
|
45
|
+
return {
|
|
46
|
+
baseUrl: settings.baseUrl?.trim() || DEFAULT_LLM_BASE_URL,
|
|
47
|
+
apiKey: settings.apiKey?.trim() || null,
|
|
48
|
+
model: settings.model?.trim() || DEFAULT_LLM_MODEL,
|
|
49
|
+
models: settings.models?.length ? [...settings.models] : [],
|
|
50
|
+
systemPrompt: settings.systemPrompt ?? "",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function storedToAssistant(
|
|
55
|
+
stored: StoredLlmUserSettings,
|
|
56
|
+
): AssistantLlmSettings {
|
|
57
|
+
const baseUrl = stored.baseUrl;
|
|
58
|
+
const apiKey = stored.apiKey;
|
|
59
|
+
const model = stored.model;
|
|
60
|
+
return {
|
|
61
|
+
enabled: isLlmConfigured({
|
|
62
|
+
enabled: true,
|
|
63
|
+
baseUrl,
|
|
64
|
+
apiKey,
|
|
65
|
+
model,
|
|
66
|
+
}),
|
|
67
|
+
baseUrl,
|
|
68
|
+
apiKey,
|
|
69
|
+
model,
|
|
70
|
+
models: stored.models.length > 0 ? stored.models : undefined,
|
|
71
|
+
systemPrompt: stored.systemPrompt || undefined,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Normalize legacy/partial stored payloads against env defaults */
|
|
76
|
+
export function normalizeStoredSettings(
|
|
77
|
+
raw: unknown,
|
|
78
|
+
base: AssistantLlmSettings,
|
|
79
|
+
): StoredLlmUserSettings | null {
|
|
80
|
+
if (!raw || typeof raw !== "object") return null;
|
|
81
|
+
|
|
82
|
+
const partial = raw as Partial<StoredLlmUserSettings>;
|
|
83
|
+
const baseStored = assistantToStored(base);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
baseUrl:
|
|
87
|
+
typeof partial.baseUrl === "string" && partial.baseUrl.trim()
|
|
88
|
+
? partial.baseUrl.trim()
|
|
89
|
+
: baseStored.baseUrl,
|
|
90
|
+
apiKey:
|
|
91
|
+
typeof partial.apiKey === "string"
|
|
92
|
+
? partial.apiKey.trim() || null
|
|
93
|
+
: partial.apiKey === null
|
|
94
|
+
? null
|
|
95
|
+
: baseStored.apiKey,
|
|
96
|
+
model:
|
|
97
|
+
typeof partial.model === "string" && partial.model.trim()
|
|
98
|
+
? partial.model.trim()
|
|
99
|
+
: baseStored.model,
|
|
100
|
+
models: Array.isArray(partial.models)
|
|
101
|
+
? partial.models
|
|
102
|
+
.filter((entry): entry is string => typeof entry === "string")
|
|
103
|
+
.map((entry) => entry.trim())
|
|
104
|
+
.filter(Boolean)
|
|
105
|
+
: baseStored.models,
|
|
106
|
+
systemPrompt:
|
|
107
|
+
typeof partial.systemPrompt === "string"
|
|
108
|
+
? partial.systemPrompt
|
|
109
|
+
: baseStored.systemPrompt,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function normalizeStoredSystemPrompt(
|
|
114
|
+
storedPrompt: string,
|
|
115
|
+
base: AssistantLlmSettings,
|
|
116
|
+
): string {
|
|
117
|
+
const trimmed = storedPrompt.trim();
|
|
118
|
+
if (!trimmed) return "";
|
|
119
|
+
const basePrompt = base.systemPrompt?.trim() ?? "";
|
|
120
|
+
return trimmed === basePrompt ? "" : trimmed;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function createDefaultStoredSettings(
|
|
124
|
+
base: AssistantLlmSettings,
|
|
125
|
+
): StoredLlmUserSettings {
|
|
126
|
+
return {
|
|
127
|
+
baseUrl: base.baseUrl?.trim() || DEFAULT_LLM_BASE_URL,
|
|
128
|
+
apiKey: base.apiKey?.trim() || null,
|
|
129
|
+
model: base.model?.trim() || DEFAULT_LLM_MODEL,
|
|
130
|
+
models: base.models?.length ? [...base.models] : [],
|
|
131
|
+
systemPrompt: "",
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function storedSettingsHaveOverrides(
|
|
136
|
+
stored: StoredLlmUserSettings,
|
|
137
|
+
base: AssistantLlmSettings,
|
|
138
|
+
): boolean {
|
|
139
|
+
const normalized = {
|
|
140
|
+
...stored,
|
|
141
|
+
systemPrompt: normalizeStoredSystemPrompt(stored.systemPrompt, base),
|
|
142
|
+
};
|
|
143
|
+
return (
|
|
144
|
+
JSON.stringify(normalized) !==
|
|
145
|
+
JSON.stringify(createDefaultStoredSettings(base))
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function mergeLlmSettings(
|
|
150
|
+
base: AssistantLlmSettings,
|
|
151
|
+
stored: StoredLlmUserSettings | null,
|
|
152
|
+
): AssistantLlmSettings {
|
|
153
|
+
if (!stored) return base;
|
|
154
|
+
const merged = storedToAssistant(stored);
|
|
155
|
+
return {
|
|
156
|
+
...merged,
|
|
157
|
+
systemPrompt: merged.systemPrompt?.trim() || base.systemPrompt,
|
|
158
|
+
models: merged.models?.length ? merged.models : base.models,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function isLlmSettingsFormDirty(
|
|
163
|
+
current: LlmSettingsFormValues,
|
|
164
|
+
saved: LlmSettingsFormValues,
|
|
165
|
+
): boolean {
|
|
166
|
+
return (
|
|
167
|
+
current.baseUrl !== saved.baseUrl ||
|
|
168
|
+
current.apiKey !== saved.apiKey ||
|
|
169
|
+
current.model !== saved.model ||
|
|
170
|
+
current.modelsText !== saved.modelsText ||
|
|
171
|
+
current.systemPrompt !== saved.systemPrompt
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function resolveSelectedModel(
|
|
176
|
+
storedModel: string,
|
|
177
|
+
defaultModel: string,
|
|
178
|
+
availableModels: string[],
|
|
179
|
+
): string {
|
|
180
|
+
const preferred = storedModel.trim();
|
|
181
|
+
if (preferred) return preferred;
|
|
182
|
+
if (availableModels.includes(defaultModel)) return defaultModel;
|
|
183
|
+
return availableModels[0] ?? defaultModel;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function peekStoredModel(storageKey: string): string | null {
|
|
187
|
+
try {
|
|
188
|
+
const raw = localStorage.getItem(storageKey);
|
|
189
|
+
if (!raw) return null;
|
|
190
|
+
const parsed = JSON.parse(raw) as Partial<StoredLlmUserSettings>;
|
|
191
|
+
const model = typeof parsed.model === "string" ? parsed.model.trim() : "";
|
|
192
|
+
return model || null;
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function persistStoredModelSelection(
|
|
199
|
+
storageKey: string,
|
|
200
|
+
model: string,
|
|
201
|
+
): void {
|
|
202
|
+
const trimmed = model.trim();
|
|
203
|
+
if (!trimmed) return;
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const raw = localStorage.getItem(storageKey);
|
|
207
|
+
if (!raw) return;
|
|
208
|
+
const parsed = JSON.parse(raw) as Partial<StoredLlmUserSettings>;
|
|
209
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
210
|
+
localStorage.setItem(
|
|
211
|
+
storageKey,
|
|
212
|
+
JSON.stringify({ ...parsed, model: trimmed }),
|
|
213
|
+
);
|
|
214
|
+
} catch {
|
|
215
|
+
// ignore corrupt storage payloads
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const LEGACY_MODEL_STORAGE_KEYS = [
|
|
220
|
+
"assistant-llm-model",
|
|
221
|
+
"4d-llm-model",
|
|
222
|
+
] as const;
|
|
223
|
+
|
|
224
|
+
export function migrateLegacyModelStorage(
|
|
225
|
+
llmSettingsKey: string,
|
|
226
|
+
legacyModelKey: string | undefined,
|
|
227
|
+
base: AssistantLlmSettings,
|
|
228
|
+
): void {
|
|
229
|
+
const legacyKeys = [
|
|
230
|
+
...(legacyModelKey ? [legacyModelKey] : []),
|
|
231
|
+
...LEGACY_MODEL_STORAGE_KEYS,
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
for (const key of legacyKeys) {
|
|
235
|
+
const legacyModel = localStorage.getItem(key)?.trim();
|
|
236
|
+
if (!legacyModel) continue;
|
|
237
|
+
|
|
238
|
+
const storage = createLlmSettingsStorage(llmSettingsKey);
|
|
239
|
+
const stored = storage.load(base) ?? createDefaultStoredSettings(base);
|
|
240
|
+
storage.save({ ...stored, model: legacyModel });
|
|
241
|
+
localStorage.removeItem(key);
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function parseModelsText(text: string): string[] {
|
|
247
|
+
return [
|
|
248
|
+
...new Set(
|
|
249
|
+
text
|
|
250
|
+
.split(/[\n,]/)
|
|
251
|
+
.map((entry) => entry.trim())
|
|
252
|
+
.filter(Boolean),
|
|
253
|
+
),
|
|
254
|
+
];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function formatModelsText(models: string[] | undefined): string {
|
|
258
|
+
return models?.join(", ") ?? "";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function createLlmSettingsStorage(storageKey: string) {
|
|
262
|
+
return {
|
|
263
|
+
load(base: AssistantLlmSettings): StoredLlmUserSettings | null {
|
|
264
|
+
try {
|
|
265
|
+
const raw = localStorage.getItem(storageKey);
|
|
266
|
+
if (!raw) return null;
|
|
267
|
+
return normalizeStoredSettings(JSON.parse(raw) as unknown, base);
|
|
268
|
+
} catch {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
save(settings: StoredLlmUserSettings) {
|
|
274
|
+
localStorage.setItem(storageKey, JSON.stringify(settings));
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
clear() {
|
|
278
|
+
localStorage.removeItem(storageKey);
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
hasStored(): boolean {
|
|
282
|
+
return localStorage.getItem(storageKey) !== null;
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function toStoredSettings(
|
|
288
|
+
values: LlmSettingsFormValues,
|
|
289
|
+
existingApiKey: string | null,
|
|
290
|
+
): StoredLlmUserSettings {
|
|
291
|
+
const apiKeyInput = values.apiKey.trim();
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
baseUrl: values.baseUrl.trim() || DEFAULT_LLM_BASE_URL,
|
|
295
|
+
apiKey: apiKeyInput.length > 0 ? apiKeyInput : existingApiKey,
|
|
296
|
+
model: values.model.trim() || DEFAULT_LLM_MODEL,
|
|
297
|
+
models: parseModelsText(values.modelsText),
|
|
298
|
+
systemPrompt: values.systemPrompt,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function createLlmSettingsFormState(
|
|
303
|
+
settings: AssistantLlmSettings,
|
|
304
|
+
hasStoredApiKey: boolean,
|
|
305
|
+
defaultSystemPrompt = DEFAULT_ASSISTANT_SYSTEM_PROMPT,
|
|
306
|
+
): LlmSettingsFormState {
|
|
307
|
+
return {
|
|
308
|
+
baseUrl: settings.baseUrl,
|
|
309
|
+
apiKey: "",
|
|
310
|
+
model: settings.model,
|
|
311
|
+
modelsText: formatModelsText(settings.models),
|
|
312
|
+
systemPrompt: settings.systemPrompt ?? "",
|
|
313
|
+
hasStoredApiKey,
|
|
314
|
+
defaultSystemPrompt,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function createLlmSettingsFormStateFromStored(
|
|
319
|
+
stored: StoredLlmUserSettings,
|
|
320
|
+
defaultSystemPrompt = DEFAULT_ASSISTANT_SYSTEM_PROMPT,
|
|
321
|
+
): LlmSettingsFormState {
|
|
322
|
+
return {
|
|
323
|
+
baseUrl: stored.baseUrl,
|
|
324
|
+
apiKey: "",
|
|
325
|
+
model: stored.model,
|
|
326
|
+
modelsText: formatModelsText(stored.models),
|
|
327
|
+
systemPrompt: stored.systemPrompt,
|
|
328
|
+
hasStoredApiKey: Boolean(stored.apiKey),
|
|
329
|
+
defaultSystemPrompt,
|
|
330
|
+
};
|
|
331
|
+
}
|