feat: add zen model selection to settings (#415)
This commit is contained in:
@@ -11,6 +11,11 @@ import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { getModifierLabel } from '@/lib/utils';
|
||||
|
||||
interface ZenModel {
|
||||
id: string;
|
||||
owned_by?: string;
|
||||
}
|
||||
|
||||
const FALLBACK_PROVIDER_ID = 'opencode';
|
||||
const FALLBACK_MODEL_ID = 'big-pickle';
|
||||
|
||||
@@ -48,12 +53,16 @@ export const DefaultsSettings: React.FC = () => {
|
||||
const setSettingsDefaultAgent = useConfigStore((state) => state.setSettingsDefaultAgent);
|
||||
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
|
||||
const setSettingsAutoCreateWorktree = useConfigStore((state) => state.setSettingsAutoCreateWorktree);
|
||||
const settingsZenModel = useConfigStore((state) => state.settingsZenModel);
|
||||
const setSettingsZenModel = useConfigStore((state) => state.setSettingsZenModel);
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
|
||||
const [defaultModel, setDefaultModel] = React.useState<string | undefined>();
|
||||
const [defaultVariant, setDefaultVariant] = React.useState<string | undefined>();
|
||||
const [defaultAgent, setDefaultAgent] = React.useState<string | undefined>();
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const [zenModels, setZenModels] = React.useState<ZenModel[]>([]);
|
||||
const [zenModelsLoading, setZenModelsLoading] = React.useState(true);
|
||||
|
||||
const parsedModel = React.useMemo(() => {
|
||||
return getDisplayModel(defaultModel, providers);
|
||||
@@ -61,11 +70,43 @@ export const DefaultsSettings: React.FC = () => {
|
||||
|
||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
|
||||
// Load zen models list
|
||||
React.useEffect(() => {
|
||||
const loadZenModels = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/zen/models', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { models?: ZenModel[] };
|
||||
if (Array.isArray(data?.models)) {
|
||||
setZenModels(data.models);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load zen models:', error);
|
||||
} finally {
|
||||
setZenModelsLoading(false);
|
||||
}
|
||||
};
|
||||
loadZenModels();
|
||||
}, []);
|
||||
|
||||
// Resolve which zen model to display as selected
|
||||
const selectedZenModel = React.useMemo(() => {
|
||||
if (settingsZenModel && zenModels.some((m) => m.id === settingsZenModel)) {
|
||||
return settingsZenModel;
|
||||
}
|
||||
// Default to first free model in the list
|
||||
return zenModels[0]?.id ?? '';
|
||||
}, [settingsZenModel, zenModels]);
|
||||
|
||||
// Load current settings
|
||||
React.useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
let data: { defaultModel?: string; defaultVariant?: string; defaultAgent?: string } | null = null;
|
||||
let data: { defaultModel?: string; defaultVariant?: string; defaultAgent?: string; zenModel?: string } | null = null;
|
||||
|
||||
// 1. Runtime settings API (VSCode)
|
||||
if (!data) {
|
||||
@@ -79,6 +120,7 @@ export const DefaultsSettings: React.FC = () => {
|
||||
defaultModel: typeof settings.defaultModel === 'string' ? settings.defaultModel : undefined,
|
||||
defaultVariant: typeof (settings as Record<string, unknown>).defaultVariant === 'string' ? ((settings as Record<string, unknown>).defaultVariant as string) : undefined,
|
||||
defaultAgent: typeof settings.defaultAgent === 'string' ? settings.defaultAgent : undefined,
|
||||
zenModel: typeof (settings as Record<string, unknown>).zenModel === 'string' ? ((settings as Record<string, unknown>).zenModel as string) : undefined,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
@@ -102,6 +144,7 @@ export const DefaultsSettings: React.FC = () => {
|
||||
const model = typeof data.defaultModel === 'string' && data.defaultModel.trim().length > 0 ? data.defaultModel.trim() : undefined;
|
||||
const variant = typeof data.defaultVariant === 'string' && data.defaultVariant.trim().length > 0 ? data.defaultVariant.trim() : undefined;
|
||||
const agent = typeof data.defaultAgent === 'string' && data.defaultAgent.trim().length > 0 ? data.defaultAgent.trim() : undefined;
|
||||
const zen = typeof data.zenModel === 'string' && data.zenModel.trim().length > 0 ? data.zenModel.trim() : undefined;
|
||||
|
||||
if (model !== undefined) {
|
||||
setDefaultModel(model);
|
||||
@@ -112,6 +155,9 @@ export const DefaultsSettings: React.FC = () => {
|
||||
if (agent !== undefined) {
|
||||
setDefaultAgent(agent);
|
||||
}
|
||||
if (zen !== undefined) {
|
||||
setSettingsZenModel(zen);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load defaults settings:', error);
|
||||
@@ -120,7 +166,7 @@ export const DefaultsSettings: React.FC = () => {
|
||||
}
|
||||
};
|
||||
loadSettings();
|
||||
}, []);
|
||||
}, [setSettingsZenModel]);
|
||||
|
||||
|
||||
const handleModelChange = React.useCallback(async (providerId: string, modelId: string) => {
|
||||
@@ -241,6 +287,16 @@ export const DefaultsSettings: React.FC = () => {
|
||||
}
|
||||
}, [setSettingsAutoCreateWorktree]);
|
||||
|
||||
const handleZenModelChange = React.useCallback(async (modelId: string) => {
|
||||
setSettingsZenModel(modelId);
|
||||
try {
|
||||
await updateDesktopSettings({
|
||||
zenModel: modelId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Failed to save zen model setting:', error);
|
||||
}
|
||||
}, [setSettingsZenModel]);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
@@ -333,6 +389,47 @@ export const DefaultsSettings: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border/40 pt-4 mt-4 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">Zen Model</h3>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
The free model used for lightweight internal tasks like commit message generation, PR descriptions, notification summarization, and TTS text summarization.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Used for commit messages, PR descriptions, and text summarization.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="typography-ui-label text-muted-foreground">Model</label>
|
||||
{zenModelsLoading ? (
|
||||
<span className="typography-meta text-muted-foreground">Loading models...</span>
|
||||
) : zenModels.length > 0 ? (
|
||||
<Select value={selectedZenModel} onValueChange={handleZenModelChange}>
|
||||
<SelectTrigger className="w-auto max-w-xs typography-meta text-foreground">
|
||||
<SelectValue placeholder="Select model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{zenModels.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id} className="pr-2 [&>span:first-child]:hidden">
|
||||
{model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<span className="typography-meta text-muted-foreground">No free models available</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [
|
||||
{
|
||||
id: 'sessions',
|
||||
label: 'Sessions',
|
||||
items: ['Defaults', 'Retention'],
|
||||
items: ['Defaults', 'Zen Model', 'Retention'],
|
||||
},
|
||||
{
|
||||
id: 'git',
|
||||
|
||||
@@ -839,9 +839,11 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
|
||||
setIsGeneratingMessage(true);
|
||||
try {
|
||||
const zenModel = useConfigStore.getState().settingsZenModel;
|
||||
const { message } = await git.generateCommitMessage(
|
||||
currentDirectory,
|
||||
Array.from(selectedPaths)
|
||||
Array.from(selectedPaths),
|
||||
zenModel ? { zenModel } : undefined
|
||||
);
|
||||
const subject = message.subject?.trim() ?? '';
|
||||
const highlights = Array.isArray(message.highlights) ? message.highlights : [];
|
||||
|
||||
@@ -951,10 +951,12 @@ export const PullRequestSection: React.FC<{
|
||||
if (!directory) return;
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const zenModel = useConfigStore.getState().settingsZenModel;
|
||||
const generated = await generatePullRequestDescription(directory, {
|
||||
base: baseBranch,
|
||||
head: branch,
|
||||
context: additionalContext,
|
||||
...(zenModel ? { zenModel } : {}),
|
||||
});
|
||||
|
||||
if (generated.title?.trim()) {
|
||||
|
||||
@@ -78,7 +78,7 @@ export function useServerTTS(): UseServerTTSReturn {
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Get current model, threshold, and max length from config store for summarization
|
||||
const { currentProviderId, currentModelId, summarizeCharacterThreshold, summarizeMaxLength, openaiApiKey } = useConfigStore();
|
||||
const { currentProviderId, currentModelId, summarizeCharacterThreshold, summarizeMaxLength, openaiApiKey, settingsZenModel } = useConfigStore();
|
||||
|
||||
// Check if server TTS is available
|
||||
const checkAvailability = useCallback(async (): Promise<boolean> => {
|
||||
@@ -209,6 +209,7 @@ export function useServerTTS(): UseServerTTSReturn {
|
||||
maxLength: summarizeMaxLength ?? 500,
|
||||
// Send API key from settings if available
|
||||
apiKey: openaiApiKey || undefined,
|
||||
...(settingsZenModel ? { zenModel: settingsZenModel } : {}),
|
||||
}),
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
@@ -258,7 +259,7 @@ export function useServerTTS(): UseServerTTSReturn {
|
||||
options?.onError?.(errorMsg);
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}, [stop, currentProviderId, currentModelId, summarizeCharacterThreshold, summarizeMaxLength, openaiApiKey]);
|
||||
}, [stop, currentProviderId, currentModelId, summarizeCharacterThreshold, summarizeMaxLength, openaiApiKey, settingsZenModel]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
|
||||
@@ -332,10 +332,10 @@ export interface GitAPI {
|
||||
getGitBranches(directory: string): Promise<GitBranch>;
|
||||
deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }>;
|
||||
deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }>;
|
||||
generateCommitMessage(directory: string, files: string[]): Promise<{ message: GeneratedCommitMessage }>;
|
||||
generateCommitMessage(directory: string, files: string[], options?: { zenModel?: string }): Promise<{ message: GeneratedCommitMessage }>;
|
||||
generatePullRequestDescription(
|
||||
directory: string,
|
||||
payload: { base: string; head: string; context?: string }
|
||||
payload: { base: string; head: string; context?: string; zenModel?: string }
|
||||
): Promise<GeneratedPullRequestDescription>;
|
||||
listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]>;
|
||||
createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions): Promise<GitCommitResult>;
|
||||
|
||||
@@ -91,6 +91,7 @@ export type DesktopSettings = {
|
||||
autoCreateWorktree?: boolean;
|
||||
queueModeEnabled?: boolean;
|
||||
gitmojiEnabled?: boolean;
|
||||
zenModel?: string;
|
||||
toolCallExpansion?: 'collapsed' | 'activity' | 'detailed';
|
||||
fontSize?: number;
|
||||
terminalFontSize?: number;
|
||||
|
||||
@@ -99,16 +99,17 @@ export async function deleteRemoteBranch(directory: string, payload: import('./a
|
||||
|
||||
export async function generateCommitMessage(
|
||||
directory: string,
|
||||
files: string[]
|
||||
files: string[],
|
||||
options?: { zenModel?: string }
|
||||
): Promise<{ message: import('./api/types').GeneratedCommitMessage }> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.generateCommitMessage(directory, files);
|
||||
return gitHttp.generateCommitMessage(directory, files);
|
||||
return gitHttp.generateCommitMessage(directory, files, options);
|
||||
}
|
||||
|
||||
export async function generatePullRequestDescription(
|
||||
directory: string,
|
||||
payload: { base: string; head: string; context?: string }
|
||||
payload: { base: string; head: string; context?: string; zenModel?: string }
|
||||
): Promise<import('./api/types').GeneratedPullRequestDescription> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime?.generatePullRequestDescription) {
|
||||
|
||||
@@ -205,16 +205,22 @@ export async function deleteRemoteBranch(directory: string, payload: GitDeleteRe
|
||||
|
||||
export async function generateCommitMessage(
|
||||
directory: string,
|
||||
files: string[]
|
||||
files: string[],
|
||||
options?: { zenModel?: string }
|
||||
): Promise<{ message: GeneratedCommitMessage }> {
|
||||
if (!Array.isArray(files) || files.length === 0) {
|
||||
throw new Error('No files provided to generate commit message');
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = { files };
|
||||
if (options?.zenModel) {
|
||||
body.zenModel = options.zenModel;
|
||||
}
|
||||
|
||||
const response = await fetch(buildUrl(`${API_BASE}/commit-message`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ files }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -249,17 +255,20 @@ export async function generateCommitMessage(
|
||||
|
||||
export async function generatePullRequestDescription(
|
||||
directory: string,
|
||||
payload: { base: string; head: string; context?: string }
|
||||
payload: { base: string; head: string; context?: string; zenModel?: string }
|
||||
): Promise<{ title: string; body: string }> {
|
||||
const { base, head, context } = payload;
|
||||
const { base, head, context, zenModel } = payload;
|
||||
if (!base || !head) {
|
||||
throw new Error('base and head are required');
|
||||
}
|
||||
|
||||
const requestBody: { base: string; head: string; context?: string } = { base, head };
|
||||
const requestBody: { base: string; head: string; context?: string; zenModel?: string } = { base, head };
|
||||
if (context?.trim()) {
|
||||
requestBody.context = context.trim();
|
||||
}
|
||||
if (zenModel) {
|
||||
requestBody.zenModel = zenModel;
|
||||
}
|
||||
|
||||
const response = await fetch(buildUrl(`${API_BASE}/pr-description`, directory), {
|
||||
method: 'POST',
|
||||
|
||||
@@ -33,12 +33,13 @@ export async function summarizeText(
|
||||
}
|
||||
|
||||
try {
|
||||
const zenModel = store.settingsZenModel;
|
||||
const response = await fetch('/api/tts/summarize', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ text, threshold, maxLength }),
|
||||
body: JSON.stringify({ text, threshold, maxLength, ...(zenModel ? { zenModel } : {}) }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -25,6 +25,7 @@ interface OpenChamberDefaults {
|
||||
defaultAgent?: string;
|
||||
autoCreateWorktree?: boolean;
|
||||
gitmojiEnabled?: boolean;
|
||||
zenModel?: string;
|
||||
}
|
||||
|
||||
const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
||||
@@ -40,6 +41,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
||||
const defaultVariant = typeof data?.defaultVariant === 'string' ? data.defaultVariant.trim() : '';
|
||||
const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : '';
|
||||
const gitmojiEnabled = typeof data?.gitmojiEnabled === 'boolean' ? data.gitmojiEnabled : undefined;
|
||||
const zenModel = typeof data?.zenModel === 'string' ? data.zenModel.trim() : '';
|
||||
|
||||
return {
|
||||
defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
|
||||
@@ -47,6 +49,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
||||
defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined,
|
||||
autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined,
|
||||
gitmojiEnabled,
|
||||
zenModel: zenModel.length > 0 ? zenModel : undefined,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
@@ -67,6 +70,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
||||
const defaultVariant = typeof data?.defaultVariant === 'string' ? data.defaultVariant.trim() : '';
|
||||
const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : '';
|
||||
const gitmojiEnabled = typeof data?.gitmojiEnabled === 'boolean' ? data.gitmojiEnabled : undefined;
|
||||
const zenModel = typeof data?.zenModel === 'string' ? data.zenModel.trim() : '';
|
||||
|
||||
return {
|
||||
defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
|
||||
@@ -74,6 +78,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
||||
defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined,
|
||||
autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined,
|
||||
gitmojiEnabled,
|
||||
zenModel: zenModel.length > 0 ? zenModel : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
@@ -366,6 +371,7 @@ interface ConfigStore {
|
||||
settingsDefaultAgent: string | undefined;
|
||||
settingsAutoCreateWorktree: boolean;
|
||||
settingsGitmojiEnabled: boolean;
|
||||
settingsZenModel: string | undefined;
|
||||
// Voice provider preference ('browser', 'openai', or 'say' for macOS)
|
||||
voiceProvider: 'browser' | 'openai' | 'say';
|
||||
setVoiceProvider: (provider: 'browser' | 'openai' | 'say') => void;
|
||||
@@ -414,6 +420,7 @@ interface ConfigStore {
|
||||
setSettingsDefaultAgent: (agent: string | undefined) => void;
|
||||
setSettingsAutoCreateWorktree: (enabled: boolean) => void;
|
||||
setSettingsGitmojiEnabled: (enabled: boolean) => void;
|
||||
setSettingsZenModel: (model: string | undefined) => void;
|
||||
saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => void;
|
||||
getAgentModelSelection: (agentName: string) => { providerId: string; modelId: string } | null;
|
||||
checkConnection: () => Promise<boolean>;
|
||||
@@ -458,6 +465,7 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
settingsDefaultAgent: undefined,
|
||||
settingsAutoCreateWorktree: false,
|
||||
settingsGitmojiEnabled: false,
|
||||
settingsZenModel: undefined,
|
||||
// Voice provider preference - load from localStorage or default to 'browser'
|
||||
voiceProvider: (() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -1023,6 +1031,7 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
settingsDefaultAgent: openChamberDefaults.defaultAgent,
|
||||
settingsAutoCreateWorktree: openChamberDefaults.autoCreateWorktree ?? false,
|
||||
settingsGitmojiEnabled: openChamberDefaults.gitmojiEnabled ?? false,
|
||||
settingsZenModel: openChamberDefaults.zenModel,
|
||||
directoryScoped: {
|
||||
...state.directoryScoped,
|
||||
[directoryKey]: nextSnapshot,
|
||||
@@ -1447,6 +1456,10 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
set({ settingsGitmojiEnabled: enabled });
|
||||
},
|
||||
|
||||
setSettingsZenModel: (model: string | undefined) => {
|
||||
set({ settingsZenModel: model });
|
||||
},
|
||||
|
||||
setVoiceProvider: (provider: 'browser' | 'openai' | 'say') => {
|
||||
set({ voiceProvider: provider });
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -1656,6 +1669,7 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
settingsDefaultAgent: state.settingsDefaultAgent,
|
||||
settingsAutoCreateWorktree: state.settingsAutoCreateWorktree,
|
||||
settingsGitmojiEnabled: state.settingsGitmojiEnabled,
|
||||
settingsZenModel: state.settingsZenModel,
|
||||
speechRate: state.speechRate,
|
||||
speechPitch: state.speechPitch,
|
||||
speechVolume: state.speechVolume,
|
||||
|
||||
Reference in New Issue
Block a user