agent-zero/plugins/_onboarding/webui/onboarding-store.js
Alessandro f6bc52201d Redesign first-run onboarding
Introduce a guided Cloud versus Local first-run modal with provider selection, account connection, model picking, and a ready state.\n\nAdd the reusable discovery auto-modal trigger, chat-created startup checks, onboarding-owned provider presentation metadata and assets, OAuth affordances, local provider guidance, and model-search hardening.\n\nKeep runtime provider data centralized while preserving onboarding-specific copy, logos, and docs links in the onboarding plugin.

Update onboarding.html

Update onboarding.html
2026-05-09 07:46:36 +02:00

688 lines
22 KiB
JavaScript

import { createStore } from "/js/AlpineStore.js";
import { callJsonApi, fetchApi } from "/js/api.js";
import { store as modelConfigStore } from "/plugins/_model_config/webui/model-config-store.js";
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
import {
LOCAL_PROVIDER_IDS,
MORE_CLOUD_PROVIDER_IDS,
ONBOARDING_PROVIDER_OVERRIDES,
TOP_CLOUD_PROVIDER_IDS,
} from "/plugins/_onboarding/webui/onboarding-providers.js";
const MODEL_CONFIG_API = "/plugins/_model_config";
const OAUTH_STATUS_API = "/plugins/_oauth/status";
const OAUTH_START_API = "/plugins/_oauth/start_device_login";
const OAUTH_POLL_API = "/plugins/_oauth/poll_device_login";
const OAUTH_MODELS_API = "/plugins/_oauth/models";
const MAX_OAUTH_POLL_MS = 120000;
const TOP_CLOUD_IDS = TOP_CLOUD_PROVIDER_IDS;
const MORE_CLOUD_IDS = MORE_CLOUD_PROVIDER_IDS;
const FALLBACKS = {
codex_oauth: {
id: "codex_oauth",
name: "ChatGPT/Codex Account",
logo: "https://openai.com/favicon.ico",
onboarding_category: "account",
api_key_mode: "oauth",
short_description: "Use your connected ChatGPT or Codex account.",
setup_url: "https://chatgpt.com/",
docs_url: "https://platform.openai.com/docs/codex",
},
other: {
id: "other",
name: "Other OpenAI-compatible",
logo: "/public/darkSymbol.svg",
api_key_mode: "optional",
short_description: "Use a compatible endpoint you control.",
},
};
function clone(value) {
return JSON.parse(JSON.stringify(value || {}));
}
function detailsById(details = []) {
const result = {};
for (const item of details || []) {
const id = String(item?.id || item?.value || "").trim();
if (id) result[id] = item;
}
return result;
}
function ensureSlot(config, key) {
if (!config[key] || typeof config[key] !== "object") config[key] = {};
config[key] = {
provider: "",
name: "",
api_base: "",
api_key: "",
ctx_length: key === "utility_model" ? 128000 : 200000,
ctx_history: key === "chat_model" ? 0.7 : undefined,
ctx_input: key === "utility_model" ? 0.7 : undefined,
vision: key === "chat_model" ? true : undefined,
rl_requests: 0,
rl_input: 0,
rl_output: 0,
kwargs: {},
...config[key],
};
}
function normalizeUrl(value) {
return String(value || "").trim();
}
function safeProviderName(provider) {
return provider?.name || provider?.label || provider?.id || "Provider";
}
export const store = createStore("onboarding", {
step: "path",
pathChoice: "",
loading: true,
saving: false,
config: null,
providerDetails: {},
selectedProviderId: "",
selectedProviderOrigin: "cloud",
moreProviderQuery: "",
moreCloudOpen: false,
sameAsMain: true,
userTouchedModel: {
chat_model: false,
utility_model: false,
},
modelDropdown: {
chat_model: { models: [], open: false, loading: false, error: "", source: "" },
utility_model: { models: [], open: false, loading: false, error: "", source: "" },
},
oauthStatus: null,
oauthLoading: false,
oauthConnecting: false,
oauthDevice: null,
oauthPollTimer: null,
oauthPollStartedAt: 0,
oauthModels: [],
steps: [
{ step: "path", label: "Choose path" },
{ step: "setup", label: "Connect" },
{ step: "utility", label: "Utility" },
{ step: "ready", label: "Ready" },
],
async init() {
this.resetState();
},
resetState() {
this.step = "path";
this.pathChoice = "";
this.loading = true;
this.saving = false;
this.config = null;
this.providerDetails = {};
this.selectedProviderId = "";
this.selectedProviderOrigin = "cloud";
this.moreProviderQuery = "";
this.moreCloudOpen = false;
this.sameAsMain = true;
this.userTouchedModel = { chat_model: false, utility_model: false };
this.modelDropdown = {
chat_model: { models: [], open: false, loading: false, error: "", source: "" },
utility_model: { models: [], open: false, loading: false, error: "", source: "" },
};
this.oauthStatus = null;
this.oauthLoading = false;
this.oauthConnecting = false;
this.oauthDevice = null;
this.oauthModels = [];
this.stopOauthPolling();
},
async onOpen() {
await this.init();
await modelConfigStore.ensureLoaded();
modelConfigStore.resetApiKeyDrafts();
await modelConfigStore.refreshApiKeyStatus();
await this.loadConfig();
await this.loadOauthStatus({ silent: true });
this.loading = false;
},
cleanup() {
this.stopOauthPolling();
this.resetState();
},
async loadConfig() {
const response = await fetchApi(`${MODEL_CONFIG_API}/model_config_get`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const data = await response.json().catch(() => ({}));
this.config = clone(data.config || {});
ensureSlot(this.config, "chat_model");
ensureSlot(this.config, "utility_model");
ensureSlot(this.config, "embedding_model");
modelConfigStore.initConfigFields(this.config);
this.providerDetails = detailsById(data.chat_provider_details || modelConfigStore.chatProviderDetails || []);
if (this.config.chat_model.provider) {
this.selectedProviderId = this.config.chat_model.provider;
}
},
providerMeta(id) {
const providerId = String(id || "").trim();
const fromDetails = this.providerDetails[providerId] || {};
const fallback = FALLBACKS[providerId] || {};
const override = ONBOARDING_PROVIDER_OVERRIDES[providerId] || {};
return {
...fallback,
...fromDetails,
...override,
id: providerId,
name: override.name || fromDetails.name || fallback.name || providerId,
short_description: override.short_description || fromDetails.short_description || fallback.short_description || "Connect this provider to Agent Zero.",
logo: override.logo || fromDetails.logo || fallback.logo || "/public/darkSymbol.svg",
api_key_mode: override.api_key_mode || fromDetails.api_key_mode || fallback.api_key_mode || "required",
};
},
topCloudProviders() {
return TOP_CLOUD_IDS.map((id) => this.providerMeta(id));
},
moreCloudProviders() {
return MORE_CLOUD_IDS.map((id) => this.providerMeta(id));
},
filteredMoreCloudProviders() {
const query = this.moreProviderQuery.trim().toLowerCase();
const providers = this.moreCloudProviders();
if (!query) return providers;
return providers.filter((provider) => {
const haystack = `${provider.name} ${provider.short_description} ${provider.id}`.toLowerCase();
return haystack.includes(query);
});
},
localProviderCards() {
return LOCAL_PROVIDER_IDS.map((id) => {
const meta = this.providerMeta(id);
if (id === "other") {
return {
...meta,
name: "Other local endpoint",
short_description: "Point Agent Zero at a local compatible server.",
default_api_base: "",
api_key_mode: "optional",
};
}
return meta;
});
},
accountMeta() {
return this.providerMeta("codex_oauth");
},
accountActionLabel() {
return this.oauthConnected() ? "Use connected account" : "Connect via device code";
},
selectedProvider() {
return this.providerMeta(this.selectedProviderId || this.config?.chat_model?.provider || "");
},
selectedProviderName() {
return safeProviderName(this.selectedProvider());
},
titleText() {
if (this.step === "setup") return "Choose your main model";
if (this.step === "utility") return "Choose your utility model";
if (this.step === "ready") return "Agent Zero is ready";
if (this.step === "path") return "Choose how to use AI models in Agent Zero";
if (this.step === "cloud") return "Choose your cloud AI provider";
if (this.step === "local") {
return "Choose your local LLM provider";
}
return "Choose how to use AI models in Agent Zero";
},
stepNumber(stepName) {
const index = this.steps.findIndex((item) => item.step === stepName);
return index >= 0 ? index + 1 : 1;
},
currentStepNumber() {
if (this.step === "cloud" || this.step === "local") return 1;
return this.stepNumber(this.step);
},
isStep(name) {
return this.step === name;
},
choosePath(path) {
this.pathChoice = path;
this.step = path === "local" ? "local" : "cloud";
},
goBack() {
if (this.step === "cloud" || this.step === "local") {
this.step = "path";
return;
}
if (this.step === "setup") {
this.step = this.pathChoice === "local" ? "local" : "cloud";
return;
}
if (this.step === "utility") {
this.step = "setup";
return;
}
if (this.step === "ready") {
this.step = "utility";
}
},
showBackButton() {
return !["path", "ready"].includes(this.step);
},
showPrimaryButton() {
return ["setup", "utility", "ready"].includes(this.step);
},
primaryButtonLabel() {
if (this.step === "setup") return "Choose utility model";
if (this.step === "utility") return this.saving ? "Saving" : "Finish setup";
if (this.step === "ready") return "Start Chatting";
return "Continue";
},
primaryDisabled() {
if (this.loading || this.saving) return true;
if (this.step === "setup") {
if (this.isOAuthProvider() && !this.oauthConnected()) return true;
if (this.providerNeedsKey(this.selectedProviderId) && !this.hasProviderKey(this.selectedProviderId)) return true;
return !this.config?.chat_model?.provider || !this.config?.chat_model?.name;
}
if (this.step === "utility") return !this.config?.utility_model?.provider || !this.config?.utility_model?.name;
return false;
},
async primaryAction() {
if (this.primaryDisabled()) return;
if (this.step === "setup") {
this.prepareUtilityDefaults();
this.step = "utility";
await this.loadModels("utility_model");
return;
}
if (this.step === "utility") {
await this.completeSetup();
return;
}
if (this.step === "ready") {
await this.startChatting();
}
},
async selectProvider(providerId, origin = "cloud") {
this.selectedProviderId = providerId;
this.selectedProviderOrigin = origin;
this.pathChoice = origin;
const meta = this.providerMeta(providerId);
this.applyProviderToSlot("chat_model", providerId, meta, { forceApiBase: origin === "local" });
if (providerId === "codex_oauth") {
await this.loadOauthStatus({ silent: true });
}
this.step = "setup";
if (meta.model_list_autoload !== false) {
await this.loadModels("chat_model", { openDropdown: false });
}
},
async selectCodexAccount() {
this.pathChoice = "cloud";
await this.selectProvider("codex_oauth", "cloud");
},
applyProviderToSlot(slotKey, providerId, meta, options = {}) {
ensureSlot(this.config, slotKey);
const slot = this.config[slotKey];
const previousProvider = slot.provider;
slot.provider = providerId;
const defaultApiBase = meta.default_api_base || meta.kwargs?.api_base || "";
if (defaultApiBase && (options.forceApiBase || !slot.api_base)) {
slot.api_base = defaultApiBase;
}
const defaultModel = slotKey === "utility_model"
? meta.default_utility_model || meta.default_chat_model || ""
: meta.default_chat_model || "";
if (defaultModel && (!slot.name || !this.userTouchedModel[slotKey])) {
slot.name = defaultModel;
} else if (previousProvider && previousProvider !== providerId && !this.userTouchedModel[slotKey]) {
slot.name = "";
}
if (!slot.kwargs || typeof slot.kwargs !== "object") slot.kwargs = {};
},
localGuidance() {
return "";
},
showApiBaseField() {
return this.selectedProviderOrigin === "local" || this.selectedProviderId === "other";
},
setupPurpose() {
if (this.isOAuthProvider()) return "Connect once, then Agent Zero can use the local Codex/ChatGPT account bridge without an API key.";
if (this.selectedProviderOrigin === "local") return "Choose a local model and confirm where Agent Zero can reach it.";
return "Choose a model and add the key Agent Zero will use for this provider.";
},
selectedProviderDocsUrl() {
const provider = this.selectedProvider();
return provider.docs_url || provider.api_key_url || provider.setup_url || "";
},
openSelectedProviderDocs() {
const url = this.selectedProviderDocsUrl();
if (url) window.open(url, "_blank", "noopener,noreferrer");
},
providerNeedsKey(providerId) {
return this.providerMeta(providerId).api_key_mode === "required";
},
providerKeyOptional(providerId) {
return this.providerMeta(providerId).api_key_mode === "optional";
},
providerHasNoKey(providerId) {
const mode = this.providerMeta(providerId).api_key_mode;
return mode === "none" || mode === "oauth";
},
hasProviderKey(providerId) {
if (!providerId) return false;
if (this.providerHasNoKey(providerId)) return true;
const draft = modelConfigStore.apiKeyValues?.[providerId] || "";
return Boolean(draft.trim() || modelConfigStore.apiKeyStatus?.[providerId]);
},
isOAuthProvider() {
return this.selectedProviderId === "codex_oauth" || this.config?.chat_model?.provider === "codex_oauth";
},
oauthConnected() {
return Boolean(this.oauthStatus?.codex?.connected);
},
oauthEmail() {
return this.oauthStatus?.codex?.email || this.oauthStatus?.codex?.account_email || this.oauthStatus?.codex?.account_id || "";
},
oauthStatusLabel() {
if (this.oauthLoading) return "Checking";
return this.oauthConnected() ? "Connected" : "Not connected";
},
async loadOauthStatus({ silent = false } = {}) {
if (this.oauthLoading) return;
this.oauthLoading = true;
try {
this.oauthStatus = await callJsonApi(OAUTH_STATUS_API, {});
} catch (error) {
if (!silent) globalThis.justToast?.("Could not check account connection", "error");
} finally {
this.oauthLoading = false;
}
},
async connectCodex() {
if (this.oauthConnecting) return;
this.oauthConnecting = true;
const popup = window.open("about:blank", "_blank");
if (popup) popup.opener = null;
try {
const response = await callJsonApi(OAUTH_START_API, {});
if (!response?.ok || !response.verification_url || !response.attempt_id) {
throw new Error(response?.error || "Could not start account connection.");
}
this.oauthDevice = response;
if (popup && !popup.closed) {
popup.location.assign(response.verification_url);
} else {
window.open(response.verification_url, "_blank", "noopener,noreferrer");
}
this.startOauthPolling();
} catch (error) {
if (popup && !popup.closed) popup.close();
this.oauthConnecting = false;
globalThis.justToast?.(error?.message || "Could not connect account", "error");
}
},
startOauthPolling() {
this.stopOauthPolling();
this.oauthPollStartedAt = Date.now();
const tick = async () => {
if (!this.oauthDevice?.attempt_id) return;
try {
const response = await callJsonApi(OAUTH_POLL_API, { attempt_id: this.oauthDevice.attempt_id });
if (!response?.ok) {
if (response?.expired) {
this.oauthDevice = null;
}
throw new Error(response?.error || "Could not finish account connection.");
}
if (response.completed) {
this.oauthConnecting = false;
this.oauthDevice = null;
this.stopOauthPolling();
await this.loadOauthStatus();
this.applyProviderToSlot("chat_model", "codex_oauth", this.providerMeta("codex_oauth"));
await this.loadOauthModels();
return;
}
} catch (error) {
this.oauthConnecting = false;
this.stopOauthPolling();
globalThis.justToast?.(error?.message || "Could not connect account", "error");
return;
}
if (Date.now() - this.oauthPollStartedAt > MAX_OAUTH_POLL_MS) {
this.oauthConnecting = false;
this.oauthDevice = null;
this.stopOauthPolling();
}
};
void tick();
const parsedInterval = Number(this.oauthDevice.interval);
const intervalSeconds = Number.isFinite(parsedInterval) ? parsedInterval : 5;
const delay = Math.max(1500, intervalSeconds * 1000);
this.oauthPollTimer = window.setInterval(tick, delay);
},
stopOauthPolling() {
if (this.oauthPollTimer) window.clearInterval(this.oauthPollTimer);
this.oauthPollTimer = null;
},
async loadOauthModels() {
try {
const response = await callJsonApi(OAUTH_MODELS_API, {});
this.oauthModels = Array.isArray(response?.models) ? response.models : [];
if (this.oauthModels.length && !this.userTouchedModel.chat_model) {
this.config.chat_model.name = this.oauthModels[0];
}
this.modelDropdown.chat_model.models = this.oauthModels;
this.modelDropdown.chat_model.source = "oauth";
} catch {
this.oauthModels = [];
}
},
cancelOauthConnect() {
this.oauthConnecting = false;
this.oauthDevice = null;
this.stopOauthPolling();
},
async loadModels(slotKey, { openDropdown = true } = {}) {
if (!this.config?.[slotKey]?.provider) return;
const dropdown = this.modelDropdown[slotKey];
dropdown.loading = true;
dropdown.error = "";
dropdown.source = "";
try {
const slot = this.config[slotKey];
const response = await fetchApi(`${MODEL_CONFIG_API}/model_search`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: slot.provider,
model_type: slotKey === "embedding_model" ? "embedding" : "chat",
query: "",
api_base: slot.api_base || "",
}),
});
const data = await response.json().catch(() => ({}));
dropdown.models = Array.isArray(data.models) ? data.models : [];
dropdown.source = data.source || "";
dropdown.error = data.error || "";
dropdown.open = openDropdown && dropdown.models.length > 0;
this.selectDefaultModelIfSafe(slotKey);
} catch (error) {
dropdown.models = [];
dropdown.error = error?.message || "Could not load models.";
dropdown.open = false;
} finally {
dropdown.loading = false;
}
},
selectDefaultModelIfSafe(slotKey) {
const slot = this.config?.[slotKey];
if (!slot || this.userTouchedModel[slotKey]) return;
const models = this.modelDropdown[slotKey]?.models || [];
if (!models.length) return;
if (slot.name && models.includes(slot.name)) return;
const meta = this.providerMeta(slot.provider);
const preferred = slotKey === "utility_model" ? meta.default_utility_model : meta.default_chat_model;
if (preferred && models.includes(preferred)) {
slot.name = preferred;
}
},
filteredModels(slotKey) {
const slot = this.config?.[slotKey] || {};
const query = String(slot.name || "").trim().toLowerCase();
const models = this.modelDropdown[slotKey]?.models || [];
if (!query) return models.slice(0, 80);
return models.filter((name) => String(name).toLowerCase().includes(query)).slice(0, 80);
},
openModelDropdown(slotKey) {
this.modelDropdown[slotKey].open = true;
if (!this.modelDropdown[slotKey].models.length && !this.modelDropdown[slotKey].loading) {
void this.loadModels(slotKey);
}
},
closeModelDropdown(slotKey) {
this.modelDropdown[slotKey].open = false;
},
selectModel(slotKey, modelName) {
this.config[slotKey].name = modelName;
this.userTouchedModel[slotKey] = true;
this.modelDropdown[slotKey].open = false;
if (slotKey === "chat_model" && this.sameAsMain) {
this.syncUtilityWithMain();
}
},
markModelTouched(slotKey) {
this.userTouchedModel[slotKey] = true;
if (slotKey === "chat_model" && this.sameAsMain) {
this.syncUtilityWithMain();
}
},
prepareUtilityDefaults() {
ensureSlot(this.config, "utility_model");
if (this.sameAsMain) {
this.syncUtilityWithMain();
return;
}
const mainProvider = this.config.chat_model.provider;
const meta = this.providerMeta(mainProvider);
this.applyProviderToSlot("utility_model", mainProvider, meta);
},
syncUtilityWithMain() {
ensureSlot(this.config, "utility_model");
if (!this.sameAsMain || !this.config?.chat_model) return;
this.config.utility_model.provider = this.config.chat_model.provider;
this.config.utility_model.name = this.config.chat_model.name;
this.config.utility_model.api_base = this.config.chat_model.api_base || "";
this.config.utility_model.kwargs = clone(this.config.chat_model.kwargs || {});
},
async utilityProviderChanged() {
const providerId = this.config.utility_model.provider;
this.sameAsMain = providerId === this.config.chat_model.provider;
this.userTouchedModel.utility_model = false;
this.applyProviderToSlot("utility_model", providerId, this.providerMeta(providerId));
await this.loadModels("utility_model");
},
async completeSetup() {
this.saving = true;
try {
if (this.sameAsMain) this.syncUtilityWithMain();
await modelConfigStore.persistApiKeysForConfig(this.config);
const response = await fetchApi(`${MODEL_CONFIG_API}/model_config_set`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
project_name: "",
agent_profile: "",
config: this.config,
}),
});
const data = await response.json().catch(() => ({}));
if (!data?.ok) throw new Error(data?.error || "Could not save model setup.");
await modelConfigStore.refreshApiKeyStatus();
this.step = "ready";
document.dispatchEvent(new CustomEvent("onboarding-configured"));
} catch (error) {
globalThis.justToast?.(error?.message || "Could not save setup", "error");
} finally {
this.saving = false;
}
},
async startChatting() {
window.closeModal?.();
await chatsStore.newChat();
},
async openAdvancedSettings() {
window.closeModal?.();
const { store: pluginSettingsStore } = await import("/components/plugins/plugin-settings-store.js");
await pluginSettingsStore.openConfig("_model_config");
},
});