@marimo-team/islands 0.19.5-dev40 → 0.19.5-dev44
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/dist/main.js +11 -8
- package/package.json +1 -1
- package/src/components/ai/__tests__/ai-utils.test.ts +276 -0
- package/src/components/ai/ai-utils.ts +101 -0
- package/src/components/app-config/ai-config.tsx +56 -16
- package/src/components/app-config/user-config-form.tsx +29 -0
- package/src/components/chat/chat-panel.tsx +3 -3
- package/src/components/editor/actions/useCellActionButton.tsx +2 -2
- package/src/components/editor/actions/useNotebookActions.tsx +18 -10
- package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +1 -1
- package/src/core/ai/model-registry.ts +21 -3
- package/src/core/export/hooks.ts +7 -11
- package/src/utils/__tests__/download.test.tsx +398 -2
- package/src/utils/download.ts +107 -6
- package/src/components/export/export-output-button.tsx +0 -14
package/dist/main.js
CHANGED
|
@@ -60790,11 +60790,11 @@ ${r}
|
|
|
60790
60790
|
},
|
|
60791
60791
|
replace: (e, r) => e.endsWith(`.${r}`) ? e : `${Filenames.withoutExtension(e)}.${r}`
|
|
60792
60792
|
};
|
|
60793
|
-
async function downloadHTMLAsImage(e
|
|
60794
|
-
let c = document.getElementById("App"),
|
|
60795
|
-
document.body.classList.add("printing");
|
|
60793
|
+
async function downloadHTMLAsImage(e) {
|
|
60794
|
+
let { element: r, filename: c, prepare: d } = e, f = document.getElementById("App"), h = (f == null ? void 0 : f.scrollTop) ?? 0, _;
|
|
60795
|
+
d ? _ = d(r) : document.body.classList.add("printing");
|
|
60796
60796
|
try {
|
|
60797
|
-
downloadByURL(await toPng(
|
|
60797
|
+
downloadByURL(await toPng(r), Filenames.toPNG(c));
|
|
60798
60798
|
} catch {
|
|
60799
60799
|
toast({
|
|
60800
60800
|
title: "Error",
|
|
@@ -60802,8 +60802,8 @@ ${r}
|
|
|
60802
60802
|
variant: "danger"
|
|
60803
60803
|
});
|
|
60804
60804
|
} finally {
|
|
60805
|
-
document.body.classList.remove("printing"), requestAnimationFrame(() => {
|
|
60806
|
-
|
|
60805
|
+
_ == null ? void 0 : _(), document.body.classList.contains("printing") && document.body.classList.remove("printing"), requestAnimationFrame(() => {
|
|
60806
|
+
f == null ? void 0 : f.scrollTo(0, h);
|
|
60807
60807
|
});
|
|
60808
60808
|
}
|
|
60809
60809
|
}
|
|
@@ -101103,7 +101103,7 @@ Defaulting to \`null\`.`;
|
|
|
101103
101103
|
return Logger.warn("Failed to get version from mount config"), null;
|
|
101104
101104
|
}
|
|
101105
101105
|
}
|
|
101106
|
-
const marimoVersionAtom = atom(getVersionFromMountConfig() || "0.19.5-
|
|
101106
|
+
const marimoVersionAtom = atom(getVersionFromMountConfig() || "0.19.5-dev44"), showCodeInRunModeAtom = atom(true);
|
|
101107
101107
|
atom(null);
|
|
101108
101108
|
var VIRTUAL_FILE_REGEX = /\/@file\/([^\s"&'/]+)\.([\dA-Za-z]+)/g, VirtualFileTracker = class e {
|
|
101109
101109
|
constructor() {
|
|
@@ -101524,7 +101524,10 @@ Defaulting to \`null\`.`;
|
|
|
101524
101524
|
}
|
|
101525
101525
|
async function _temp3() {
|
|
101526
101526
|
let e = document.getElementById("App");
|
|
101527
|
-
e && await downloadHTMLAsImage(
|
|
101527
|
+
e && await downloadHTMLAsImage({
|
|
101528
|
+
element: e,
|
|
101529
|
+
filename: document.title
|
|
101530
|
+
});
|
|
101528
101531
|
}
|
|
101529
101532
|
async function _temp4() {
|
|
101530
101533
|
document.getElementById("App") && await downloadAsHTML({
|
package/package.json
CHANGED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import type { AiModel } from "@marimo-team/llm-info";
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { UserConfig } from "@/core/config/config-schema";
|
|
6
|
+
|
|
7
|
+
// Mock the models.json import
|
|
8
|
+
vi.mock("@marimo-team/llm-info/models.json", () => {
|
|
9
|
+
const models: AiModel[] = [
|
|
10
|
+
{
|
|
11
|
+
name: "GPT-4",
|
|
12
|
+
model: "gpt-4",
|
|
13
|
+
description: "OpenAI GPT-4 model",
|
|
14
|
+
providers: ["openai"],
|
|
15
|
+
roles: ["chat", "edit"],
|
|
16
|
+
thinking: false,
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: "Claude 3",
|
|
20
|
+
model: "claude-3-sonnet",
|
|
21
|
+
description: "Anthropic Claude 3 Sonnet",
|
|
22
|
+
providers: ["anthropic"],
|
|
23
|
+
roles: ["chat", "edit"],
|
|
24
|
+
thinking: false,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "Gemini Pro",
|
|
28
|
+
model: "gemini-pro",
|
|
29
|
+
description: "Google Gemini Pro model",
|
|
30
|
+
providers: ["google"],
|
|
31
|
+
roles: ["chat", "edit"],
|
|
32
|
+
thinking: false,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: "Ollama Model",
|
|
36
|
+
model: "llama2",
|
|
37
|
+
description: "Ollama Llama 2 model",
|
|
38
|
+
providers: ["ollama"],
|
|
39
|
+
roles: ["chat", "edit"],
|
|
40
|
+
thinking: false,
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
models: models,
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Must import after mock
|
|
50
|
+
import {
|
|
51
|
+
autoPopulateModels,
|
|
52
|
+
getConfiguredProvider,
|
|
53
|
+
getRecommendedModel,
|
|
54
|
+
} from "../ai-utils";
|
|
55
|
+
|
|
56
|
+
describe("ai-utils", () => {
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
vi.clearAllMocks();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("getConfiguredProvider", () => {
|
|
62
|
+
it("should return undefined when no AI config", () => {
|
|
63
|
+
const config: UserConfig = {} as UserConfig;
|
|
64
|
+
expect(getConfiguredProvider(config)).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("should return undefined when AI config has no credentials", () => {
|
|
68
|
+
const config: UserConfig = {
|
|
69
|
+
ai: {},
|
|
70
|
+
} as UserConfig;
|
|
71
|
+
expect(getConfiguredProvider(config)).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("should return openai when OpenAI API key is set", () => {
|
|
75
|
+
const config: UserConfig = {
|
|
76
|
+
ai: {
|
|
77
|
+
open_ai: { api_key: "sk-test" },
|
|
78
|
+
},
|
|
79
|
+
} as UserConfig;
|
|
80
|
+
expect(getConfiguredProvider(config)).toBe("openai");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should return anthropic when Anthropic API key is set", () => {
|
|
84
|
+
const config: UserConfig = {
|
|
85
|
+
ai: {
|
|
86
|
+
anthropic: { api_key: "sk-ant-test" },
|
|
87
|
+
},
|
|
88
|
+
} as UserConfig;
|
|
89
|
+
expect(getConfiguredProvider(config)).toBe("anthropic");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should return google when Google API key is set", () => {
|
|
93
|
+
const config: UserConfig = {
|
|
94
|
+
ai: {
|
|
95
|
+
google: { api_key: "google-key" },
|
|
96
|
+
},
|
|
97
|
+
} as UserConfig;
|
|
98
|
+
expect(getConfiguredProvider(config)).toBe("google");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("should return ollama when Ollama base URL is set", () => {
|
|
102
|
+
const config: UserConfig = {
|
|
103
|
+
ai: {
|
|
104
|
+
ollama: { base_url: "http://localhost:11434" },
|
|
105
|
+
},
|
|
106
|
+
} as UserConfig;
|
|
107
|
+
expect(getConfiguredProvider(config)).toBe("ollama");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("should return azure only when both API key and base URL are set", () => {
|
|
111
|
+
const config: UserConfig = {
|
|
112
|
+
ai: {
|
|
113
|
+
azure: { api_key: "azure-key", base_url: "https://azure.com" },
|
|
114
|
+
},
|
|
115
|
+
} as UserConfig;
|
|
116
|
+
expect(getConfiguredProvider(config)).toBe("azure");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("should return undefined for azure with only API key", () => {
|
|
120
|
+
const config: UserConfig = {
|
|
121
|
+
ai: {
|
|
122
|
+
azure: { api_key: "azure-key" },
|
|
123
|
+
},
|
|
124
|
+
} as UserConfig;
|
|
125
|
+
expect(getConfiguredProvider(config)).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("should return custom provider when configured", () => {
|
|
129
|
+
const config = {
|
|
130
|
+
ai: {
|
|
131
|
+
custom_providers: {
|
|
132
|
+
my_provider: { base_url: "https://my-api.com" },
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
} as unknown as UserConfig;
|
|
136
|
+
expect(getConfiguredProvider(config)).toBe("my_provider");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("getRecommendedModel", () => {
|
|
141
|
+
it("should return undefined when no provider is configured", () => {
|
|
142
|
+
const config: UserConfig = {} as UserConfig;
|
|
143
|
+
expect(getRecommendedModel(config)).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("should return openai model when OpenAI is configured", () => {
|
|
147
|
+
const config: UserConfig = {
|
|
148
|
+
ai: {
|
|
149
|
+
open_ai: { api_key: "sk-test" },
|
|
150
|
+
},
|
|
151
|
+
} as UserConfig;
|
|
152
|
+
expect(getRecommendedModel(config)).toBe("openai/gpt-4");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("should return anthropic model when Anthropic is configured", () => {
|
|
156
|
+
const config: UserConfig = {
|
|
157
|
+
ai: {
|
|
158
|
+
anthropic: { api_key: "sk-ant-test" },
|
|
159
|
+
},
|
|
160
|
+
} as UserConfig;
|
|
161
|
+
expect(getRecommendedModel(config)).toBe("anthropic/claude-3-sonnet");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("should return google model when Google is configured", () => {
|
|
165
|
+
const config: UserConfig = {
|
|
166
|
+
ai: {
|
|
167
|
+
google: { api_key: "google-key" },
|
|
168
|
+
},
|
|
169
|
+
} as UserConfig;
|
|
170
|
+
expect(getRecommendedModel(config)).toBe("google/gemini-pro");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("should return ollama model when Ollama is configured", () => {
|
|
174
|
+
const config: UserConfig = {
|
|
175
|
+
ai: {
|
|
176
|
+
ollama: { base_url: "http://localhost:11434" },
|
|
177
|
+
},
|
|
178
|
+
} as UserConfig;
|
|
179
|
+
expect(getRecommendedModel(config)).toBe("ollama/llama2");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("autoPopulateModels", () => {
|
|
184
|
+
it("should return empty result when both models are already set", () => {
|
|
185
|
+
const values = {
|
|
186
|
+
ai: {
|
|
187
|
+
open_ai: { api_key: "sk-test" },
|
|
188
|
+
models: {
|
|
189
|
+
chat_model: "openai/gpt-4",
|
|
190
|
+
edit_model: "openai/gpt-4",
|
|
191
|
+
custom_models: [],
|
|
192
|
+
displayed_models: [],
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
} as unknown as UserConfig;
|
|
196
|
+
|
|
197
|
+
const result = autoPopulateModels(values);
|
|
198
|
+
|
|
199
|
+
expect(result.chatModel).toBeUndefined();
|
|
200
|
+
expect(result.editModel).toBeUndefined();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("should return empty result when no credentials are configured", () => {
|
|
204
|
+
const values: UserConfig = {
|
|
205
|
+
ai: {},
|
|
206
|
+
} as UserConfig;
|
|
207
|
+
|
|
208
|
+
const result = autoPopulateModels(values);
|
|
209
|
+
|
|
210
|
+
expect(result.chatModel).toBeUndefined();
|
|
211
|
+
expect(result.editModel).toBeUndefined();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("should auto-populate both models when neither is set", () => {
|
|
215
|
+
const values: UserConfig = {
|
|
216
|
+
ai: {
|
|
217
|
+
open_ai: { api_key: "sk-test" },
|
|
218
|
+
},
|
|
219
|
+
} as UserConfig;
|
|
220
|
+
|
|
221
|
+
const result = autoPopulateModels(values);
|
|
222
|
+
|
|
223
|
+
expect(result.chatModel).toBe("openai/gpt-4");
|
|
224
|
+
expect(result.editModel).toBe("openai/gpt-4");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("should only auto-populate chat_model when edit_model is set", () => {
|
|
228
|
+
const values = {
|
|
229
|
+
ai: {
|
|
230
|
+
open_ai: { api_key: "sk-test" },
|
|
231
|
+
models: {
|
|
232
|
+
edit_model: "openai/gpt-3.5-turbo",
|
|
233
|
+
custom_models: [],
|
|
234
|
+
displayed_models: [],
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
} as unknown as UserConfig;
|
|
238
|
+
|
|
239
|
+
const result = autoPopulateModels(values);
|
|
240
|
+
|
|
241
|
+
expect(result.chatModel).toBe("openai/gpt-4");
|
|
242
|
+
expect(result.editModel).toBeUndefined();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("should only auto-populate edit_model when chat_model is set", () => {
|
|
246
|
+
const values = {
|
|
247
|
+
ai: {
|
|
248
|
+
open_ai: { api_key: "sk-test" },
|
|
249
|
+
models: {
|
|
250
|
+
chat_model: "openai/gpt-3.5-turbo",
|
|
251
|
+
custom_models: [],
|
|
252
|
+
displayed_models: [],
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
} as unknown as UserConfig;
|
|
256
|
+
|
|
257
|
+
const result = autoPopulateModels(values);
|
|
258
|
+
|
|
259
|
+
expect(result.chatModel).toBeUndefined();
|
|
260
|
+
expect(result.editModel).toBe("openai/gpt-4");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("should return recommended model for anthropic provider", () => {
|
|
264
|
+
const values: UserConfig = {
|
|
265
|
+
ai: {
|
|
266
|
+
anthropic: { api_key: "sk-ant-test" },
|
|
267
|
+
},
|
|
268
|
+
} as UserConfig;
|
|
269
|
+
|
|
270
|
+
const result = autoPopulateModels(values);
|
|
271
|
+
|
|
272
|
+
expect(result.chatModel).toBe("anthropic/claude-3-sonnet");
|
|
273
|
+
expect(result.editModel).toBe("anthropic/claude-3-sonnet");
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
KNOWN_PROVIDERS,
|
|
5
|
+
type KnownProviderId,
|
|
6
|
+
type ProviderId,
|
|
7
|
+
} from "@/core/ai/ids/ids";
|
|
8
|
+
import { getKnownModelMaps } from "@/core/ai/model-registry";
|
|
9
|
+
import type { AiConfig, UserConfig } from "@/core/config/config-schema";
|
|
10
|
+
|
|
11
|
+
type CredentialChecker = (ai: AiConfig | undefined) => boolean;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Credential checkers for each known provider.
|
|
15
|
+
*/
|
|
16
|
+
const CREDENTIAL_CHECKERS: Record<KnownProviderId, CredentialChecker> = {
|
|
17
|
+
openai: (ai) => Boolean(ai?.open_ai?.api_key),
|
|
18
|
+
anthropic: (ai) => Boolean(ai?.anthropic?.api_key),
|
|
19
|
+
google: (ai) => Boolean(ai?.google?.api_key),
|
|
20
|
+
github: (ai) => Boolean(ai?.github?.api_key),
|
|
21
|
+
openrouter: (ai) => Boolean(ai?.openrouter?.api_key),
|
|
22
|
+
azure: (ai) => Boolean(ai?.azure?.api_key && ai?.azure?.base_url),
|
|
23
|
+
wandb: (ai) => Boolean(ai?.wandb?.api_key),
|
|
24
|
+
bedrock: (ai) => Boolean(ai?.bedrock?.region_name),
|
|
25
|
+
ollama: (ai) => Boolean(ai?.ollama?.base_url),
|
|
26
|
+
// These providers don't have user-configurable credentials in the UI
|
|
27
|
+
deepseek: () => false,
|
|
28
|
+
marimo: () => false,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Returns the first configured provider based on credentials.
|
|
33
|
+
*/
|
|
34
|
+
export function getConfiguredProvider(
|
|
35
|
+
config: UserConfig,
|
|
36
|
+
): ProviderId | undefined {
|
|
37
|
+
const ai = config.ai;
|
|
38
|
+
|
|
39
|
+
for (const provider of KNOWN_PROVIDERS) {
|
|
40
|
+
if (CREDENTIAL_CHECKERS[provider](ai)) {
|
|
41
|
+
return provider;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check custom providers
|
|
46
|
+
const customProviders = ai?.custom_providers;
|
|
47
|
+
if (customProviders) {
|
|
48
|
+
const firstCustomProvider = Object.entries(customProviders).find(
|
|
49
|
+
([_, providerConfig]) => providerConfig?.base_url,
|
|
50
|
+
);
|
|
51
|
+
if (firstCustomProvider) {
|
|
52
|
+
return firstCustomProvider[0];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getRecommendedModel(config: UserConfig): string | undefined {
|
|
58
|
+
const provider = getConfiguredProvider(config);
|
|
59
|
+
if (!provider) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
return getKnownModelMaps().defaultModelByProvider.get(provider);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface AutoPopulateResult {
|
|
66
|
+
chatModel: string | undefined;
|
|
67
|
+
editModel: string | undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Determines which models to auto-populate based on configured credentials.
|
|
72
|
+
* Returns the recommended model for chat/edit if credentials are configured but models aren't set.
|
|
73
|
+
*
|
|
74
|
+
* @param values - The full form values
|
|
75
|
+
*/
|
|
76
|
+
export function autoPopulateModels(values: UserConfig): AutoPopulateResult {
|
|
77
|
+
const result: AutoPopulateResult = {
|
|
78
|
+
chatModel: undefined,
|
|
79
|
+
editModel: undefined,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const needsChatModel = !values.ai?.models?.chat_model;
|
|
83
|
+
const needsEditModel = !values.ai?.models?.edit_model;
|
|
84
|
+
|
|
85
|
+
if (!needsChatModel && !needsEditModel) {
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const recommendedModel = getRecommendedModel(values);
|
|
90
|
+
if (!recommendedModel) {
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (needsChatModel) {
|
|
95
|
+
result.chatModel = recommendedModel;
|
|
96
|
+
}
|
|
97
|
+
if (needsEditModel) {
|
|
98
|
+
result.editModel = recommendedModel;
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
@@ -115,6 +115,7 @@ interface ApiKeyProps {
|
|
|
115
115
|
placeholder: string;
|
|
116
116
|
testId: string;
|
|
117
117
|
description?: React.ReactNode;
|
|
118
|
+
onChange?: (value: string) => void;
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
export const ApiKey: React.FC<ApiKeyProps> = ({
|
|
@@ -124,6 +125,7 @@ export const ApiKey: React.FC<ApiKeyProps> = ({
|
|
|
124
125
|
placeholder,
|
|
125
126
|
testId,
|
|
126
127
|
description,
|
|
128
|
+
onChange,
|
|
127
129
|
}) => {
|
|
128
130
|
return (
|
|
129
131
|
<FormField
|
|
@@ -141,11 +143,12 @@ export const ApiKey: React.FC<ApiKeyProps> = ({
|
|
|
141
143
|
placeholder={placeholder}
|
|
142
144
|
type="password"
|
|
143
145
|
{...field}
|
|
144
|
-
value={
|
|
146
|
+
value={asStringOrEmpty(field.value)}
|
|
145
147
|
onChange={(e) => {
|
|
146
148
|
const value = e.target.value;
|
|
147
149
|
if (!value.includes("*")) {
|
|
148
150
|
field.onChange(value);
|
|
151
|
+
onChange?.(value);
|
|
149
152
|
}
|
|
150
153
|
}}
|
|
151
154
|
/>
|
|
@@ -168,12 +171,12 @@ interface BaseUrlProps {
|
|
|
168
171
|
testId: string;
|
|
169
172
|
description?: React.ReactNode;
|
|
170
173
|
disabled?: boolean;
|
|
171
|
-
|
|
174
|
+
onChange?: (value: string) => void;
|
|
172
175
|
}
|
|
173
176
|
|
|
174
|
-
function
|
|
177
|
+
function asStringOrEmpty<T>(value: T): string {
|
|
175
178
|
if (value == null) {
|
|
176
|
-
return
|
|
179
|
+
return "";
|
|
177
180
|
}
|
|
178
181
|
|
|
179
182
|
if (typeof value === "string") {
|
|
@@ -191,13 +194,12 @@ export const BaseUrl: React.FC<BaseUrlProps> = ({
|
|
|
191
194
|
testId,
|
|
192
195
|
description,
|
|
193
196
|
disabled = false,
|
|
194
|
-
|
|
197
|
+
onChange,
|
|
195
198
|
}) => {
|
|
196
199
|
return (
|
|
197
200
|
<FormField
|
|
198
201
|
control={form.control}
|
|
199
202
|
name={name}
|
|
200
|
-
disabled={disabled}
|
|
201
203
|
render={({ field }) => (
|
|
202
204
|
<div className="flex flex-col space-y-1">
|
|
203
205
|
<FormItem className={formItemClasses}>
|
|
@@ -208,9 +210,13 @@ export const BaseUrl: React.FC<BaseUrlProps> = ({
|
|
|
208
210
|
rootClassName="flex-1"
|
|
209
211
|
className="m-0 inline-flex h-7"
|
|
210
212
|
placeholder={placeholder}
|
|
211
|
-
defaultValue={defaultValue}
|
|
212
213
|
{...field}
|
|
213
|
-
value={
|
|
214
|
+
value={asStringOrEmpty(field.value)}
|
|
215
|
+
disabled={disabled}
|
|
216
|
+
onChange={(e) => {
|
|
217
|
+
field.onChange(e.target.value);
|
|
218
|
+
onChange?.(e.target.value);
|
|
219
|
+
}}
|
|
214
220
|
/>
|
|
215
221
|
</FormControl>
|
|
216
222
|
<FormMessage />
|
|
@@ -252,9 +258,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|
|
252
258
|
<FormField
|
|
253
259
|
control={form.control}
|
|
254
260
|
name={name}
|
|
255
|
-
disabled={disabled}
|
|
256
261
|
render={({ field }) => {
|
|
257
|
-
const value =
|
|
262
|
+
const value = asStringOrEmpty(field.value);
|
|
258
263
|
|
|
259
264
|
const selectModel = (modelId: QualifiedModelId) => {
|
|
260
265
|
field.onChange(modelId);
|
|
@@ -286,7 +291,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|
|
286
291
|
className="w-full border-border shadow-none focus-visible:shadow-xs"
|
|
287
292
|
placeholder={placeholder}
|
|
288
293
|
{...field}
|
|
289
|
-
value={
|
|
294
|
+
value={asStringOrEmpty(field.value)}
|
|
295
|
+
disabled={disabled}
|
|
290
296
|
onKeyDown={Events.stopPropagation()}
|
|
291
297
|
/>
|
|
292
298
|
{value && (
|
|
@@ -339,7 +345,6 @@ export const ProviderSelect: React.FC<ProviderSelectProps> = ({
|
|
|
339
345
|
<FormField
|
|
340
346
|
control={form.control}
|
|
341
347
|
name={name}
|
|
342
|
-
disabled={disabled}
|
|
343
348
|
render={({ field }) => (
|
|
344
349
|
<div className="flex flex-col space-y-1">
|
|
345
350
|
<FormItem className={formItemClasses}>
|
|
@@ -354,14 +359,14 @@ export const ProviderSelect: React.FC<ProviderSelectProps> = ({
|
|
|
354
359
|
field.onChange(e.target.value);
|
|
355
360
|
}
|
|
356
361
|
}}
|
|
357
|
-
value={
|
|
362
|
+
value={asStringOrEmpty(
|
|
358
363
|
field.value === true
|
|
359
364
|
? "github"
|
|
360
365
|
: field.value === false
|
|
361
366
|
? "none"
|
|
362
367
|
: field.value,
|
|
363
368
|
)}
|
|
364
|
-
disabled={
|
|
369
|
+
disabled={disabled}
|
|
365
370
|
className="inline-flex mr-2"
|
|
366
371
|
>
|
|
367
372
|
{options.map((option) => (
|
|
@@ -715,6 +720,22 @@ export const CustomProvidersConfig: React.FC<AiConfigProps> = ({
|
|
|
715
720
|
</div>
|
|
716
721
|
);
|
|
717
722
|
|
|
723
|
+
// Update a provider field by updating the entire custom_providers object.
|
|
724
|
+
// As this config will be replaced, it needs to be sent in its entirety.
|
|
725
|
+
const updateProviderField = (opts: {
|
|
726
|
+
providerName: string;
|
|
727
|
+
fieldName: keyof CustomProviderConfig;
|
|
728
|
+
value: string;
|
|
729
|
+
}) => {
|
|
730
|
+
field.onChange({
|
|
731
|
+
...customProviders,
|
|
732
|
+
[opts.providerName]: {
|
|
733
|
+
...customProviders[opts.providerName],
|
|
734
|
+
[opts.fieldName]: opts.value || undefined,
|
|
735
|
+
},
|
|
736
|
+
});
|
|
737
|
+
};
|
|
738
|
+
|
|
718
739
|
const renderAccordionItem = ({
|
|
719
740
|
providerName,
|
|
720
741
|
providerConfig,
|
|
@@ -744,6 +765,13 @@ export const CustomProvidersConfig: React.FC<AiConfigProps> = ({
|
|
|
744
765
|
}
|
|
745
766
|
placeholder="sk-..."
|
|
746
767
|
testId={`custom-provider-${providerName}-api-key`}
|
|
768
|
+
onChange={(value) =>
|
|
769
|
+
updateProviderField({
|
|
770
|
+
providerName,
|
|
771
|
+
fieldName: "api_key",
|
|
772
|
+
value,
|
|
773
|
+
})
|
|
774
|
+
}
|
|
747
775
|
/>
|
|
748
776
|
<BaseUrl
|
|
749
777
|
form={form}
|
|
@@ -753,6 +781,13 @@ export const CustomProvidersConfig: React.FC<AiConfigProps> = ({
|
|
|
753
781
|
}
|
|
754
782
|
placeholder="https://api.example.com/v1"
|
|
755
783
|
testId={`custom-provider-${providerName}-base-url`}
|
|
784
|
+
onChange={(value) =>
|
|
785
|
+
updateProviderField({
|
|
786
|
+
providerName,
|
|
787
|
+
fieldName: "base_url",
|
|
788
|
+
value,
|
|
789
|
+
})
|
|
790
|
+
}
|
|
756
791
|
/>
|
|
757
792
|
<Button
|
|
758
793
|
variant="destructive"
|
|
@@ -918,7 +953,6 @@ export const AiProvidersConfig: React.FC<AiConfigProps> = ({
|
|
|
918
953
|
config={config}
|
|
919
954
|
name="ai.ollama.base_url"
|
|
920
955
|
placeholder="http://localhost:11434/v1"
|
|
921
|
-
defaultValue="http://localhost:11434/v1"
|
|
922
956
|
testId="ollama-base-url-input"
|
|
923
957
|
/>
|
|
924
958
|
</AccordionFormItem>
|
|
@@ -1038,7 +1072,6 @@ export const AiProvidersConfig: React.FC<AiConfigProps> = ({
|
|
|
1038
1072
|
config={config}
|
|
1039
1073
|
name="ai.azure.base_url"
|
|
1040
1074
|
placeholder="https://<your-resource-name>.openai.azure.com/openai/deployments/<deployment-name>?api-version=<api-version>"
|
|
1041
|
-
defaultValue="https://<your-resource-name>.openai.azure.com/openai/deployments/<deployment-name>?api-version=<api-version>"
|
|
1042
1075
|
testId="ai-azure-base-url-input"
|
|
1043
1076
|
/>
|
|
1044
1077
|
</AccordionFormItem>
|
|
@@ -1427,6 +1460,13 @@ export const AiModelDisplayConfig: React.FC<AiConfigProps> = ({
|
|
|
1427
1460
|
|
|
1428
1461
|
const deleteModel = useEvent((modelId: QualifiedModelId) => {
|
|
1429
1462
|
const newModels = customModels.filter((id) => id !== modelId);
|
|
1463
|
+
// Remove from displayed models if it's in there
|
|
1464
|
+
const newDisplayedModels = currentDisplayedModels.filter(
|
|
1465
|
+
(id) => id !== modelId,
|
|
1466
|
+
);
|
|
1467
|
+
form.setValue("ai.models.displayed_models", newDisplayedModels, {
|
|
1468
|
+
shouldDirty: true,
|
|
1469
|
+
});
|
|
1430
1470
|
form.setValue("ai.models.custom_models", newModels, {
|
|
1431
1471
|
shouldDirty: true,
|
|
1432
1472
|
});
|
|
@@ -50,6 +50,7 @@ import { Banner } from "@/plugins/impl/common/error-banner";
|
|
|
50
50
|
import { THEMES } from "@/theme/useTheme";
|
|
51
51
|
import { arrayToggle } from "@/utils/arrays";
|
|
52
52
|
import { cn } from "@/utils/cn";
|
|
53
|
+
import { autoPopulateModels } from "../ai/ai-utils";
|
|
53
54
|
import { keyboardShortcutsAtom } from "../editor/controls/keyboard-shortcuts";
|
|
54
55
|
import { Badge } from "../ui/badge";
|
|
55
56
|
import { ExternalLink } from "../ui/links";
|
|
@@ -180,6 +181,28 @@ export const UserConfigForm: React.FC = () => {
|
|
|
180
181
|
defaultValues: config,
|
|
181
182
|
});
|
|
182
183
|
|
|
184
|
+
const setAiModels = (values: UserConfig, dirtyAiConfig: UserConfig["ai"]) => {
|
|
185
|
+
const { chatModel, editModel } = autoPopulateModels(values);
|
|
186
|
+
if (chatModel || editModel) {
|
|
187
|
+
dirtyAiConfig = {
|
|
188
|
+
...dirtyAiConfig,
|
|
189
|
+
models: {
|
|
190
|
+
...dirtyAiConfig?.models,
|
|
191
|
+
...(chatModel && { chat_model: chatModel }),
|
|
192
|
+
...(editModel && { edit_model: editModel }),
|
|
193
|
+
},
|
|
194
|
+
} as typeof dirtyAiConfig;
|
|
195
|
+
if (chatModel) {
|
|
196
|
+
form.setValue("ai.models.chat_model", chatModel);
|
|
197
|
+
}
|
|
198
|
+
if (editModel) {
|
|
199
|
+
form.setValue("ai.models.edit_model", editModel);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return dirtyAiConfig;
|
|
204
|
+
};
|
|
205
|
+
|
|
183
206
|
const onSubmitNotDebounced = async (values: UserConfig) => {
|
|
184
207
|
// Only send values that were actually changed to avoid
|
|
185
208
|
// overwriting backend values the form doesn't manage
|
|
@@ -187,6 +210,12 @@ export const UserConfigForm: React.FC = () => {
|
|
|
187
210
|
if (Object.keys(dirtyValues).length === 0) {
|
|
188
211
|
return; // Nothing changed
|
|
189
212
|
}
|
|
213
|
+
|
|
214
|
+
// Auto-populate AI models when credentials are set, makes it easier to get started
|
|
215
|
+
if (dirtyValues.ai) {
|
|
216
|
+
dirtyValues.ai = setAiModels(values, dirtyValues.ai);
|
|
217
|
+
}
|
|
218
|
+
|
|
190
219
|
await saveUserConfig({ config: dirtyValues }).then(() => {
|
|
191
220
|
// Update local state with form values
|
|
192
221
|
setConfig((prev) => ({ ...prev, ...values }));
|