-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat: add model profiles for saving and switching model configurations #5253
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
bf1aab6
1eb7b6e
e8e5e75
15ebfc4
d6bb1bc
f7d628c
8d18f5d
474592b
6f361e3
4056bac
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| import type { Model } from "@oh-my-pi/pi-ai"; | ||
| import type { ConfiguredThinkingLevel } from "../thinking"; | ||
| import { resolveModelRoleValue } from "./model-resolver"; | ||
| import type { Settings } from "./settings"; | ||
| import type { ProfileSnapshot } from "./settings-schema"; | ||
|
|
||
| /** Get all profiles as a typed record. */ | ||
| export function getProfiles(settings: Settings): Record<string, ProfileSnapshot> { | ||
| const value = settings.get("profiles"); | ||
| if (!value || typeof value !== "object" || Array.isArray(value)) return {}; | ||
| return value as Record<string, ProfileSnapshot>; | ||
| } | ||
|
|
||
| /** Get sorted profile names. */ | ||
| export function getProfileNames(settings: Settings): string[] { | ||
| return Object.keys(getProfiles(settings)).sort((a, b) => a.localeCompare(b)); | ||
| } | ||
|
|
||
| /** Save the current model configuration as a named profile. Overwrites if exists. */ | ||
| export function saveProfile(settings: Settings, name: string): void { | ||
| const modelRoles: Record<string, string> = {}; | ||
| for (const [role, selector] of Object.entries(settings.getModelRoles())) { | ||
| if (selector) modelRoles[role] = selector; | ||
| } | ||
| const defaultThinkingLevel = settings.get("defaultThinkingLevel"); | ||
| const snapshot: ProfileSnapshot = { | ||
| modelRoles, | ||
| defaultThinkingLevel, | ||
| }; | ||
| // Read from the global layer only — not the merged view — so we don't | ||
| // copy project-scoped profiles into the global config. | ||
| const profiles = { ...settings.getGlobalProfiles() }; | ||
| profiles[name] = snapshot; | ||
| settings.set("profiles", profiles); | ||
| } | ||
|
|
||
| /** | ||
| * Switch to a named profile. Replaces the entire modelRoles record and | ||
| * defaultThinkingLevel — clean cutover, NOT a merge. This ensures stale | ||
| * role assignments from the previously-active profile are removed. | ||
| * | ||
| * Returns true if the profile existed and was applied. | ||
| */ | ||
| export function switchProfile(settings: Settings, name: string): boolean { | ||
| const profiles = getProfiles(settings); | ||
| const profile = profiles[name]; | ||
| if (!profile) return false; | ||
| settings.set("modelRoles", profile.modelRoles); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a session was started with Useful? React with 👍 / 👎.
oldschoola marked this conversation as resolved.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. blocking: This does not replace the effective role record when project/config layers exist. |
||
| if (profile.defaultThinkingLevel !== undefined) { | ||
| settings.set("defaultThinkingLevel", profile.defaultThinkingLevel); | ||
| } | ||
| // Clear any runtime model-role overrides (from --model or env) so the | ||
| // profile's assignments take effect instead of the override winning. | ||
| settings.clearOverride("modelRoles"); | ||
| return true; | ||
| } | ||
|
|
||
| /** Delete a named profile. Returns true if it existed. */ | ||
| export function deleteProfile(settings: Settings, name: string): boolean { | ||
| // Read from the global layer only — not the merged view — so we don't | ||
| // copy project-scoped profiles into the global config. | ||
| const profiles = { ...settings.getGlobalProfiles() }; | ||
| if (!(name in profiles)) return false; | ||
| delete profiles[name]; | ||
| settings.set("profiles", profiles); | ||
| return true; | ||
| } | ||
|
|
||
| export interface ProfileSwitchResult { | ||
| ok: boolean; | ||
| model?: Model; | ||
| thinkingLevel?: ConfiguredThinkingLevel; | ||
| /** True when the profile has no explicit default role — the caller should | ||
| * reset the session to its automatic/default model. */ | ||
| needsReset?: boolean; | ||
| /** True when the profile has a default role but it couldn't resolve against | ||
| * available models (provider disabled, credentials removed, typo). The | ||
| * caller should warn the user and reset to the automatic default. */ | ||
| unresolvedDefault?: boolean; | ||
| } | ||
|
|
||
| /** | ||
| * Switch to a named profile and resolve the default role's model + thinking | ||
| * level from the available models. Returns the resolved model and thinking | ||
| * level so the caller can apply them to the live session. | ||
| * | ||
| * This is the single source of truth for profile switching — the slash | ||
| * command handlers and the interactive picker all call this. | ||
| */ | ||
| export function switchProfileAndResolve( | ||
| settings: Settings, | ||
| name: string, | ||
| availableModels: ReadonlyArray<Model>, | ||
| ): ProfileSwitchResult { | ||
| const ok = switchProfile(settings, name); | ||
| if (!ok) return { ok: false }; | ||
| const defaultRole = settings.getModelRole("default"); | ||
| if (!defaultRole) return { ok: true, needsReset: true }; | ||
| const resolved = resolveModelRoleValue(defaultRole, availableModels as Model[], { | ||
| settings, | ||
| }); | ||
| if (!resolved.model) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should-fix: This assertion discards the |
||
| // Profile has a default role but it doesn't resolve — fall back to | ||
| // automatic default and warn the user. | ||
| return { ok: true, needsReset: true, unresolvedDefault: true }; | ||
| } | ||
| return { | ||
| ok: true, | ||
| model: resolved.model, | ||
| thinkingLevel: resolved.explicitThinkingLevel ? resolved.thinkingLevel : undefined, | ||
|
oldschoola marked this conversation as resolved.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. blocking: The snapshot's |
||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import { Container, type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui"; | ||
| import { getSelectListTheme } from "../theme/theme"; | ||
| import { DynamicBorder } from "./dynamic-border"; | ||
| import { routeSelectListMouseWithTopBorder } from "./select-list-mouse-routing"; | ||
|
|
||
| export interface ProfileSelectorCallbacks { | ||
| onPick: (profileName: string) => void; | ||
| onCancel: () => void; | ||
| } | ||
|
|
||
| /** | ||
| * Interactive profile picker: lists saved profiles with keyboard navigation | ||
| * and fuzzy search. Rendered as an editor-slot selector (like /thinking). | ||
| */ | ||
| export class ProfileSelectorComponent extends Container { | ||
| #selectList: SelectList; | ||
|
|
||
| constructor(profileNames: string[], callbacks: ProfileSelectorCallbacks) { | ||
| super(); | ||
|
|
||
| const items: SelectItem[] = profileNames.map(name => ({ | ||
| value: name, | ||
| label: name, | ||
| })); | ||
|
|
||
| this.addChild(new DynamicBorder()); | ||
|
|
||
| this.#selectList = new SelectList( | ||
| items.length > 0 | ||
| ? items | ||
| : [{ value: "", label: "No profiles saved. Use /profiles save <name> to create one." }], | ||
| Math.min(Math.max(items.length, 1), 15), | ||
| getSelectListTheme(), | ||
| ); | ||
|
|
||
| this.#selectList.onSelect = item => { | ||
| if (item.value) callbacks.onPick(item.value); | ||
| }; | ||
|
|
||
| this.#selectList.onCancel = () => { | ||
| callbacks.onCancel(); | ||
| }; | ||
|
|
||
| this.addChild(this.#selectList); | ||
|
|
||
| this.addChild(new DynamicBorder()); | ||
| } | ||
|
|
||
| routeMouse(event: SgrMouseEvent, line: number, col: number): void { | ||
| routeSelectListMouseWithTopBorder(this.#selectList, event, line, col); | ||
| } | ||
|
|
||
| getSelectList(): SelectList { | ||
| return this.#selectList; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
blocking: This lookup accepts inherited object keys as profiles. With the normal
{}prototype,/profiles switch toStringfindsObject.prototype.toString, passes the truthiness guard, writesundefinedasmodelRoles, clears the runtime override, and reports success even though no such profile exists.deleteProfile()has the same issue vianame in profiles. RequireObject.hasOwn(profiles, name)and validate the snapshot shape before applying it; please cover prototype-key names in the missing-profile tests.