Files
continue/core/config/ConfigHandler.ts
2025-04-06 15:31:05 -07:00

514 lines
16 KiB
TypeScript

import { ConfigResult } from "@continuedev/config-yaml";
import {
ControlPlaneClient,
ControlPlaneSessionInfo,
} from "../control-plane/client.js";
import { getControlPlaneEnv, useHub } from "../control-plane/env.js";
import {
BrowserSerializedContinueConfig,
ContinueConfig,
IContextProvider,
IDE,
IdeSettings,
ILLM,
} from "../index.js";
import Ollama from "../llm/llms/Ollama.js";
import { GlobalContext } from "../util/GlobalContext.js";
import { getAllAssistantFiles } from "./loadLocalAssistants.js";
import {
LOCAL_ONBOARDING_CHAT_MODEL,
LOCAL_ONBOARDING_PROVIDER_TITLE,
} from "./onboarding.js";
import ControlPlaneProfileLoader from "./profile/ControlPlaneProfileLoader.js";
import LocalProfileLoader from "./profile/LocalProfileLoader.js";
import PlatformProfileLoader from "./profile/PlatformProfileLoader.js";
import {
OrganizationDescription,
OrgWithProfiles,
ProfileDescription,
ProfileLifecycleManager,
SerializedOrgWithProfiles,
} from "./ProfileLifecycleManager.js";
export type { ProfileDescription };
type ConfigUpdateFunction = (payload: ConfigResult<ContinueConfig>) => void;
export class ConfigHandler {
controlPlaneClient: ControlPlaneClient;
private readonly globalContext = new GlobalContext();
private globalLocalProfileManager: ProfileLifecycleManager;
private organizations: OrgWithProfiles[] = [];
currentProfile: ProfileLifecycleManager | null;
currentOrg: OrgWithProfiles;
constructor(
private readonly ide: IDE,
private ideSettingsPromise: Promise<IdeSettings>,
private readonly writeLog: (text: string) => Promise<void>,
sessionInfoPromise: Promise<ControlPlaneSessionInfo | undefined>,
) {
this.ide = ide;
this.ideSettingsPromise = ideSettingsPromise;
this.writeLog = writeLog;
this.controlPlaneClient = new ControlPlaneClient(
sessionInfoPromise,
ideSettingsPromise,
);
// This profile manager will always be available
this.globalLocalProfileManager = new ProfileLifecycleManager(
new LocalProfileLoader(
ide,
ideSettingsPromise,
this.controlPlaneClient,
writeLog,
),
this.ide,
);
// Just to be safe, always force a default personal org with local profile manager
this.currentProfile = this.globalLocalProfileManager;
const personalOrg: OrgWithProfiles = {
currentProfile: this.globalLocalProfileManager,
profiles: [this.globalLocalProfileManager],
...this.PERSONAL_ORG_DESC,
};
this.currentOrg = personalOrg;
this.organizations = [personalOrg];
void this.cascadeInit();
}
private workspaceDirs: string[] | null = null;
async getWorkspaceId() {
if (!this.workspaceDirs) {
this.workspaceDirs = await this.ide.getWorkspaceDirs();
}
return this.workspaceDirs.join("&");
}
async getProfileKey(orgId: string) {
const workspaceId = await this.getWorkspaceId();
return `${workspaceId}:::${orgId}`;
}
private async cascadeInit() {
this.workspaceDirs = null; // forces workspace dirs reload
const orgs = await this.getOrgs();
// Figure out selected org
const workspaceId = await this.getWorkspaceId();
const selectedOrgs =
this.globalContext.get("lastSelectedOrgIdForWorkspace") ?? {};
const currentSelection = selectedOrgs[workspaceId];
const firstNonPersonal = orgs.find(
(org) => org.id !== this.PERSONAL_ORG_DESC.id,
);
const fallback = firstNonPersonal ?? orgs[0];
// note, ignoring case of zero orgs since should never happen
let selectedOrg: OrgWithProfiles;
if (!currentSelection) {
selectedOrg = fallback;
} else {
const match = orgs.find((org) => org.id === currentSelection);
if (match) {
selectedOrg = match;
} else {
selectedOrg = fallback;
}
}
this.globalContext.update("lastSelectedOrgIdForWorkspace", {
...selectedOrgs,
[workspaceId]: selectedOrg.id,
});
this.organizations = orgs;
this.currentOrg = selectedOrg;
this.currentProfile = selectedOrg.currentProfile;
await this.reloadConfig();
}
private async getOrgs(): Promise<OrgWithProfiles[]> {
const userId = await this.controlPlaneClient.userId;
if (userId) {
const orgDescs = await this.controlPlaneClient.listOrganizations();
if (await useHub(this.ideSettingsPromise)) {
const personalHubOrg = await this.getPersonalHubOrg();
const hubOrgs = await Promise.all(
orgDescs.map((org) => this.getNonPersonalHubOrg(org)),
);
return [personalHubOrg, ...hubOrgs];
} else {
// Should only ever be one teams org. Will be removed soon anyways
return await Promise.all(orgDescs.map((org) => this.getTeamsOrg(org)));
}
} else {
return [await this.getLocalOrg()];
}
}
getSerializedOrgs(): SerializedOrgWithProfiles[] {
return this.organizations.map((org) => ({
iconUrl: org.iconUrl,
id: org.id,
name: org.name,
slug: org.slug,
profiles: org.profiles.map((profile) => profile.profileDescription),
selectedProfileId: org.currentProfile?.profileDescription.id || null,
}));
}
private async getHubProfiles(orgScopeId: string | null) {
const assistants = await this.controlPlaneClient.listAssistants(orgScopeId);
return await Promise.all(
assistants.map(async (assistant) => {
const profileLoader = await PlatformProfileLoader.create(
{
...assistant.configResult,
config: assistant.configResult.config,
},
assistant.ownerSlug,
assistant.packageSlug,
assistant.iconUrl,
assistant.configResult.config?.version ?? "latest",
this.controlPlaneClient,
this.ide,
this.ideSettingsPromise,
this.writeLog,
assistant.rawYaml,
orgScopeId,
);
return new ProfileLifecycleManager(profileLoader, this.ide);
}),
);
}
private async getNonPersonalHubOrg(
org: OrganizationDescription,
): Promise<OrgWithProfiles> {
const profiles = await this.getHubProfiles(org.id);
return this.rectifyProfilesForOrg(org, profiles);
}
private PERSONAL_ORG_DESC: OrganizationDescription = {
iconUrl: "",
id: "personal",
name: "Personal",
slug: undefined,
};
private async getPersonalHubOrg() {
const allLocalProfiles = await this.getAllLocalProfiles();
const hubProfiles = await this.getHubProfiles(null);
const profiles = [...hubProfiles, ...allLocalProfiles];
return this.rectifyProfilesForOrg(this.PERSONAL_ORG_DESC, profiles);
}
private async getLocalOrg() {
const allLocalProfiles = await this.getAllLocalProfiles();
return this.rectifyProfilesForOrg(this.PERSONAL_ORG_DESC, allLocalProfiles);
}
async getTeamsOrg(org: OrganizationDescription): Promise<OrgWithProfiles> {
const workspaces = await this.controlPlaneClient.listWorkspaces();
const profiles = await this.getAllLocalProfiles();
workspaces.forEach((workspace) => {
const profileLoader = new ControlPlaneProfileLoader(
workspace.id,
workspace.name,
this.controlPlaneClient,
this.ide,
this.ideSettingsPromise,
this.writeLog,
this.reloadConfig.bind(this),
);
profiles.push(new ProfileLifecycleManager(profileLoader, this.ide));
});
return this.rectifyProfilesForOrg(org, profiles);
}
private async rectifyProfilesForOrg(
org: OrganizationDescription,
profiles: ProfileLifecycleManager[],
): Promise<OrgWithProfiles> {
const profileKey = await this.getProfileKey(org.id);
const selectedProfiles =
this.globalContext.get("lastSelectedProfileForWorkspace") ?? {};
const currentSelection = selectedProfiles[profileKey];
const firstNonLocal = profiles.find(
(profile) => profile.profileDescription.profileType !== "local",
);
const fallback =
firstNonLocal ?? (profiles.length > 0 ? profiles[0] : null);
let currentProfile: ProfileLifecycleManager | null;
if (!currentSelection) {
currentProfile = fallback;
} else {
const match = profiles.find(
(profile) => profile.profileDescription.id === currentSelection,
);
if (match) {
currentProfile = match;
} else {
currentProfile = fallback;
}
}
if (currentProfile) {
this.globalContext.update("lastSelectedProfileForWorkspace", {
...selectedProfiles,
[profileKey]: selectedProfiles.id ?? null,
});
}
return {
...org,
profiles,
currentProfile,
};
}
async getAllLocalProfiles() {
/**
* Users can define as many local assistants as they want in a `.continue/assistants` folder
*/
const assistantFiles = await getAllAssistantFiles(this.ide);
const profiles = assistantFiles.map((assistant) => {
return new LocalProfileLoader(
this.ide,
this.ideSettingsPromise,
this.controlPlaneClient,
this.writeLog,
assistant,
);
});
const localAssistantProfiles = profiles.map(
(profile) => new ProfileLifecycleManager(profile, this.ide),
);
return [this.globalLocalProfileManager, ...localAssistantProfiles];
}
//////////////////
// External actions that can cause a cascading config refresh
// Should not be used internally
//////////////////
async refreshAll() {
await this.cascadeInit();
}
// Ide settings change: refresh session and cascade refresh from the top
async updateIdeSettings(ideSettings: IdeSettings) {
this.ideSettingsPromise = Promise.resolve(ideSettings);
await this.cascadeInit();
}
// Session change: refresh session and cascade refresh from the top
async updateControlPlaneSessionInfo(
sessionInfo: ControlPlaneSessionInfo | undefined,
) {
this.controlPlaneClient = new ControlPlaneClient(
Promise.resolve(sessionInfo),
this.ideSettingsPromise,
);
await this.cascadeInit();
}
// Org id: check id validity, save selection, switch and reload
async setSelectedOrgId(orgId: string, profileId?: string) {
if (orgId === this.currentOrg.id) {
return;
}
const org = this.organizations.find((org) => org.id === orgId);
if (!org) {
throw new Error(`Org ${orgId} not found`);
}
const workspaceId = await this.getWorkspaceId();
const selectedOrgs =
this.globalContext.get("lastSelectedOrgIdForWorkspace") ?? {};
this.globalContext.update("lastSelectedOrgIdForWorkspace", {
...selectedOrgs,
[workspaceId]: org.id,
});
this.currentOrg = org;
if (profileId) {
this.setSelectedProfileId(profileId);
} else {
this.currentProfile = org.currentProfile;
await this.reloadConfig();
}
}
// Profile id: check id validity, save selection, switch and reload
async setSelectedProfileId(profileId: string) {
if (
this.currentProfile &&
profileId === this.currentProfile.profileDescription.id
) {
return;
}
const profile = this.currentOrg.profiles.find(
(profile) => profile.profileDescription.id === profileId,
);
if (!profile) {
throw new Error(`Profile ${profileId} not found in current org`);
}
const profileKey = await this.getProfileKey(this.currentOrg.id);
const selectedProfiles =
this.globalContext.get("lastSelectedProfileForWorkspace") ?? {};
this.globalContext.update("lastSelectedProfileForWorkspace", {
...selectedProfiles,
[profileKey]: profileId,
});
this.currentProfile = profile;
await this.reloadConfig();
}
// Bottom level of cascade: refresh the current profile
// IMPORTANT - must always refresh when switching profiles
// Because of e.g. MCP singleton and docs service using things from config
// Could improve this
async reloadConfig() {
if (!this.currentProfile) {
return {
config: undefined,
errors: [],
configLoadInterrupted: true,
};
}
for (const org of this.organizations) {
for (const profile of org.profiles) {
if (
profile.profileDescription.id !==
this.currentProfile.profileDescription.id
) {
profile.clearConfig();
}
}
}
const { config, errors, configLoadInterrupted } =
await this.currentProfile.reloadConfig(this.additionalContextProviders);
this.notifyConfigListeners({ config, errors, configLoadInterrupted });
return { config, errors, configLoadInterrupted };
}
// Listeners setup - can listen to current profile updates
private notifyConfigListeners(result: ConfigResult<ContinueConfig>) {
for (const listener of this.updateListeners) {
listener(result);
}
}
private updateListeners: ConfigUpdateFunction[] = [];
onConfigUpdate(listener: ConfigUpdateFunction) {
this.updateListeners.push(listener);
}
// Methods for loading (without reloading) config
// Serialized for passing to GUI
// Load for just awaiting current config load promise for the profile
async getSerializedConfig(): Promise<
ConfigResult<BrowserSerializedContinueConfig>
> {
if (!this.currentProfile) {
return {
config: undefined,
errors: [],
configLoadInterrupted: true,
};
}
return await this.currentProfile.getSerializedConfig(
this.additionalContextProviders,
);
}
async loadConfig(): Promise<ConfigResult<ContinueConfig>> {
if (!this.currentProfile) {
return {
config: undefined,
errors: [],
configLoadInterrupted: true,
};
}
return await this.currentProfile.loadConfig(
this.additionalContextProviders,
);
}
async openConfigProfile(profileId?: string) {
let openProfileId = profileId || this.currentProfile?.profileDescription.id;
if (!openProfileId) {
return;
}
const profile = this.currentOrg.profiles.find(
(p) => p.profileDescription.id === openProfileId,
);
if (profile?.profileDescription.profileType === "local") {
await this.ide.openFile(profile.profileDescription.uri);
} else {
const env = await getControlPlaneEnv(this.ide.getIdeSettings());
await this.ide.openUrl(`${env.APP_URL}${openProfileId}`);
}
}
// Only used till we move to using selectedModelByRole.chat
async llmFromTitle(title?: string): Promise<ILLM> {
const { config } = await this.loadConfig();
const model = config?.models.find((m) => m.title === title);
if (!model) {
if (title === LOCAL_ONBOARDING_PROVIDER_TITLE) {
// Special case, make calls to Ollama before we have it in the config
const ollama = new Ollama({
model: LOCAL_ONBOARDING_CHAT_MODEL,
});
return ollama;
} else if (config?.models?.length) {
return config?.models[0];
}
throw new Error("No model found");
}
return model;
}
// Ancient method of adding custom providers through vs code
private additionalContextProviders: IContextProvider[] = [];
registerCustomContextProvider(contextProvider: IContextProvider) {
this.additionalContextProviders.push(contextProvider);
void this.reloadConfig();
}
/**
* Retrieves the titles of additional context providers that are of type "submenu".
*
* @returns {string[]} An array of titles of the additional context providers that have a description type of "submenu".
*/
getAdditionalSubmenuContextProviders(): string[] {
return this.additionalContextProviders
.filter((provider) => provider.description.type === "submenu")
.map((provider) => provider.description.title);
}
}