514 lines
16 KiB
TypeScript
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);
|
|
}
|
|
}
|