From 85e79fc36513b19932a6a7b7d093473acbbff2e9 Mon Sep 17 00:00:00 2001 From: Sidharth Date: Sun, 29 Mar 2026 00:21:05 -0700 Subject: [PATCH 01/74] Add Model Profiles and Visible Models management - New Visible Models settings tab: fetch models from OpenRouter/Ollama/LM Studio, then toggle per-model visibility with sub-provider filter chips - New Model Profiles settings tab: create/edit/delete named model sets grouped by provider; profiles draw only from visible models - Centralized filtering (ModelFiltering.ts) so visibility and active profile apply consistently in the chat model picker and quick chat - Profile selector dropdown in the chat model picker uses the themed Radix Select component Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/src/migrations.rs | 27 ++ src/core/chorus/Models.ts | 26 ++ src/core/chorus/api/ModelProfilesAPI.ts | 183 ++++++++++ src/core/chorus/api/ProviderVisibilityAPI.ts | 115 +++++++ src/core/utilities/ModelFiltering.ts | 76 +++++ src/ui/components/ManageModelsBox.tsx | 62 +++- src/ui/components/ModelProfilesTab.tsx | 336 +++++++++++++++++++ src/ui/components/QuickChatModelSelector.tsx | 12 +- src/ui/components/Settings.tsx | 13 + src/ui/components/VisibleModelsTab.tsx | 277 +++++++++++++++ 10 files changed, 1120 insertions(+), 7 deletions(-) create mode 100644 src/core/chorus/api/ModelProfilesAPI.ts create mode 100644 src/core/chorus/api/ProviderVisibilityAPI.ts create mode 100644 src/core/utilities/ModelFiltering.ts create mode 100644 src/ui/components/ModelProfilesTab.tsx create mode 100644 src/ui/components/VisibleModelsTab.tsx diff --git a/src-tauri/src/migrations.rs b/src-tauri/src/migrations.rs index 87622999..07e4451c 100644 --- a/src-tauri/src/migrations.rs +++ b/src-tauri/src/migrations.rs @@ -2554,5 +2554,32 @@ You have full access to bash commands on the user''''s computer. If you write a UPDATE projects SET total_cost_usd = 0.0 WHERE total_cost_usd IS NULL; "#, }, + Migration { + version: 139, + description: "add provider_visible_models table", + kind: MigrationKind::Up, + sql: r#" + CREATE TABLE provider_visible_models ( + provider_name TEXT NOT NULL, + model_id TEXT NOT NULL, + is_visible BOOLEAN DEFAULT 1, + PRIMARY KEY (provider_name, model_id) + ); + "#, + }, + Migration { + version: 140, + description: "add model_profiles table", + kind: MigrationKind::Up, + sql: r#" + CREATE TABLE model_profiles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + model_config_ids TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + "#, + }, ]; } diff --git a/src/core/chorus/Models.ts b/src/core/chorus/Models.ts index 1563d354..5b0712ac 100644 --- a/src/core/chorus/Models.ts +++ b/src/core/chorus/Models.ts @@ -206,6 +206,32 @@ export type ModelConfig = { completionPricePerToken?: number; }; +/// ------------------------------------------------------------------------------------------------ +/// Provider Visibility & Model Profiles +/// ------------------------------------------------------------------------------------------------ + +/** + * Per-model visibility setting for provider-level filtering. + * Users can hide models they don't want to see in the model picker. + */ +export type ProviderVisibility = { + providerName: string; + modelId: string; + isVisible: boolean; +}; + +/** + * A named profile containing a set of selected model configs. + * Users can quickly switch between profiles (e.g., "3 model set", "4 model set"). + */ +export type ModelProfile = { + id: string; + name: string; + modelConfigIds: string[]; // Ordered list of model config IDs + createdAt?: string; + updatedAt?: string; +}; + export type UsageData = { prompt_tokens?: number; completion_tokens?: number; diff --git a/src/core/chorus/api/ModelProfilesAPI.ts b/src/core/chorus/api/ModelProfilesAPI.ts new file mode 100644 index 00000000..ec1ba118 --- /dev/null +++ b/src/core/chorus/api/ModelProfilesAPI.ts @@ -0,0 +1,183 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { db } from "../DB"; +import { ModelProfile } from "../Models"; + +const modelProfileKeys = { + all: () => ["modelProfiles"] as const, + list: () => [...modelProfileKeys.all(), "list"] as const, + active: () => [...modelProfileKeys.all(), "active"] as const, +}; + +type ModelProfileDBRow = { + id: string; + name: string; + model_config_ids: string; + created_at: string; + updated_at: string; +}; + +function readModelProfile(row: ModelProfileDBRow): ModelProfile { + return { + id: row.id, + name: row.name, + modelConfigIds: JSON.parse(row.model_config_ids) as string[], + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +/** + * Fetch all model profiles from the database. + */ +export async function fetchModelProfiles(): Promise { + const rows = await db.select( + "SELECT id, name, model_config_ids, created_at, updated_at FROM model_profiles ORDER BY created_at ASC" + ); + return rows.map(readModelProfile); +} + +/** + * Fetch the active model profile ID from app_metadata. + */ +export async function fetchActiveModelProfileId(): Promise { + const rows = await db.select<{ value: string }[]>( + "SELECT value FROM app_metadata WHERE key = 'active_model_profile_id'" + ); + return rows.length > 0 ? rows[0].value : null; +} + +/** + * Hook to get all model profiles. + */ +export function useModelProfiles() { + return useQuery({ + queryKey: modelProfileKeys.list(), + queryFn: fetchModelProfiles, + }); +} + +/** + * Hook to get the active model profile ID. + */ +export function useActiveModelProfileId() { + return useQuery({ + queryKey: modelProfileKeys.active(), + queryFn: fetchActiveModelProfileId, + }); +} + +/** + * Hook to get the full active model profile (if any). + */ +export function useActiveModelProfile(): ModelProfile | null { + const { data: profiles } = useModelProfiles(); + const { data: activeId } = useActiveModelProfileId(); + + if (!profiles || !activeId) return null; + + return profiles.find((p) => p.id === activeId) ?? null; +} + +/** + * Hook to set the active model profile. + * Pass null to deactivate (no profile active). + */ +export function useSetActiveModelProfile() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["setActiveModelProfile"] as const, + mutationFn: async (profileId: string | null) => { + if (profileId) { + await db.execute( + "INSERT OR REPLACE INTO app_metadata (key, value) VALUES ('active_model_profile_id', ?)", + [profileId] + ); + } else { + await db.execute( + "DELETE FROM app_metadata WHERE key = 'active_model_profile_id'" + ); + } + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: modelProfileKeys.active(), + }); + }, + }); +} + +/** + * Hook to create a new model profile. + */ +export function useCreateModelProfile() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["createModelProfile"] as const, + mutationFn: async ({ + id, + name, + modelConfigIds, + }: { + id: string; + name: string; + modelConfigIds: string[]; + }) => { + await db.execute( + "INSERT INTO model_profiles (id, name, model_config_ids) VALUES (?, ?, ?)", + [id, name, JSON.stringify(modelConfigIds)] + ); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: modelProfileKeys.list(), + }); + }, + }); +} + +/** + * Hook to update an existing model profile. + */ +export function useUpdateModelProfile() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["updateModelProfile"] as const, + mutationFn: async ({ + id, + name, + modelConfigIds, + }: { + id: string; + name: string; + modelConfigIds: string[]; + }) => { + await db.execute( + "UPDATE model_profiles SET name = ?, model_config_ids = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + [name, JSON.stringify(modelConfigIds), id] + ); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: modelProfileKeys.list(), + }); + }, + }); +} + +/** + * Hook to delete a model profile. + */ +export function useDeleteModelProfile() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["deleteModelProfile"] as const, + mutationFn: async ({ id }: { id: string }) => { + await db.execute("DELETE FROM model_profiles WHERE id = ?", [id]); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: modelProfileKeys.list(), + }); + }, + }); +} diff --git a/src/core/chorus/api/ProviderVisibilityAPI.ts b/src/core/chorus/api/ProviderVisibilityAPI.ts new file mode 100644 index 00000000..4237084b --- /dev/null +++ b/src/core/chorus/api/ProviderVisibilityAPI.ts @@ -0,0 +1,115 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { db } from "../DB"; +import { ProviderVisibility, ProviderName } from "../Models"; + +const providerVisibilityKeys = { + all: () => ["providerVisibility"] as const, + list: () => [...providerVisibilityKeys.all(), "list"] as const, +}; + +type ProviderVisibilityDBRow = { + provider_name: string; + model_id: string; + is_visible: number; +}; + +function readProviderVisibility(row: ProviderVisibilityDBRow): ProviderVisibility { + return { + providerName: row.provider_name, + modelId: row.model_id, + isVisible: row.is_visible === 1, + }; +} + +/** + * Fetch all provider visibility records from the database. + */ +export async function fetchProviderVisibleModels(): Promise { + const rows = await db.select( + "SELECT provider_name, model_id, is_visible FROM provider_visible_models" + ); + return rows.map(readProviderVisibility); +} + +/** + * Hook to get all provider visibility records. + */ +export function useProviderVisibleModels() { + return useQuery({ + queryKey: providerVisibilityKeys.list(), + queryFn: fetchProviderVisibleModels, + }); +} + +/** + * Hook to set visibility for a specific model. + */ +export function useSetModelVisibility() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["setModelVisibility"] as const, + mutationFn: async ({ + providerName, + modelId, + isVisible, + }: { + providerName: string; + modelId: string; + isVisible: boolean; + }) => { + await db.execute( + "INSERT OR REPLACE INTO provider_visible_models (provider_name, model_id, is_visible) VALUES (?, ?, ?)", + [providerName, modelId, isVisible ? 1 : 0] + ); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: providerVisibilityKeys.list(), + }); + }, + }); +} + +/** + * Hook to set visibility for all models of a provider at once. + */ +export function useSetAllProviderModelsVisible() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["setAllProviderModelsVisible"] as const, + mutationFn: async ({ + providerName, + modelIds, + isVisible, + }: { + providerName: ProviderName; + modelIds: string[]; + isVisible: boolean; + }) => { + const value = isVisible ? 1 : 0; + for (const modelId of modelIds) { + await db.execute( + "INSERT OR REPLACE INTO provider_visible_models (provider_name, model_id, is_visible) VALUES (?, ?, ?)", + [providerName, modelId, value] + ); + } + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: providerVisibilityKeys.list(), + }); + }, + }); +} + +/** + * Get the visibility map for quick lookup. + * Returns a Map where key is modelId and value is isVisible. + * Models not in the map should be considered visible by default. + */ +export function useProviderVisibilityMap(): Map | undefined { + const { data } = useProviderVisibleModels(); + if (!data) return undefined; + + return new Map(data.map((v) => [v.modelId, v.isVisible])); +} diff --git a/src/core/utilities/ModelFiltering.ts b/src/core/utilities/ModelFiltering.ts new file mode 100644 index 00000000..719a8def --- /dev/null +++ b/src/core/utilities/ModelFiltering.ts @@ -0,0 +1,76 @@ +import { ModelConfig, ProviderVisibility, ModelProfile } from "@core/chorus/Models"; + +/** + * Centralized filtering utility that combines provider visibility and profile filtering. + * + * Filtering order: + * 1. Filter out internal and deprecated models (always) + * 2. Filter by provider visibility (if configured) + * 3. Filter by active profile (if active) + * + * Models not in the visibility map are considered visible by default (backward compatibility). + * + * @param allModelConfigs - All available model configs + * @param providerVisibilityMap - Map of modelId -> isVisible (from ProviderVisibilityAPI) + * @param activeProfile - Currently active profile (or null) + * @returns Filtered model configs that pass all filters + */ +export function getFilteredModelConfigs( + allModelConfigs: ModelConfig[], + providerVisibilityMap: Map | undefined, + activeProfile: ModelProfile | null +): ModelConfig[] { + // Step 1: Always filter out internal and deprecated models + let filtered = allModelConfigs.filter( + (config) => !config.isInternal && !config.isDeprecated && config.isEnabled + ); + + // Step 2: Filter by provider visibility + // If providerVisibilityMap is undefined, assume all models visible (backward compatibility) + if (providerVisibilityMap && providerVisibilityMap.size > 0) { + filtered = filtered.filter((config) => { + const isVisible = providerVisibilityMap.get(config.modelId); + // If model is not in the visibility map, default to visible + return isVisible === undefined ? true : isVisible; + }); + } + + // Step 3: If a profile is active, filter to only include models in that profile + // Note: Profile uses modelConfigIds, not modelIds + if (activeProfile) { + const profileConfigIds = new Set(activeProfile.modelConfigIds); + filtered = filtered.filter((config) => profileConfigIds.has(config.id)); + } + + return filtered; +} + +/** + * Get the list of provider names that have at least one model in the given configs. + */ +export function getProvidersWithModels(modelConfigs: ModelConfig[]): string[] { + const providers = new Set(); + for (const config of modelConfigs) { + const provider = config.modelId.split("::")[0]; + if (provider) { + providers.add(provider); + } + } + return Array.from(providers).sort(); +} + +/** + * Group model configs by provider. + */ +export function groupModelsByProvider( + modelConfigs: ModelConfig[] +): Map { + const groups = new Map(); + for (const config of modelConfigs) { + const provider = config.modelId.split("::")[0] ?? "unknown"; + const existing = groups.get(provider) ?? []; + existing.push(config); + groups.set(provider, existing); + } + return groups; +} diff --git a/src/ui/components/ManageModelsBox.tsx b/src/ui/components/ManageModelsBox.tsx index cbf1dc27..da60c494 100644 --- a/src/ui/components/ManageModelsBox.tsx +++ b/src/ui/components/ManageModelsBox.tsx @@ -47,6 +47,20 @@ import { hasApiKey } from "@core/utilities/ProxyUtils"; import * as ModelsAPI from "@core/chorus/api/ModelsAPI"; import * as MessageAPI from "@core/chorus/api/MessageAPI"; import { useSettings } from "./hooks/useSettings"; +import { useProviderVisibilityMap } from "@core/chorus/api/ProviderVisibilityAPI"; +import { + useActiveModelProfile, + useModelProfiles, + useSetActiveModelProfile, +} from "@core/chorus/api/ModelProfilesAPI"; +import { getFilteredModelConfigs } from "@core/utilities/ModelFiltering"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./ui/select"; // Helper function to filter models by search terms const filterBySearch = (models: ModelConfig[], searchTerms: string[]) => { @@ -295,6 +309,35 @@ export const MANAGE_MODELS_COMPARE_DIALOG_ID = "manage-models-compare"; export const MANAGE_MODELS_COMPARE_INLINE_DIALOG_ID = "manage-models-compare-inline"; // dialog for the inline add model button +function ProfileSelector() { + const { data: profiles } = useModelProfiles(); + const activeProfile = useActiveModelProfile(); + const setActiveProfile = useSetActiveModelProfile(); + + if (!profiles || profiles.length === 0) return null; + + return ( + + ); +} + /** Main component that handles all model grouping and UI. */ export function ManageModelsBox({ mode, @@ -474,18 +517,24 @@ export function ManageModelsBox({ }, [navigate]); // Compute filtered model groups based on search + const providerVisibilityMap = useProviderVisibilityMap(); + const activeProfile = useActiveModelProfile(); const modelGroups = useMemo(() => { const searchTerms = searchQuery .toLowerCase() .split(" ") .filter(Boolean); - const nonInternalModelConfigs = - modelConfigs.data?.filter((m) => !m.isInternal) ?? []; - const systemModels = nonInternalModelConfigs.filter( + const filtered = getFilteredModelConfigs( + modelConfigs.data ?? [], + providerVisibilityMap, + activeProfile + ); + + const systemModels = filtered.filter( (m) => m.author === "system", ); - const userModels = nonInternalModelConfigs.filter( + const userModels = filtered.filter( (m) => m.author === "user", ); @@ -525,7 +574,7 @@ export function ManageModelsBox({ openrouter: filterBySearch(openrouterModels, searchTerms), directByProvider, }; - }, [modelConfigs.data, searchQuery]); + }, [modelConfigs.data, searchQuery, providerVisibilityMap, activeProfile]); useLayoutEffect(() => { if (!listRef.current) return; @@ -631,6 +680,9 @@ export function ManageModelsBox({ }} autoFocus /> +
+ +
No models found diff --git a/src/ui/components/ModelProfilesTab.tsx b/src/ui/components/ModelProfilesTab.tsx new file mode 100644 index 00000000..3374a3ea --- /dev/null +++ b/src/ui/components/ModelProfilesTab.tsx @@ -0,0 +1,336 @@ +import { useState } from "react"; +import { Button } from "./ui/button"; +import { + useModelProfiles, + useCreateModelProfile, + useUpdateModelProfile, + useDeleteModelProfile, +} from "@core/chorus/api/ModelProfilesAPI"; +import { useModelConfigs } from "@core/chorus/api/ModelsAPI"; +import { useProviderVisibilityMap } from "@core/chorus/api/ProviderVisibilityAPI"; +import { getFilteredModelConfigs } from "@core/utilities/ModelFiltering"; +import { ModelConfig, getProviderName, ModelProfile } from "@core/chorus/Models"; +import { Loader2, Plus, Trash2, Pencil, Check, X } from "lucide-react"; +import { v4 as uuidv4 } from "uuid"; +import { Input } from "./ui/input"; +import { Checkbox } from "@ui/components/ui/checkbox"; + +const PROVIDER_LABELS: Record = { + anthropic: "Anthropic", + openai: "OpenAI", + google: "Google AI (Gemini)", + openrouter: "OpenRouter", + grok: "Grok", + perplexity: "Perplexity", + ollama: "Ollama", + lmstudio: "LM Studio", +}; + +const PROVIDER_ORDER = [ + "anthropic", + "openai", + "google", + "openrouter", + "grok", + "perplexity", + "ollama", + "lmstudio", +]; + +function groupByProvider(models: ModelConfig[]): [string, ModelConfig[]][] { + const groups = new Map(); + for (const m of models) { + const provider = getProviderName(m.modelId); + const existing = groups.get(provider) ?? []; + existing.push(m); + groups.set(provider, existing); + } + return Array.from(groups.entries()).sort(([a], [b]) => { + const ai = PROVIDER_ORDER.indexOf(a); + const bi = PROVIDER_ORDER.indexOf(b); + if (ai === -1 && bi === -1) return a.localeCompare(b); + if (ai === -1) return 1; + if (bi === -1) return -1; + return ai - bi; + }); +} + +function ModelChecklist({ + visibleModels, + selectedIds, + onChange, +}: { + visibleModels: ModelConfig[]; + selectedIds: string[]; + onChange: (ids: string[]) => void; +}) { + const providerGroups = groupByProvider(visibleModels); + + const toggleOne = (id: string, checked: boolean) => { + onChange( + checked ? [...selectedIds, id] : selectedIds.filter((x) => x !== id) + ); + }; + + const toggleAll = (models: ModelConfig[], checked: boolean) => { + const ids = models.map((m) => m.id); + if (checked) { + onChange(Array.from(new Set([...selectedIds, ...ids]))); + } else { + const idSet = new Set(ids); + onChange(selectedIds.filter((id) => !idSet.has(id))); + } + }; + + if (visibleModels.length === 0) { + return ( +

+ No visible models available. Go to "Visible Models" to enable models + first. +

+ ); + } + + return ( +
+ {providerGroups.map(([provider, models]) => { + const allSelected = models.every((m) => selectedIds.includes(m.id)); + const someSelected = models.some((m) => selectedIds.includes(m.id)); + const label = PROVIDER_LABELS[provider] ?? provider; + + return ( +
+
+ + toggleAll(models, !!checked) + } + /> + + {label} + +
+
+ {models.map((m) => ( +
+ + toggleOne(m.id, !!checked) + } + /> + {m.displayName} +
+ ))} +
+
+ ); + })} +
+ ); +} + +function EditProfileForm({ + profile, + visibleModels, + onSave, + onCancel, +}: { + profile: ModelProfile; + visibleModels: ModelConfig[]; + onSave: (name: string, modelConfigIds: string[]) => void; + onCancel: () => void; +}) { + const [name, setName] = useState(profile.name); + const [selectedIds, setSelectedIds] = useState( + profile.modelConfigIds + ); + + return ( +
+ setName(e.target.value)} + /> + +
+ + +
+
+ ); +} + +export function ModelProfilesTab() { + const { data: profiles, isLoading } = useModelProfiles(); + const { data: allModels } = useModelConfigs(); + const providerVisibilityMap = useProviderVisibilityMap(); + const createProfile = useCreateModelProfile(); + const updateProfile = useUpdateModelProfile(); + const deleteProfile = useDeleteModelProfile(); + + const [isCreating, setIsCreating] = useState(false); + const [newName, setNewName] = useState(""); + const [newSelectedModels, setNewSelectedModels] = useState([]); + const [editingId, setEditingId] = useState(null); + + if (isLoading || !allModels) { + return ( +
+ +
+ ); + } + + const visibleModels = getFilteredModelConfigs( + allModels, + providerVisibilityMap, + null + ); + + const handleCreate = () => { + createProfile.mutate({ + id: uuidv4(), + name: newName, + modelConfigIds: newSelectedModels, + }); + setIsCreating(false); + setNewName(""); + setNewSelectedModels([]); + }; + + const handleUpdate = ( + id: string, + name: string, + modelConfigIds: string[] + ) => { + updateProfile.mutate({ id, name, modelConfigIds }); + setEditingId(null); + }; + + return ( +
+
+

Model Profiles

+

+ Create named sets of models to quickly switch between them in chat. + Profiles draw from your visible models — configure which models are + visible in the "Visible Models" tab. +

+
+ + + + {isCreating && ( +
+ setNewName(e.target.value)} + /> + +
+ + +
+
+ )} + +
+ {profiles?.map((p) => + editingId === p.id ? ( + handleUpdate(p.id, name, ids)} + onCancel={() => setEditingId(null)} + /> + ) : ( +
+
+

{p.name}

+

+ {p.modelConfigIds.length} models +

+
+
+ + +
+
+ ) + )} +
+
+ ); +} diff --git a/src/ui/components/QuickChatModelSelector.tsx b/src/ui/components/QuickChatModelSelector.tsx index eb55dce0..85b358e7 100644 --- a/src/ui/components/QuickChatModelSelector.tsx +++ b/src/ui/components/QuickChatModelSelector.tsx @@ -20,6 +20,8 @@ import { useMemo } from "react"; import { ALLOWED_MODEL_IDS_FOR_QUICK_CHAT } from "@ui/lib/models"; import * as ModelsAPI from "@core/chorus/api/ModelsAPI"; import * as AppMetadataAPI from "@core/chorus/api/AppMetadataAPI"; +import { useProviderVisibilityMap } from "@core/chorus/api/ProviderVisibilityAPI"; +import { getFilteredModelConfigs } from "@core/utilities/ModelFiltering"; interface ModelSelectorProps { onModelSelect: (modelId: string) => void; @@ -78,9 +80,15 @@ export function QuickChatModelSelector({ [apiKeys], ); + const providerVisibilityMap = useProviderVisibilityMap(); + const quickChatSelectableModelConfigs = useMemo( () => - modelConfigsQuery?.data?.filter( + getFilteredModelConfigs( + modelConfigsQuery?.data ?? [], + providerVisibilityMap, + null // Active profile not applied to ambient chat + ).filter( (config) => config.isEnabled && !config.id.includes("chorus") && @@ -88,7 +96,7 @@ export function QuickChatModelSelector({ ALLOWED_MODEL_IDS_FOR_QUICK_CHAT.includes(config.id) && isModelAllowed(config), ) ?? [], - [modelConfigsQuery, isModelAllowed], + [modelConfigsQuery, isModelAllowed, providerVisibilityMap], ); const handleModelSelect = useCallback( diff --git a/src/ui/components/Settings.tsx b/src/ui/components/Settings.tsx index f3a7e363..0f9b24a1 100644 --- a/src/ui/components/Settings.tsx +++ b/src/ui/components/Settings.tsx @@ -40,6 +40,8 @@ import { Import, BookOpen, Globe, + Eye, + Layers, } from "lucide-react"; import { toast } from "sonner"; import { config } from "@core/config"; @@ -88,6 +90,9 @@ import * as AppMetadataAPI from "@core/chorus/api/AppMetadataAPI"; import { PermissionsTab } from "./PermissionsTab"; import { cn } from "@ui/lib/utils"; +import { VisibleModelsTab } from "./VisibleModelsTab"; +import { ModelProfilesTab } from "./ModelProfilesTab"; + type ToolsetFormProps = { toolset: CustomToolsetConfig; errors: Record; @@ -1095,6 +1100,8 @@ export type SettingsTabId = | "import" | "system-prompt" | "api-keys" + | "visible-models" + | "model-profiles" | "quick-chat" | "connections" | "permissions" @@ -1111,6 +1118,8 @@ const TABS: Record = { import: { label: "Import", icon: Import }, "system-prompt": { label: "System Prompt", icon: FileText }, "api-keys": { label: "API Keys", icon: Key }, + "visible-models": { label: "Visible Models", icon: Eye }, + "model-profiles": { label: "Model Profiles", icon: Layers }, "quick-chat": { label: "Ambient Chat", icon: Fullscreen }, connections: { label: "Connections", icon: PlugIcon }, permissions: { label: "Tool Permissions", icon: ShieldCheckIcon }, @@ -1776,6 +1785,10 @@ export default function Settings({ tab = "general" }: SettingsProps) { )} + {activeTab === "visible-models" && } + + {activeTab === "model-profiles" && } + {activeTab === "quick-chat" && (
diff --git a/src/ui/components/VisibleModelsTab.tsx b/src/ui/components/VisibleModelsTab.tsx new file mode 100644 index 00000000..90d86871 --- /dev/null +++ b/src/ui/components/VisibleModelsTab.tsx @@ -0,0 +1,277 @@ +import { useState } from "react"; +import { Button } from "./ui/button"; +import { Switch } from "./ui/switch"; +import { + useProviderVisibleModels, + useSetModelVisibility, + useSetAllProviderModelsVisible, +} from "@core/chorus/api/ProviderVisibilityAPI"; +import { + useModelConfigs, + useRefreshOpenRouterModels, + useRefreshOllamaModels, + useRefreshLMStudioModels, +} from "@core/chorus/api/ModelsAPI"; +import { ModelConfig } from "@core/chorus/Models"; +import { Loader2, RefreshCcw } from "lucide-react"; +import { getProviderName } from "@core/chorus/Models"; + +const FETCHABLE_PROVIDERS = ["openrouter", "ollama", "lmstudio"] as const; +type FetchableProvider = (typeof FETCHABLE_PROVIDERS)[number]; + +const PROVIDER_LABELS: Record = { + openrouter: "OpenRouter", + ollama: "Ollama", + lmstudio: "LM Studio", + anthropic: "Anthropic", + openai: "OpenAI", + google: "Google", + grok: "Grok", + perplexity: "Perplexity", +}; + +/** + * Extracts the sub-provider org from a model ID. + * For "openrouter::meta-llama/llama-4-scout" returns "meta-llama". + * For models without an org prefix returns null. + */ +function getSubProvider(modelId: string): string | null { + const modelPart = modelId.split("::")[1]; + if (!modelPart) return null; + const slashIdx = modelPart.indexOf("/"); + if (slashIdx === -1) return null; + return modelPart.slice(0, slashIdx); +} + +export function VisibleModelsTab() { + const { data: visibleModels, isLoading } = useProviderVisibleModels(); + const { data: allModels } = useModelConfigs(); + const setVisibility = useSetModelVisibility(); + const setAllVisibility = useSetAllProviderModelsVisible(); + + const refreshOpenRouter = useRefreshOpenRouterModels(); + const refreshOllama = useRefreshOllamaModels(); + const refreshLMStudio = useRefreshLMStudioModels(); + const [fetchingProviders, setFetchingProviders] = useState< + Record + >({ openrouter: false, ollama: false, lmstudio: false }); + + // Selected sub-provider filter per top-level provider (null = show all) + const [subProviderFilter, setSubProviderFilter] = useState< + Record + >({}); + + if (isLoading || !allModels) { + return ( +
+ +
+ ); + } + + const handleFetchModels = async (provider: FetchableProvider) => { + setFetchingProviders((prev) => ({ ...prev, [provider]: true })); + try { + if (provider === "openrouter") await refreshOpenRouter.mutateAsync(); + else if (provider === "ollama") await refreshOllama.mutateAsync(); + else if (provider === "lmstudio") await refreshLMStudio.mutateAsync(); + } finally { + setFetchingProviders((prev) => ({ ...prev, [provider]: false })); + } + }; + + // Group models by provider + const allProviders = Array.from( + new Set(allModels.map((m) => getProviderName(m.modelId))) + ); + + const fetchableWithModels = FETCHABLE_PROVIDERS.filter((p) => + allProviders.includes(p) + ); + const fetchableWithoutModels = FETCHABLE_PROVIDERS.filter( + (p) => !allProviders.includes(p) + ); + const otherProviders = allProviders.filter( + (p) => !FETCHABLE_PROVIDERS.includes(p as FetchableProvider) + ); + + const orderedProviders = [ + ...otherProviders, + ...fetchableWithModels, + ...fetchableWithoutModels, + ]; + + return ( +
+
+

Visible Models

+

+ Fetch and choose which models appear in the chat model picker and + in your model profiles. +

+
+ + {orderedProviders.map((provider) => { + const providerModels = allModels.filter( + (m) => getProviderName(m.modelId) === provider + ); + const isFetchable = FETCHABLE_PROVIDERS.includes( + provider as FetchableProvider + ); + const isFetching = + isFetchable && + fetchingProviders[provider as FetchableProvider]; + + // Compute unique sub-providers for this top-level provider + const subProviders = Array.from( + new Set( + providerModels + .map((m) => getSubProvider(m.modelId)) + .filter((s): s is string => s !== null) + ) + ).sort(); + + const activeSubFilter = subProviderFilter[provider] ?? null; + + // Apply sub-provider filter + const visibleProviderModels: ModelConfig[] = + activeSubFilter !== null + ? providerModels.filter( + (m) => getSubProvider(m.modelId) === activeSubFilter + ) + : providerModels; + + const isAllVisible = visibleProviderModels.every((m) => { + const v = visibleModels?.find((vm) => vm.modelId === m.modelId); + return v ? v.isVisible : true; + }); + + return ( +
+
+

+ {PROVIDER_LABELS[provider] ?? provider} +

+
+ {isFetchable && ( + + )} + {visibleProviderModels.length > 0 && ( + + )} +
+
+ + {/* Sub-provider filter chips */} + {subProviders.length > 1 && ( +
+ + {subProviders.map((sub) => ( + + ))} +
+ )} + + {providerModels.length === 0 ? ( +

+ {isFetchable + ? 'No models loaded yet. Click "Fetch Models" to load the model list.' + : "No models available."} +

+ ) : ( +
+ {visibleProviderModels.map((m) => { + const visibility = visibleModels?.find( + (vm) => vm.modelId === m.modelId + ); + const isVisible = visibility + ? visibility.isVisible + : true; + + return ( +
+ {m.displayName} + + setVisibility.mutate({ + providerName: provider, + modelId: m.modelId, + isVisible: checked, + }) + } + /> +
+ ); + })} +
+ )} +
+ ); + })} +
+ ); +} From 7f8cc4e21ef62230580e0998112f21e76ba1e21d Mon Sep 17 00:00:00 2001 From: Sidharth Date: Sun, 29 Mar 2026 00:25:15 -0700 Subject: [PATCH 02/74] Add Prompt Profiles feature Adds persona/role presets that inject a system prompt into chats. Includes 5 built-in profiles (Data Scientist, Academic Researcher, Study Guide, Code Reviewer, Creative Writer), a per-chat selector pill in the chat input toolbar, and a Prompt Profiles tab in Settings. Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/src/migrations.rs | 31 +++ src/core/chorus/Models.ts | 13 ++ src/core/chorus/api/MessageAPI.ts | 7 + src/core/chorus/api/PromptProfilesAPI.ts | 199 +++++++++++++++++++ src/core/chorus/prompts/prompts.ts | 9 +- src/ui/components/ChatInput.tsx | 2 + src/ui/components/PromptProfilePill.tsx | 117 +++++++++++ src/ui/components/PromptProfilesTab.tsx | 235 +++++++++++++++++++++++ src/ui/components/Settings.tsx | 6 + 9 files changed, 618 insertions(+), 1 deletion(-) create mode 100644 src/core/chorus/api/PromptProfilesAPI.ts create mode 100644 src/ui/components/PromptProfilePill.tsx create mode 100644 src/ui/components/PromptProfilesTab.tsx diff --git a/src-tauri/src/migrations.rs b/src-tauri/src/migrations.rs index 07e4451c..8cf0988b 100644 --- a/src-tauri/src/migrations.rs +++ b/src-tauri/src/migrations.rs @@ -2581,5 +2581,36 @@ You have full access to bash commands on the user''''s computer. If you write a ); "#, }, + Migration { + version: 141, + description: "add prompt_profiles and prompt_profile_chats tables", + kind: MigrationKind::Up, + sql: r#" + CREATE TABLE prompt_profiles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + system_prompt TEXT NOT NULL, + icon TEXT, + author TEXT NOT NULL DEFAULT 'user' CHECK (author IN ('user', 'system')), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE prompt_profile_chats ( + id TEXT PRIMARY KEY, + chat_id TEXT NOT NULL UNIQUE, + prompt_profile_id TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO prompt_profiles (id, name, system_prompt, icon, author) VALUES + ('pp-data-scientist', 'Data Scientist', 'You are an expert data scientist. Focus on statistical rigor, reproducibility, and evidence-based reasoning. Prefer concrete numbers and quantitative analysis. Use Python or R code examples when helpful, following best practices for data manipulation, visualization, and modeling.', '🔬', 'system'), + ('pp-academic-researcher', 'Academic Researcher', 'You are an academic researcher. Prioritize primary sources, peer-reviewed literature, and rigorous methodology. Structure responses with clear argumentation. Cite relevant work and distinguish between established findings and emerging hypotheses. Use precise academic language.', '🎓', 'system'), + ('pp-study-guide', 'Study Guide', 'You are a patient and encouraging tutor. Break down complex topics into clear, digestible explanations. Use examples, analogies, and step-by-step reasoning to build understanding. Ask clarifying questions to gauge comprehension and adapt your explanations to the learner''s level. Suggest practice questions when appropriate.', '📚', 'system'), + ('pp-code-reviewer', 'Code Reviewer', 'You are a meticulous code reviewer. Focus on correctness, performance, security, and maintainability. Point out bugs, edge cases, and anti-patterns. Suggest concrete improvements with explanations. Consider readability and adherence to best practices. Be constructive but thorough.', '🔍', 'system'), + ('pp-creative-writer', 'Creative Writer', 'You are a skilled creative writing partner. Focus on vivid, engaging language, compelling narrative, and emotional resonance. Help with brainstorming ideas, developing characters, structuring plots, and refining prose. Offer specific suggestions rather than vague encouragement.', '✏️', 'system'); + "#, + }, ]; } diff --git a/src/core/chorus/Models.ts b/src/core/chorus/Models.ts index 5b0712ac..da41e4ec 100644 --- a/src/core/chorus/Models.ts +++ b/src/core/chorus/Models.ts @@ -232,6 +232,19 @@ export type ModelProfile = { updatedAt?: string; }; +/** + * A named persona/role preset with a system prompt injected into chats. + */ +export type PromptProfile = { + id: string; + name: string; + systemPrompt: string; + icon?: string; + author: "user" | "system"; + createdAt?: string; + updatedAt?: string; +}; + export type UsageData = { prompt_tokens?: number; completion_tokens?: number; diff --git a/src/core/chorus/api/MessageAPI.ts b/src/core/chorus/api/MessageAPI.ts index 013cd067..f427798a 100644 --- a/src/core/chorus/api/MessageAPI.ts +++ b/src/core/chorus/api/MessageAPI.ts @@ -58,6 +58,7 @@ import { fetchModelConfigById, } from "./ModelsAPI"; import { Attachment, AttachmentDBRow, readAttachment } from "./AttachmentsAPI"; +import { fetchChatPromptProfileSystemPrompt } from "./PromptProfilesAPI"; // Query keys objects are based on https://tkdodo.eu/blog/effective-react-query-keys // although also consider this approach: https://tkdodo.eu/blog/leveraging-the-query-function-context @@ -1285,6 +1286,8 @@ export function useStreamMessagePart() { queryKey: appMetadataKeys.appMetadata(), queryFn: () => fetchAppMetadata(), }); + const promptProfileSystemPrompt = + await fetchChatPromptProfileSystemPrompt(chatId); const modelConfig = Prompts.injectSystemPrompts(modelConfigRaw, { toolsetInfo: toolsets.map((toolset) => ({ displayName: toolset.displayName, @@ -1293,6 +1296,7 @@ export function useStreamMessagePart() { })), isInProject: project.id !== "default", universalSystemPrompt: appMetadata["universal_system_prompt"], + promptProfileSystemPrompt, }); const customBaseUrl = await getCustomBaseUrl(); @@ -1364,9 +1368,12 @@ export function useStreamMessageLegacy() { queryKey: appMetadataKeys.appMetadata(), queryFn: () => fetchAppMetadata(), }); + const promptProfileSystemPrompt = + await fetchChatPromptProfileSystemPrompt(chatId); const modelConfig = Prompts.injectSystemPrompts(modelConfigRaw, { isInProject: project.id !== "default", universalSystemPrompt: appMetadata["universal_system_prompt"], + promptProfileSystemPrompt, }); const projectContext = await getProjectContext(project.id, chatId); diff --git a/src/core/chorus/api/PromptProfilesAPI.ts b/src/core/chorus/api/PromptProfilesAPI.ts new file mode 100644 index 00000000..cc3ab984 --- /dev/null +++ b/src/core/chorus/api/PromptProfilesAPI.ts @@ -0,0 +1,199 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { db } from "../DB"; +import { PromptProfile } from "../Models"; +import { v4 as uuidv4 } from "uuid"; + +const promptProfileKeys = { + all: () => ["promptProfiles"] as const, + list: () => [...promptProfileKeys.all(), "list"] as const, + chatProfile: (chatId: string) => + [...promptProfileKeys.all(), "chat", chatId] as const, +}; + +type PromptProfileDBRow = { + id: string; + name: string; + system_prompt: string; + icon: string | null; + author: "user" | "system"; + created_at: string; + updated_at: string; +}; + +function readPromptProfile(row: PromptProfileDBRow): PromptProfile { + return { + id: row.id, + name: row.name, + systemPrompt: row.system_prompt, + icon: row.icon ?? undefined, + author: row.author, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export async function fetchPromptProfiles(): Promise { + const rows = await db.select( + "SELECT id, name, system_prompt, icon, author, created_at, updated_at FROM prompt_profiles ORDER BY created_at ASC" + ); + return rows.map(readPromptProfile); +} + +/** + * Fetch the system prompt for the profile associated with a chat. + * Returns undefined if no profile is set. + * Intended for use inside mutations (not a hook). + */ +export async function fetchChatPromptProfileSystemPrompt( + chatId: string +): Promise { + const rows = await db.select<{ system_prompt: string }[]>( + `SELECT pp.system_prompt + FROM prompt_profile_chats ppc + JOIN prompt_profiles pp ON pp.id = ppc.prompt_profile_id + WHERE ppc.chat_id = ?`, + [chatId] + ); + return rows.length > 0 ? rows[0].system_prompt : undefined; +} + +/** + * Fetch the prompt profile ID associated with a chat. + */ +async function fetchChatPromptProfileId( + chatId: string +): Promise { + const rows = await db.select<{ prompt_profile_id: string }[]>( + "SELECT prompt_profile_id FROM prompt_profile_chats WHERE chat_id = ?", + [chatId] + ); + return rows.length > 0 ? rows[0].prompt_profile_id : undefined; +} + +export function usePromptProfiles() { + return useQuery({ + queryKey: promptProfileKeys.list(), + queryFn: fetchPromptProfiles, + }); +} + +export function useChatPromptProfileId(chatId: string) { + return useQuery({ + queryKey: promptProfileKeys.chatProfile(chatId), + queryFn: () => fetchChatPromptProfileId(chatId), + }); +} + +/** + * Returns the full PromptProfile for a chat, or undefined if none is set. + */ +export function useChatPromptProfile(chatId: string): PromptProfile | undefined { + const { data: profiles } = usePromptProfiles(); + const { data: profileId } = useChatPromptProfileId(chatId); + if (!profiles || !profileId) return undefined; + return profiles.find((p) => p.id === profileId); +} + +/** + * Set or clear the prompt profile for a chat. + * Pass null to remove the association. + */ +export function useSetChatPromptProfile() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + chatId, + profileId, + }: { + chatId: string; + profileId: string | null; + }) => { + if (profileId) { + await db.execute( + "INSERT OR REPLACE INTO prompt_profile_chats (id, chat_id, prompt_profile_id) VALUES (?, ?, ?)", + [uuidv4(), chatId, profileId] + ); + } else { + await db.execute( + "DELETE FROM prompt_profile_chats WHERE chat_id = ?", + [chatId] + ); + } + }, + onSuccess: async (_data, variables) => { + await queryClient.invalidateQueries({ + queryKey: promptProfileKeys.chatProfile(variables.chatId), + }); + }, + }); +} + +export function useCreatePromptProfile() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + name, + systemPrompt, + icon, + }: { + name: string; + systemPrompt: string; + icon?: string; + }) => { + await db.execute( + "INSERT INTO prompt_profiles (id, name, system_prompt, icon, author) VALUES (?, ?, ?, ?, 'user')", + [uuidv4(), name, systemPrompt, icon ?? null] + ); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: promptProfileKeys.list(), + }); + }, + }); +} + +export function useUpdatePromptProfile() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + id, + name, + systemPrompt, + icon, + }: { + id: string; + name: string; + systemPrompt: string; + icon?: string; + }) => { + await db.execute( + "UPDATE prompt_profiles SET name = ?, system_prompt = ?, icon = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + [name, systemPrompt, icon ?? null, id] + ); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: promptProfileKeys.list(), + }); + }, + }); +} + +export function useDeletePromptProfile() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ id }: { id: string }) => { + await db.execute( + "DELETE FROM prompt_profile_chats WHERE prompt_profile_id = ?", + [id] + ); + await db.execute("DELETE FROM prompt_profiles WHERE id = ?", [id]); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: promptProfileKeys.all(), + }); + }, + }); +} diff --git a/src/core/chorus/prompts/prompts.ts b/src/core/chorus/prompts/prompts.ts index 0c6c0347..8f8ec293 100644 --- a/src/core/chorus/prompts/prompts.ts +++ b/src/core/chorus/prompts/prompts.ts @@ -604,9 +604,15 @@ export function injectSystemPrompts( }[]; isInProject?: boolean; universalSystemPrompt?: string; + promptProfileSystemPrompt?: string; }, ): ModelConfig { - const { toolsetInfo, isInProject, universalSystemPrompt } = options ?? { + const { + toolsetInfo, + isInProject, + universalSystemPrompt, + promptProfileSystemPrompt, + } = options ?? { isInProject: false, }; @@ -615,6 +621,7 @@ export function injectSystemPrompts( systemPrompt: [ CHORUS_SYSTEM_PROMPT, universalSystemPrompt || UNIVERSAL_SYSTEM_PROMPT_DEFAULT, + ...(promptProfileSystemPrompt ? [promptProfileSystemPrompt] : []), ...(toolsetInfo ? [TOOLS_MODE_SYSTEM_PROMPT(toolsetInfo)] : []), ...(isInProject ? [PROJECTS_SYSTEM_PROMPT] : []), ...(modelConfigIn.systemPrompt diff --git a/src/ui/components/ChatInput.tsx b/src/ui/components/ChatInput.tsx index 1892f80c..e5d47cc0 100644 --- a/src/ui/components/ChatInput.tsx +++ b/src/ui/components/ChatInput.tsx @@ -42,6 +42,7 @@ import * as ModelsAPI from "@core/chorus/api/ModelsAPI"; import * as DraftAPI from "@core/chorus/api/DraftAPI"; import * as ModelConfigChatAPI from "@core/chorus/api/ModelConfigChatAPI"; import * as ProjectAPI from "@core/chorus/api/ProjectAPI"; +import { PromptProfilePill } from "./PromptProfilePill"; const DEFAULT_CHAT_INPUT_ID = "default-chat-input"; const REPLY_CHAT_INPUT_ID = "reply-chat-input"; @@ -625,6 +626,7 @@ export function ChatInput({ /> )} {!isReply && } + {!isReply && }
diff --git a/src/ui/components/PromptProfilePill.tsx b/src/ui/components/PromptProfilePill.tsx new file mode 100644 index 00000000..70ad8ab0 --- /dev/null +++ b/src/ui/components/PromptProfilePill.tsx @@ -0,0 +1,117 @@ +import { useState } from "react"; +import { UserCircle, Check, Settings } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; +import { + useChatPromptProfile, + usePromptProfiles, + useSetChatPromptProfile, +} from "@core/chorus/api/PromptProfilesAPI"; +import { dialogActions } from "@core/infra/DialogStore"; +import { SETTINGS_DIALOG_ID } from "./Settings"; + +export function PromptProfilePill({ chatId }: { chatId: string }) { + const [open, setOpen] = useState(false); + const activeProfile = useChatPromptProfile(chatId); + const { data: profiles } = usePromptProfiles(); + const setProfile = useSetChatPromptProfile(); + + const handleSelect = (profileId: string | null) => { + setProfile.mutate({ chatId, profileId }); + setOpen(false); + }; + + const handleManage = () => { + setOpen(false); + dialogActions.openDialog(SETTINGS_DIALOG_ID); + }; + + const trigger = activeProfile ? ( + + ) : ( + + + + + Set prompt profile + + ); + + return ( + + {trigger} + +
+
+ Prompt Profile +
+ + {/* None option */} + + + {profiles?.map((p) => ( + + ))} + +
+ +
+
+
+
+ ); +} diff --git a/src/ui/components/PromptProfilesTab.tsx b/src/ui/components/PromptProfilesTab.tsx new file mode 100644 index 00000000..1e5cd087 --- /dev/null +++ b/src/ui/components/PromptProfilesTab.tsx @@ -0,0 +1,235 @@ +import { useState } from "react"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { Textarea } from "./ui/textarea"; +import { + usePromptProfiles, + useCreatePromptProfile, + useUpdatePromptProfile, + useDeletePromptProfile, +} from "@core/chorus/api/PromptProfilesAPI"; +import { PromptProfile } from "@core/chorus/Models"; +import { Loader2, Plus, Trash2, Pencil, Check, X } from "lucide-react"; + +function EditProfileForm({ + profile, + onSave, + onCancel, +}: { + profile: PromptProfile; + onSave: (name: string, systemPrompt: string, icon: string) => void; + onCancel: () => void; +}) { + const [name, setName] = useState(profile.name); + const [systemPrompt, setSystemPrompt] = useState(profile.systemPrompt); + const [icon, setIcon] = useState(profile.icon ?? ""); + + return ( +
+
+ setIcon(e.target.value)} + className="w-24 flex-shrink-0" + /> + setName(e.target.value)} + className="flex-1" + /> +
+