diff --git a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx index 281db095..b2d1c3fd 100644 --- a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx @@ -12,10 +12,8 @@ import { useUIStore } from '@/stores/useUIStore'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { getModifierLabel, cn } from '@/lib/utils'; -interface ZenModel { - id: string; - owned_by?: string; -} +const UTILITY_PROVIDER_ID = 'zen'; +const UTILITY_PREFERRED_MODEL_ID = 'big-pickle'; const getDisplayModel = ( storedModel: string | undefined @@ -26,9 +24,46 @@ const getDisplayModel = ( return { providerId: parts[0], modelId: parts[1] }; } } - - // Return empty values when no model is explicitly set - // This allows showing "Not selected" instead of a fallback + + return { providerId: '', modelId: '' }; +}; + +const getUtilityDisplayModel = ( + storedGitProviderId: string | undefined, + storedGitModelId: string | undefined, + zenModel: string | undefined, + providers: Array<{ id: string; models: Array<{ id: string }> }> +): { providerId: string; modelId: string } => { + if (storedGitProviderId && storedGitModelId) { + const provider = providers.find((p) => p.id === storedGitProviderId); + if (provider?.models.some((m) => m.id === storedGitModelId)) { + return { providerId: storedGitProviderId, modelId: storedGitModelId }; + } + } + + const utilityProvider = providers.find((p) => p.id === UTILITY_PROVIDER_ID); + if (zenModel && utilityProvider?.models.some((m) => m.id === zenModel)) { + return { providerId: UTILITY_PROVIDER_ID, modelId: zenModel }; + } + + const preferredUtilityModel = utilityProvider?.models.find((m) => m.id === UTILITY_PREFERRED_MODEL_ID); + if (preferredUtilityModel) { + return { providerId: UTILITY_PROVIDER_ID, modelId: preferredUtilityModel.id }; + } + + if (utilityProvider?.models.length) { + const randomIndex = Math.floor(Math.random() * utilityProvider.models.length); + const randomModel = utilityProvider.models[randomIndex]; + if (randomModel?.id) { + return { providerId: UTILITY_PROVIDER_ID, modelId: randomModel.id }; + } + } + + const firstProvider = providers[0]; + if (firstProvider?.models[0]) { + return { providerId: firstProvider.id, modelId: firstProvider.models[0].id }; + } + return { providerId: '', modelId: '' }; }; @@ -44,6 +79,10 @@ export const DefaultsSettings: React.FC = () => { const setSettingsAutoCreateWorktree = useConfigStore((state) => state.setSettingsAutoCreateWorktree); const settingsZenModel = useConfigStore((state) => state.settingsZenModel); const setSettingsZenModel = useConfigStore((state) => state.setSettingsZenModel); + const settingsGitProviderId = useConfigStore((state) => state.settingsGitProviderId); + const settingsGitModelId = useConfigStore((state) => state.settingsGitModelId); + const setSettingsGitProviderId = useConfigStore((state) => state.setSettingsGitProviderId); + const setSettingsGitModelId = useConfigStore((state) => state.setSettingsGitModelId); const showDeletionDialog = useUIStore((state) => state.showDeletionDialog); const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog); const providers = useConfigStore((state) => state.providers); @@ -52,54 +91,25 @@ export const DefaultsSettings: React.FC = () => { const [defaultVariant, setDefaultVariant] = React.useState(); const [defaultAgent, setDefaultAgent] = React.useState(); const [isLoading, setIsLoading] = React.useState(true); - const [zenModels, setZenModels] = React.useState([]); - const [zenModelsLoading, setZenModelsLoading] = React.useState(true); - - const parsedModel = React.useMemo(() => { - return getDisplayModel(defaultModel); - }, [defaultModel]); + const parsedModel = React.useMemo(() => getDisplayModel(defaultModel), [defaultModel]); + const utilityDisplayModel = React.useMemo(() => { + return getUtilityDisplayModel(settingsGitProviderId, settingsGitModelId, settingsZenModel, providers); + }, [settingsGitProviderId, settingsGitModelId, settingsZenModel, providers]); 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; zenModel?: string } | null = null; + let data: { + defaultModel?: string; + defaultVariant?: string; + defaultAgent?: string; + zenModel?: string; + gitProviderId?: string; + gitModelId?: string; + } | null = null; - // 1. Runtime settings API (VSCode) if (!data) { const runtimeSettings = getRegisteredRuntimeAPIs()?.settings; if (runtimeSettings) { @@ -109,9 +119,23 @@ export const DefaultsSettings: React.FC = () => { if (settings) { data = { defaultModel: typeof settings.defaultModel === 'string' ? settings.defaultModel : undefined, - defaultVariant: typeof (settings as Record).defaultVariant === 'string' ? ((settings as Record).defaultVariant as string) : undefined, + defaultVariant: + typeof (settings as Record).defaultVariant === 'string' + ? ((settings as Record).defaultVariant as string) + : undefined, defaultAgent: typeof settings.defaultAgent === 'string' ? settings.defaultAgent : undefined, - zenModel: typeof (settings as Record).zenModel === 'string' ? ((settings as Record).zenModel as string) : undefined, + zenModel: + typeof (settings as Record).zenModel === 'string' + ? ((settings as Record).zenModel as string) + : undefined, + gitProviderId: + typeof (settings as Record).gitProviderId === 'string' + ? ((settings as Record).gitProviderId as string) + : undefined, + gitModelId: + typeof (settings as Record).gitModelId === 'string' + ? ((settings as Record).gitModelId as string) + : undefined, }; } } catch { @@ -120,7 +144,6 @@ export const DefaultsSettings: React.FC = () => { } } - // 2. Fetch API (Web/server) if (!data) { const response = await fetch('/api/config/settings', { method: 'GET', @@ -131,25 +154,39 @@ export const DefaultsSettings: React.FC = () => { } } - if (data) { - 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 (data) { + 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; + const gitProviderId = + typeof data.gitProviderId === 'string' && data.gitProviderId.trim().length > 0 + ? data.gitProviderId.trim() + : undefined; + const gitModelId = + typeof data.gitModelId === 'string' && data.gitModelId.trim().length > 0 + ? data.gitModelId.trim() + : undefined; - if (model !== undefined) { - setDefaultModel(model); - } - if (variant !== undefined) { - setDefaultVariant(variant); - } - if (agent !== undefined) { - setDefaultAgent(agent); - } - if (zen !== undefined) { - setSettingsZenModel(zen); - } - } + if (model !== undefined) setDefaultModel(model); + if (variant !== undefined) setDefaultVariant(variant); + if (agent !== undefined) setDefaultAgent(agent); + if (zen !== undefined) setSettingsZenModel(zen); + if (gitProviderId !== undefined) setSettingsGitProviderId(gitProviderId); + if (gitModelId !== undefined) setSettingsGitModelId(gitModelId); + } } catch (error) { console.warn('Failed to load defaults settings:', error); } finally { @@ -157,88 +194,78 @@ export const DefaultsSettings: React.FC = () => { } }; loadSettings(); - }, [setSettingsZenModel]); + }, [setSettingsGitModelId, setSettingsGitProviderId, setSettingsZenModel]); + const handleModelChange = React.useCallback( + async (providerId: string, modelId: string) => { + const newValue = providerId && modelId ? `${providerId}/${modelId}` : undefined; + setDefaultModel(newValue); + setDefaultVariant(undefined); + setSettingsDefaultVariant(undefined); + setCurrentVariant(undefined); + setSettingsDefaultModel(newValue); - const handleModelChange = React.useCallback(async (providerId: string, modelId: string) => { - const newValue = providerId && modelId ? `${providerId}/${modelId}` : undefined; - setDefaultModel(newValue); - - // Reset variant when model changes (model-specific) - setDefaultVariant(undefined); - setSettingsDefaultVariant(undefined); - setCurrentVariant(undefined); - - // Update config store settings default (used by setAgent logic) - setSettingsDefaultModel(newValue); - - // Also update current model immediately so new sessions use this model - if (providerId && modelId) { - const provider = providers.find((p) => p.id === providerId); - if (provider) { - setProvider(providerId); - setModel(modelId); + if (providerId && modelId) { + const provider = providers.find((p) => p.id === providerId); + if (provider) { + setProvider(providerId); + setModel(modelId); + } } - } - try { - await updateDesktopSettings({ - defaultModel: newValue ?? '', - defaultVariant: '', - }); - - { - const response = await fetch('/api/config/settings', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ defaultModel: newValue }), - }); - if (!response.ok) { - console.warn('Failed to save default model to server:', response.status, response.statusText); - } - } - } catch (error) { - console.warn('Failed to save default model:', error); - } - }, [providers, setCurrentVariant, setProvider, setModel, setSettingsDefaultModel, setSettingsDefaultVariant]); + try { + await updateDesktopSettings({ defaultModel: newValue ?? '', defaultVariant: '' }); + const response = await fetch('/api/config/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ defaultModel: newValue }), + }); + if (!response.ok) { + console.warn('Failed to save default model to server:', response.status, response.statusText); + } + } catch (error) { + console.warn('Failed to save default model:', error); + } + }, + [providers, setCurrentVariant, setModel, setProvider, setSettingsDefaultModel, setSettingsDefaultVariant] + ); const DEFAULT_VARIANT_VALUE = '__default__'; - const handleVariantChange = React.useCallback(async (variant: string) => { - const newValue = variant === DEFAULT_VARIANT_VALUE ? undefined : (variant || undefined); - setDefaultVariant(newValue); - setSettingsDefaultVariant(newValue); - setCurrentVariant(newValue); + const handleVariantChange = React.useCallback( + async (variant: string) => { + const newValue = variant === DEFAULT_VARIANT_VALUE ? undefined : variant || undefined; + setDefaultVariant(newValue); + setSettingsDefaultVariant(newValue); + setCurrentVariant(newValue); - try { - await updateDesktopSettings({ - defaultVariant: newValue ?? '', - }); - } catch (error) { - console.warn('Failed to save default variant:', error); - } - }, [setCurrentVariant, setSettingsDefaultVariant]); + try { + await updateDesktopSettings({ defaultVariant: newValue ?? '' }); + } catch (error) { + console.warn('Failed to save default variant:', error); + } + }, + [setCurrentVariant, setSettingsDefaultVariant] + ); - const handleAgentChange = React.useCallback(async (agentName: string) => { - const newValue = agentName || undefined; - setDefaultAgent(newValue); + const handleAgentChange = React.useCallback( + async (agentName: string) => { + const newValue = agentName || undefined; + setDefaultAgent(newValue); + setSettingsDefaultAgent(newValue); - // Update config store settings default - setSettingsDefaultAgent(newValue); + if (agentName) { + setAgent(agentName); + } - // Update current agent (setAgent will respect settingsDefaultModel) - if (agentName) { - setAgent(agentName); - } - - try { - await updateDesktopSettings({ - defaultAgent: newValue ?? '', - }); - } catch (error) { - console.warn('Failed to save default agent:', error); - } - }, [setAgent, setSettingsDefaultAgent]); + try { + await updateDesktopSettings({ defaultAgent: newValue ?? '' }); + } catch (error) { + console.warn('Failed to save default agent:', error); + } + }, + [setAgent, setSettingsDefaultAgent] + ); const availableVariants = React.useMemo(() => { if (!parsedModel.providerId || !parsedModel.modelId) return []; @@ -247,9 +274,7 @@ export const DefaultsSettings: React.FC = () => { | { variants?: Record } | undefined; const variants = model?.variants; - if (!variants) { - return []; - } + if (!variants) return []; return Object.keys(variants); }, [parsedModel.modelId, parsedModel.providerId, providers]); @@ -266,27 +291,37 @@ export const DefaultsSettings: React.FC = () => { } }, [defaultVariant, setCurrentVariant, setSettingsDefaultVariant, supportsVariants]); - const handleAutoWorktreeChange = React.useCallback(async (enabled: boolean) => { - setSettingsAutoCreateWorktree(enabled); - try { - await updateDesktopSettings({ - autoCreateWorktree: enabled, - }); - } catch (error) { - console.warn('Failed to save auto create worktree setting:', error); - } - }, [setSettingsAutoCreateWorktree]); + const handleAutoWorktreeChange = React.useCallback( + async (enabled: boolean) => { + setSettingsAutoCreateWorktree(enabled); + try { + await updateDesktopSettings({ autoCreateWorktree: enabled }); + } catch (error) { + console.warn('Failed to save auto create worktree setting:', error); + } + }, + [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]); + const handleUtilityModelChange = React.useCallback( + async (providerId: string, modelId: string) => { + setSettingsGitProviderId(providerId); + setSettingsGitModelId(modelId); + if (providerId === UTILITY_PROVIDER_ID) { + setSettingsZenModel(modelId); + } + try { + await updateDesktopSettings({ + gitProviderId: providerId, + gitModelId: modelId, + ...(providerId === UTILITY_PROVIDER_ID ? { zenModel: modelId } : {}), + }); + } catch (error) { + console.warn('Failed to save utility model setting:', error); + } + }, + [setSettingsGitModelId, setSettingsGitProviderId, setSettingsZenModel] + ); if (isLoading) { return null; @@ -319,85 +354,65 @@ export const DefaultsSettings: React.FC = () => { )} -
+
Default Model
- +
-
-
- Default Thinking -
-
- -
+
+
+ Default Thinking
+
+ +
+
Default Agent
- +
- Zen Model + Utility Model - The free model used for lightweight internal tasks like commit message generation, PR descriptions, notification summarization, and TTS text summarization. + The model used for lightweight background tasks like commit messages, PR descriptions, and summarization.
-
- {zenModelsLoading ? ( - Loading models... - ) : zenModels.length > 0 ? ( - - ) : ( - No free models available - )} +
+
@@ -414,11 +429,7 @@ export const DefaultsSettings: React.FC = () => { } }} > - + Show Deletion Dialog
@@ -462,9 +473,7 @@ export const DefaultsSettings: React.FC = () => {
)} - - ); }; diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 104b4d8c..7b702a9a 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -818,11 +818,22 @@ export const GitView: React.FC = ({ mode = 'full' }) => { setIsGeneratingMessage(true); try { - const zenModel = useConfigStore.getState().settingsZenModel; + const { getResolvedGitGenerationModel, settingsZenModel } = useConfigStore.getState(); + const resolvedModel = getResolvedGitGenerationModel(); + const options: { zenModel?: string; providerId?: string; modelId?: string } = {}; + if (resolvedModel) { + options.providerId = resolvedModel.providerId; + options.modelId = resolvedModel.modelId; + if (resolvedModel.providerId === 'zen') { + options.zenModel = resolvedModel.modelId; + } + } else if (settingsZenModel) { + options.zenModel = settingsZenModel; + } const { message } = await git.generateCommitMessage( currentDirectory, Array.from(selectedPaths), - zenModel ? { zenModel } : undefined + Object.keys(options).length > 0 ? options : undefined ); const subject = message.subject?.trim() ?? ''; const highlights = Array.isArray(message.highlights) ? message.highlights : []; diff --git a/packages/ui/src/components/views/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx index 56d43d47..0e52f450 100644 --- a/packages/ui/src/components/views/git/PullRequestSection.tsx +++ b/packages/ui/src/components/views/git/PullRequestSection.tsx @@ -1128,13 +1128,25 @@ export const PullRequestSection: React.FC<{ if (!directory) return; setIsGenerating(true); try { - const zenModel = useConfigStore.getState().settingsZenModel; - const generated = await generatePullRequestDescription(directory, { + const { getResolvedGitGenerationModel, settingsZenModel } = useConfigStore.getState(); + const resolvedModel = getResolvedGitGenerationModel(); + const payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string } = { base: targetBaseBranch, head: branch, - context: additionalContext, - ...(zenModel ? { zenModel } : {}), - }); + }; + if (additionalContext) { + payload.context = additionalContext; + } + if (resolvedModel) { + payload.providerId = resolvedModel.providerId; + payload.modelId = resolvedModel.modelId; + if (resolvedModel.providerId === 'zen') { + payload.zenModel = resolvedModel.modelId; + } + } else if (settingsZenModel) { + payload.zenModel = settingsZenModel; + } + const generated = await generatePullRequestDescription(directory, payload); if (generated.title?.trim()) { setTitle(generated.title.trim()); diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 23f486ad..77db5558 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -390,10 +390,10 @@ export interface GitAPI { getGitBranches(directory: string): Promise; deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }>; deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }>; - generateCommitMessage(directory: string, files: string[], options?: { zenModel?: string }): Promise<{ message: GeneratedCommitMessage }>; + generateCommitMessage(directory: string, files: string[], options?: { zenModel?: string; providerId?: string; modelId?: string }): Promise<{ message: GeneratedCommitMessage }>; generatePullRequestDescription( directory: string, - payload: { base: string; head: string; context?: string; zenModel?: string } + payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string } ): Promise; listGitWorktrees(directory: string): Promise; validateGitWorktree?(directory: string, payload: CreateGitWorktreePayload): Promise; @@ -529,6 +529,8 @@ export interface SettingsPayload { directoryShowHidden?: boolean; filesViewShowGitignored?: boolean; openInAppId?: string; + gitProviderId?: string; + gitModelId?: string; [key: string]: unknown; } diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index f9953467..342c863b 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -93,6 +93,8 @@ export type DesktopSettings = { queueModeEnabled?: boolean; gitmojiEnabled?: boolean; zenModel?: string; + gitProviderId?: string; + gitModelId?: string; toolCallExpansion?: 'collapsed' | 'activity' | 'detailed'; fontSize?: number; terminalFontSize?: number; diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index 24302e57..fc7963a8 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -105,16 +105,16 @@ export async function deleteRemoteBranch(directory: string, payload: import('./a export async function generateCommitMessage( directory: string, files: string[], - options?: { zenModel?: string } + options?: { zenModel?: string; providerId?: string; modelId?: string } ): Promise<{ message: import('./api/types').GeneratedCommitMessage }> { const runtime = getRuntimeGit(); - if (runtime) return runtime.generateCommitMessage(directory, files); + if (runtime) return runtime.generateCommitMessage(directory, files, options); return gitHttp.generateCommitMessage(directory, files, options); } export async function generatePullRequestDescription( directory: string, - payload: { base: string; head: string; context?: string; zenModel?: string } + payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string } ): Promise { const runtime = getRuntimeGit(); if (runtime?.generatePullRequestDescription) { diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index 2876aba4..c051ef0d 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -210,7 +210,7 @@ export async function deleteRemoteBranch(directory: string, payload: GitDeleteRe export async function generateCommitMessage( directory: string, files: string[], - options?: { zenModel?: string } + options?: { zenModel?: string; providerId?: string; modelId?: string } ): Promise<{ message: GeneratedCommitMessage }> { if (!Array.isArray(files) || files.length === 0) { throw new Error('No files provided to generate commit message'); @@ -220,6 +220,12 @@ export async function generateCommitMessage( if (options?.zenModel) { body.zenModel = options.zenModel; } + if (options?.providerId) { + body.providerId = options.providerId; + } + if (options?.modelId) { + body.modelId = options.modelId; + } const response = await fetch(buildUrl(`${API_BASE}/commit-message`, directory), { method: 'POST', @@ -259,20 +265,26 @@ export async function generateCommitMessage( export async function generatePullRequestDescription( directory: string, - payload: { base: string; head: string; context?: string; zenModel?: string } + payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string } ): Promise<{ title: string; body: string }> { - const { base, head, context, zenModel } = payload; + const { base, head, context, zenModel, providerId, modelId } = payload; if (!base || !head) { throw new Error('base and head are required'); } - const requestBody: { base: string; head: string; context?: string; zenModel?: string } = { base, head }; + const requestBody: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string } = { base, head }; if (context?.trim()) { requestBody.context = context.trim(); } if (zenModel) { requestBody.zenModel = zenModel; } + if (providerId) { + requestBody.providerId = providerId; + } + if (modelId) { + requestBody.modelId = modelId; + } const response = await fetch(buildUrl(`${API_BASE}/pr-description`, directory), { method: 'POST', diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 2bfc1d74..e5c8cfeb 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -18,6 +18,8 @@ const MODELS_DEV_PROXY_URL = "/api/openchamber/models-metadata"; const FALLBACK_PROVIDER_ID = "opencode"; const FALLBACK_MODEL_ID = "big-pickle"; +const GIT_UTILITY_PROVIDER_ID = "zen"; +const GIT_UTILITY_PREFERRED_MODEL_ID = "big-pickle"; interface OpenChamberDefaults { defaultModel?: string; @@ -26,6 +28,8 @@ interface OpenChamberDefaults { autoCreateWorktree?: boolean; gitmojiEnabled?: boolean; zenModel?: string; + gitProviderId?: string; + gitModelId?: string; } const fetchOpenChamberDefaults = async (): Promise => { @@ -42,6 +46,8 @@ const fetchOpenChamberDefaults = async (): Promise => { 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() : ''; + const gitProviderId = typeof data?.gitProviderId === 'string' ? data.gitProviderId.trim() : ''; + const gitModelId = typeof data?.gitModelId === 'string' ? data.gitModelId.trim() : ''; return { defaultModel: defaultModel.length > 0 ? defaultModel : undefined, @@ -50,6 +56,8 @@ const fetchOpenChamberDefaults = async (): Promise => { autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined, gitmojiEnabled, zenModel: zenModel.length > 0 ? zenModel : undefined, + gitProviderId: gitProviderId.length > 0 ? gitProviderId : undefined, + gitModelId: gitModelId.length > 0 ? gitModelId : undefined, }; } } catch { @@ -71,6 +79,8 @@ const fetchOpenChamberDefaults = async (): Promise => { 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() : ''; + const gitProviderId = typeof data?.gitProviderId === 'string' ? data.gitProviderId.trim() : ''; + const gitModelId = typeof data?.gitModelId === 'string' ? data.gitModelId.trim() : ''; return { defaultModel: defaultModel.length > 0 ? defaultModel : undefined, @@ -79,6 +89,8 @@ const fetchOpenChamberDefaults = async (): Promise => { autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined, gitmojiEnabled, zenModel: zenModel.length > 0 ? zenModel : undefined, + gitProviderId: gitProviderId.length > 0 ? gitProviderId : undefined, + gitModelId: gitModelId.length > 0 ? gitModelId : undefined, }; } catch { return {}; @@ -103,6 +115,80 @@ const isPrimaryMode = (mode?: string) => mode === "primary" || mode === "all" || type ProviderModel = Provider["models"][string]; type ProviderWithModelList = Omit & { models: ProviderModel[] }; +type GitModelSelection = { providerId: string; modelId: string }; + +const normalizeOptionalString = (value: unknown): string | undefined => { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +}; + +const hasProviderModel = ( + providers: ProviderWithModelList[], + providerId: string, + modelId: string +): boolean => { + const provider = providers.find((item) => item.id === providerId); + if (!provider) { + return false; + } + return provider.models.some((model) => model.id === modelId); +}; + +const resolveGitGenerationModelSelection = ({ + providers, + settingsGitProviderId, + settingsGitModelId, + settingsZenModel, +}: { + providers: ProviderWithModelList[]; + settingsGitProviderId?: string; + settingsGitModelId?: string; + settingsZenModel?: string; +}): GitModelSelection | null => { + const gitProviderId = normalizeOptionalString(settingsGitProviderId); + const gitModelId = normalizeOptionalString(settingsGitModelId); + const zenModel = normalizeOptionalString(settingsZenModel); + + if (!Array.isArray(providers) || providers.length === 0) { + if (zenModel) { + return { providerId: GIT_UTILITY_PROVIDER_ID, modelId: zenModel }; + } + return null; + } + + if (gitProviderId && gitModelId && hasProviderModel(providers, gitProviderId, gitModelId)) { + return { providerId: gitProviderId, modelId: gitModelId }; + } + + if (zenModel && hasProviderModel(providers, GIT_UTILITY_PROVIDER_ID, zenModel)) { + return { providerId: GIT_UTILITY_PROVIDER_ID, modelId: zenModel }; + } + + if (hasProviderModel(providers, GIT_UTILITY_PROVIDER_ID, GIT_UTILITY_PREFERRED_MODEL_ID)) { + return { providerId: GIT_UTILITY_PROVIDER_ID, modelId: GIT_UTILITY_PREFERRED_MODEL_ID }; + } + + const zenProvider = providers.find((provider) => provider.id === GIT_UTILITY_PROVIDER_ID); + if (zenProvider?.models.length) { + const randomIndex = Math.floor(Math.random() * zenProvider.models.length); + const randomModelId = normalizeOptionalString(zenProvider.models[randomIndex]?.id); + if (randomModelId) { + return { providerId: GIT_UTILITY_PROVIDER_ID, modelId: randomModelId }; + } + } + + const firstProvider = providers.find((provider) => provider.models.length > 0); + const firstModelId = normalizeOptionalString(firstProvider?.models[0]?.id); + if (firstProvider?.id && firstModelId) { + return { providerId: firstProvider.id, modelId: firstModelId }; + } + + return null; +}; + interface ModelsDevModelEntry { id?: string; name?: string; @@ -372,6 +458,8 @@ interface ConfigStore { settingsAutoCreateWorktree: boolean; settingsGitmojiEnabled: boolean; settingsZenModel: string | undefined; + settingsGitProviderId: string | undefined; + settingsGitModelId: string | undefined; // Voice provider preference ('browser', 'openai', or 'say' for macOS) voiceProvider: 'browser' | 'openai' | 'say'; setVoiceProvider: (provider: 'browser' | 'openai' | 'say') => void; @@ -421,6 +509,9 @@ interface ConfigStore { setSettingsAutoCreateWorktree: (enabled: boolean) => void; setSettingsGitmojiEnabled: (enabled: boolean) => void; setSettingsZenModel: (model: string | undefined) => void; + setSettingsGitProviderId: (providerId: string | undefined) => void; + setSettingsGitModelId: (modelId: string | undefined) => void; + getResolvedGitGenerationModel: () => { providerId: string; modelId: string } | null; saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => void; getAgentModelSelection: (agentName: string) => { providerId: string; modelId: string } | null; checkConnection: () => Promise; @@ -466,6 +557,8 @@ export const useConfigStore = create()( settingsAutoCreateWorktree: false, settingsGitmojiEnabled: false, settingsZenModel: undefined, + settingsGitProviderId: undefined, + settingsGitModelId: undefined, // Voice provider preference - load from localStorage or default to 'browser' voiceProvider: (() => { if (typeof window !== 'undefined') { @@ -1007,6 +1100,36 @@ export const useConfigStore = create()( ? get().providers : (get().directoryScoped[directoryKey]?.providers ?? []); + const existingGitProviderId = normalizeOptionalString(get().settingsGitProviderId); + const existingGitModelId = normalizeOptionalString(get().settingsGitModelId); + const existingZenModel = normalizeOptionalString(get().settingsZenModel); + + const defaultGitProviderId = normalizeOptionalString(openChamberDefaults.gitProviderId); + const defaultGitModelId = normalizeOptionalString(openChamberDefaults.gitModelId); + const defaultZenModel = normalizeOptionalString(openChamberDefaults.zenModel); + + const resolvedExistingGitSelection = resolveGitGenerationModelSelection({ + providers, + settingsGitProviderId: existingGitProviderId, + settingsGitModelId: existingGitModelId, + settingsZenModel: existingZenModel, + }); + + const resolvedDefaultGitSelection = resolveGitGenerationModelSelection({ + providers, + settingsGitProviderId: defaultGitProviderId, + settingsGitModelId: defaultGitModelId, + settingsZenModel: defaultZenModel, + }); + + const resolvedGitSelection = resolvedExistingGitSelection || resolvedDefaultGitSelection; + const resolvedGitProviderId = resolvedGitSelection?.providerId; + const resolvedGitModelId = resolvedGitSelection?.modelId; + const resolvedZenModel = + resolvedGitProviderId === GIT_UTILITY_PROVIDER_ID && resolvedGitModelId + ? resolvedGitModelId + : (defaultZenModel || existingZenModel); + set((state) => { const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? { providers, @@ -1031,7 +1154,9 @@ export const useConfigStore = create()( settingsDefaultAgent: openChamberDefaults.defaultAgent, settingsAutoCreateWorktree: openChamberDefaults.autoCreateWorktree ?? false, settingsGitmojiEnabled: openChamberDefaults.gitmojiEnabled ?? false, - settingsZenModel: openChamberDefaults.zenModel, + settingsZenModel: resolvedZenModel, + settingsGitProviderId: resolvedGitProviderId, + settingsGitModelId: resolvedGitModelId, directoryScoped: { ...state.directoryScoped, [directoryKey]: nextSnapshot, @@ -1045,6 +1170,37 @@ export const useConfigStore = create()( return nextState; }); + const shouldPersistResolvedGitSelection = + !!resolvedGitProviderId && + !!resolvedGitModelId && + ( + defaultGitProviderId !== resolvedGitProviderId || + defaultGitModelId !== resolvedGitModelId || + ( + resolvedGitProviderId === GIT_UTILITY_PROVIDER_ID && + resolvedZenModel !== defaultZenModel + ) + ); + + if (shouldPersistResolvedGitSelection && resolvedGitProviderId && resolvedGitModelId) { + const gitSettingsUpdate: { + gitProviderId: string; + gitModelId: string; + zenModel?: string; + } = { + gitProviderId: resolvedGitProviderId, + gitModelId: resolvedGitModelId, + }; + + if (resolvedGitProviderId === GIT_UTILITY_PROVIDER_ID && resolvedZenModel) { + gitSettingsUpdate.zenModel = resolvedZenModel; + } + + updateDesktopSettings(gitSettingsUpdate).catch(() => { + // Ignore errors - best effort cleanup + }); + } + if (safeAgents.length === 0) { set((state) => { const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? { @@ -1460,6 +1616,24 @@ export const useConfigStore = create()( set({ settingsZenModel: model }); }, + setSettingsGitProviderId: (providerId: string | undefined) => { + set({ settingsGitProviderId: providerId }); + }, + + setSettingsGitModelId: (modelId: string | undefined) => { + set({ settingsGitModelId: modelId }); + }, + + getResolvedGitGenerationModel: () => { + const state = get(); + return resolveGitGenerationModelSelection({ + providers: state.providers, + settingsGitProviderId: state.settingsGitProviderId, + settingsGitModelId: state.settingsGitModelId, + settingsZenModel: state.settingsZenModel, + }); + }, + setVoiceProvider: (provider: 'browser' | 'openai' | 'say') => { set({ voiceProvider: provider }); if (typeof window !== 'undefined') { @@ -1670,6 +1844,8 @@ export const useConfigStore = create()( settingsAutoCreateWorktree: state.settingsAutoCreateWorktree, settingsGitmojiEnabled: state.settingsGitmojiEnabled, settingsZenModel: state.settingsZenModel, + settingsGitProviderId: state.settingsGitProviderId, + settingsGitModelId: state.settingsGitModelId, speechRate: state.speechRate, speechPitch: state.speechPitch, speechVolume: state.speechVolume, diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index 6e745560..30ea1ed6 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -304,28 +304,253 @@ const normalizeMergeMethod = (value: string): 'merge' | 'squash' | 'rebase' => { return 'merge'; }; -const extractZenOutputText = (value: unknown): string | null => { - if (!value || typeof value !== 'object') return null; - const root = value as Record; - const output = root.output; - if (!Array.isArray(output)) return null; +const BRIDGE_ZEN_DEFAULT_MODEL = 'gpt-5-nano'; +const BRIDGE_GIT_GENERATION_TIMEOUT_MS = 2 * 60 * 1000; +const BRIDGE_GIT_GENERATION_POLL_INTERVAL_MS = 500; +let bridgeGitModelCatalogCache: Set | null = null; +let bridgeGitModelCatalogCacheAt = 0; +const BRIDGE_GIT_MODEL_CATALOG_CACHE_TTL_MS = 30 * 1000; - const messageItem = output.find((item) => { - if (!item || typeof item !== 'object') return false; - return (item as Record).type === 'message'; - }) as Record | undefined; - if (!messageItem) return null; +const sleep = (ms: number) => new Promise((resolve) => { + setTimeout(resolve, ms); +}); - const content = messageItem.content; - if (!Array.isArray(content)) return null; +const fetchBridgeGitModelCatalog = async ( + apiUrl: string, + authHeaders?: Record +): Promise> => { + const now = Date.now(); + if (bridgeGitModelCatalogCache && now - bridgeGitModelCatalogCacheAt < BRIDGE_GIT_MODEL_CATALOG_CACHE_TTL_MS) { + return bridgeGitModelCatalogCache; + } - const textItem = content.find((item) => { - if (!item || typeof item !== 'object') return false; - return (item as Record).type === 'output_text'; - }) as Record | undefined; + const headers = authHeaders || {}; + const modelsUrl = new URL(`${apiUrl.replace(/\/+$/, '')}/model`); + const response = await fetch(modelsUrl.toString(), { + method: 'GET', + headers: { + Accept: 'application/json', + ...headers, + }, + signal: AbortSignal.timeout(8_000), + }); - const text = typeof textItem?.text === 'string' ? textItem.text.trim() : ''; - return text || null; + if (!response.ok) { + throw new Error('Failed to fetch model catalog'); + } + + const payload = await response.json().catch(() => null) as unknown; + const refs = new Set(); + if (Array.isArray(payload)) { + for (const item of payload) { + if (!item || typeof item !== 'object') { + continue; + } + const record = item as Record; + const providerID = typeof record.providerID === 'string' ? record.providerID.trim() : ''; + const modelID = typeof record.modelID === 'string' ? record.modelID.trim() : ''; + if (providerID && modelID) { + refs.add(`${providerID}/${modelID}`); + } + } + } + + bridgeGitModelCatalogCache = refs; + bridgeGitModelCatalogCacheAt = now; + return refs; +}; + +const resolveBridgeGitGenerationModel = async ( + payloadModel: { providerId?: string; modelId?: string; zenModel?: string }, + settings: Record, + apiUrl: string, + authHeaders?: Record +): Promise<{ providerID: string; modelID: string }> => { + let catalog: Set | null = null; + try { + catalog = await fetchBridgeGitModelCatalog(apiUrl, authHeaders); + } catch { + catalog = null; + } + + const hasModel = (providerID: string, modelID: string): boolean => { + if (!catalog) { + return false; + } + return catalog.has(`${providerID}/${modelID}`); + }; + + const requestProviderId = typeof payloadModel.providerId === 'string' ? payloadModel.providerId.trim() : ''; + const requestModelId = typeof payloadModel.modelId === 'string' ? payloadModel.modelId.trim() : ''; + if (requestProviderId && requestModelId && hasModel(requestProviderId, requestModelId)) { + return { providerID: requestProviderId, modelID: requestModelId }; + } + + const settingsProviderId = readStringField(settings, 'gitProviderId'); + const settingsModelId = readStringField(settings, 'gitModelId'); + if (settingsProviderId && settingsModelId && hasModel(settingsProviderId, settingsModelId)) { + return { providerID: settingsProviderId, modelID: settingsModelId }; + } + + const payloadZenModel = typeof payloadModel.zenModel === 'string' ? payloadModel.zenModel.trim() : ''; + const settingsZenModel = readStringField(settings, 'zenModel'); + return { + providerID: 'zen', + modelID: payloadZenModel || settingsZenModel || BRIDGE_ZEN_DEFAULT_MODEL, + }; +}; + +const extractTextFromMessageParts = (parts: unknown): string => { + if (!Array.isArray(parts)) { + return ''; + } + + const textParts = parts + .filter((part) => { + if (!part || typeof part !== 'object') return false; + const record = part as Record; + return record.type === 'text' && typeof record.text === 'string'; + }) + .map((part) => (part as Record).text as string) + .map((text) => text.trim()) + .filter((text) => text.length > 0); + + return textParts.join('\n').trim(); +}; + +const generateBridgeTextWithSessionFlow = async ({ + apiUrl, + directory, + prompt, + providerID, + modelID, + authHeaders, +}: { + apiUrl: string; + directory: string; + prompt: string; + providerID: string; + modelID: string; + authHeaders?: Record; +}): Promise => { + const headers = authHeaders || {}; + const apiBase = apiUrl.replace(/\/+$/, ''); + const deadlineAt = Date.now() + BRIDGE_GIT_GENERATION_TIMEOUT_MS; + const remainingMs = () => Math.max(1_000, deadlineAt - Date.now()); + let sessionId: string | null = null; + + try { + const sessionUrl = new URL(`${apiBase}/session`); + if (directory) { + sessionUrl.searchParams.set('directory', directory); + } + + const createResponse = await fetch(sessionUrl.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body: JSON.stringify({ title: 'Git Generation' }), + signal: AbortSignal.timeout(remainingMs()), + }); + + if (!createResponse.ok) { + throw new Error('Failed to create OpenCode session'); + } + + const session = await createResponse.json().catch(() => null) as unknown; + const sessionObj = session && typeof session === 'object' ? session as Record : null; + const createdSessionId = sessionObj && typeof sessionObj.id === 'string' ? sessionObj.id : ''; + if (!createdSessionId) { + throw new Error('Invalid session response'); + } + sessionId = createdSessionId; + + const promptUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}/prompt_async`); + if (directory) { + promptUrl.searchParams.set('directory', directory); + } + + const promptResponse = await fetch(promptUrl.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body: JSON.stringify({ + model: { + providerID, + modelID, + }, + parts: [{ type: 'text', text: prompt }], + }), + signal: AbortSignal.timeout(remainingMs()), + }); + + if (!promptResponse.ok) { + throw new Error('Failed to send prompt'); + } + + const messagesUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}/message`); + if (directory) { + messagesUrl.searchParams.set('directory', directory); + } + messagesUrl.searchParams.set('limit', '10'); + + while (Date.now() < deadlineAt) { + await sleep(BRIDGE_GIT_GENERATION_POLL_INTERVAL_MS); + + const messagesResponse = await fetch(messagesUrl.toString(), { + method: 'GET', + headers: { + Accept: 'application/json', + ...headers, + }, + signal: AbortSignal.timeout(remainingMs()), + }); + + if (!messagesResponse.ok) { + continue; + } + + const messages = await messagesResponse.json().catch(() => null) as unknown; + if (!Array.isArray(messages)) { + continue; + } + + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] as Record | null; + if (!message || typeof message !== 'object') { + continue; + } + const info = message.info as Record | undefined; + if (info?.role !== 'assistant' || info?.finish !== 'stop') { + continue; + } + + const text = extractTextFromMessageParts(message.parts); + if (text) { + return text; + } + } + } + + throw new Error('Timeout waiting for generation to complete'); + } finally { + if (sessionId) { + const deleteUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}`); + try { + await fetch(deleteUrl.toString(), { + method: 'DELETE', + headers, + signal: AbortSignal.timeout(5_000), + }); + } catch { + // ignore cleanup failures + } + } + } }; const parseJsonObjectSafe = (value: string): Record | null => { @@ -2920,10 +3145,14 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo } case 'api:git/pr-description': { - const { directory, base, head } = (payload || {}) as { + const { directory, base, head, context, providerId, modelId, zenModel: payloadZenModel } = (payload || {}) as { directory?: string; base?: string; head?: string; + context?: string; + providerId?: string; + modelId?: string; + zenModel?: string; }; if (!directory) { return { id, type, success: false, error: 'Directory is required' }; @@ -2961,31 +3190,33 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo return { id, type, success: false, error: 'No diffs available for selected files' }; } - const prompt = `You are drafting a GitHub Pull Request title + description. Respond in JSON of the shape {"title": string, "body": string} (ONLY JSON in response, no markdown fences) with these rules:\n- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no "feat:", "fix:")\n- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes\n- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names\n- Testing: bullet list ("- Not tested" allowed)\n- Notes: bullet list; include breaking/rollout notes only when relevant\n\nContext:\n- base branch: ${base}\n- head branch: ${head}\n\nDiff summary:\n${diffSummaries}`; + const prompt = `You are drafting a GitHub Pull Request title + description. Respond in JSON of the shape {"title": string, "body": string} (ONLY JSON in response, no markdown fences) with these rules:\n- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no "feat:", "fix:")\n- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes\n- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names\n- Testing: bullet list ("- Not tested" allowed)\n- Notes: bullet list; include breaking/rollout notes only when relevant\n\nContext:\n- base branch: ${base}\n- head branch: ${head}${context?.trim() ? `\n- Additional context: ${context.trim()}` : ''}\n\nDiff summary:\n${diffSummaries}`; try { - const zenSettings = readSettings(ctx) as Record; - const zenModelRaw = typeof zenSettings?.zenModel === 'string' ? (zenSettings.zenModel as string).trim() : ''; - const zenModel = zenModelRaw.length > 0 ? zenModelRaw : 'gpt-5-nano'; - const response = await fetch('https://opencode.ai/zen/v1/responses', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: zenModel, - input: [{ role: 'user', content: prompt }], - max_output_tokens: 1200, - stream: false, - reasoning: { effort: 'low' }, - }), - }); - if (!response.ok) { - return { id, type, success: false, error: 'Failed to generate PR description' }; + const apiUrl = ctx?.manager?.getApiUrl(); + if (!apiUrl) { + return { id, type, success: false, error: 'OpenCode API unavailable' }; } - const data = await response.json().catch(() => null) as unknown; - const raw = extractZenOutputText(data); + + const settings = readSettings(ctx) as Record; + const { providerID, modelID } = await resolveBridgeGitGenerationModel( + { providerId, modelId, zenModel: payloadZenModel }, + settings, + apiUrl, + ctx?.manager?.getOpenCodeAuthHeaders() + ); + const raw = await generateBridgeTextWithSessionFlow({ + apiUrl, + directory, + prompt, + providerID, + modelID, + authHeaders: ctx?.manager?.getOpenCodeAuthHeaders(), + }); if (!raw) { return { id, type, success: false, error: 'No PR description returned by generator' }; } + const cleaned = String(raw) .trim() .replace(/^```json\s*/i, '') diff --git a/packages/vscode/webview/api/git.ts b/packages/vscode/webview/api/git.ts index 70551f64..32feb32b 100644 --- a/packages/vscode/webview/api/git.ts +++ b/packages/vscode/webview/api/git.ts @@ -90,10 +90,15 @@ export const createVSCodeGitAPI = (): GitAPI => ({ }); }, - generateCommitMessage: async (directory: string, files: string[]): Promise<{ message: GeneratedCommitMessage }> => { + generateCommitMessage: async ( + directory: string, + files: string[], + options?: { zenModel?: string; providerId?: string; modelId?: string } + ): Promise<{ message: GeneratedCommitMessage }> => { // This requires AI integration - stubbed for now void directory; // Unused for now void files; // Unused for now + void options; // Unused for now return { message: { subject: '', @@ -104,12 +109,16 @@ export const createVSCodeGitAPI = (): GitAPI => ({ generatePullRequestDescription: async ( directory: string, - payload: { base: string; head: string } + payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string } ): Promise => { return sendBridgeMessage('api:git/pr-description', { directory, base: payload.base, head: payload.head, + context: payload.context, + zenModel: payload.zenModel, + providerId: payload.providerId, + modelId: payload.modelId, }); }, diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 95ccb2da..cc53fdf1 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -604,6 +604,9 @@ let validatedZenFallback = null; let cachedZenModels = null; let cachedZenModelsTimestamp = 0; const ZEN_MODELS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes +let cachedGitModelCatalog = null; +let cachedGitModelCatalogTimestamp = 0; +const GIT_MODEL_CATALOG_CACHE_TTL = 30 * 1000; /** * Fetch free models from the zen API with caching. Returns an array of @@ -660,6 +663,220 @@ const resolveZenModel = async (override) => { return validatedZenFallback || ZEN_DEFAULT_MODEL; }; +const getGitModelCatalog = async () => { + const now = Date.now(); + if (cachedGitModelCatalog && now - cachedGitModelCatalogTimestamp < GIT_MODEL_CATALOG_CACHE_TTL) { + return cachedGitModelCatalog; + } + + const response = await fetch(buildOpenCodeUrl('/model', ''), { + method: 'GET', + headers: { + Accept: 'application/json', + ...getOpenCodeAuthHeaders(), + }, + signal: AbortSignal.timeout(8_000), + }); + + if (!response.ok) { + throw new Error(`Failed to fetch model catalog: ${response.status}`); + } + + const payload = await response.json().catch(() => null); + const modelRefs = new Set(); + if (Array.isArray(payload)) { + for (const item of payload) { + if (!item || typeof item !== 'object') { + continue; + } + const providerID = typeof item.providerID === 'string' ? item.providerID.trim() : ''; + const modelID = typeof item.modelID === 'string' ? item.modelID.trim() : ''; + if (providerID && modelID) { + modelRefs.add(`${providerID}/${modelID}`); + } + } + } + + cachedGitModelCatalog = modelRefs; + cachedGitModelCatalogTimestamp = now; + return modelRefs; +}; + +/** + * Resolve git generation model based on priority: + * 1) request providerId+modelId + * 2) saved settings gitProviderId+gitModelId + * 3) legacy zenModel from request/settings as zen/ + * 4) Zen default (validatedZenFallback || ZEN_DEFAULT_MODEL) + */ +const resolveGitModel = async (requestParams) => { + const { providerId, modelId, zenModel } = requestParams || {}; + const requestProviderId = typeof providerId === 'string' ? providerId.trim() : ''; + const requestModelId = typeof modelId === 'string' ? modelId.trim() : ''; + + let modelCatalog = null; + try { + modelCatalog = await getGitModelCatalog(); + } catch { + modelCatalog = null; + } + + const hasModel = (providerID, modelID) => { + if (!modelCatalog) { + return false; + } + return modelCatalog.has(`${providerID}/${modelID}`); + }; + + if (requestProviderId && requestModelId && hasModel(requestProviderId, requestModelId)) { + return { providerID: requestProviderId, modelID: requestModelId }; + } + + try { + const settings = await readSettingsFromDisk(); + const settingsProviderId = typeof settings?.gitProviderId === 'string' ? settings.gitProviderId.trim() : ''; + const settingsModelId = typeof settings?.gitModelId === 'string' ? settings.gitModelId.trim() : ''; + if (settingsProviderId && settingsModelId && hasModel(settingsProviderId, settingsModelId)) { + return { providerID: settingsProviderId, modelID: settingsModelId }; + } + } catch { + // ignore + } + + const fallbackZenModel = typeof zenModel === 'string' && zenModel.trim().length > 0 + ? zenModel.trim() + : (await resolveZenModel(zenModel)); + + return { providerID: 'zen', modelID: fallbackZenModel }; +}; + +const GIT_GENERATION_TIMEOUT_MS = 2 * 60 * 1000; +const GIT_GENERATION_POLL_INTERVAL_MS = 500; + +/** + * Generate text using OpenCode session flow: + * - Create short-lived session + * - POST prompt_async with model and text prompt + * - Poll session messages until final assistant response + * - Extract text from parts + * - Best-effort cleanup of temporary session + */ +const generateWithSessionFlow = async ({ prompt, providerID, modelID }) => { + const completionTimeout = createTimeoutSignal(GIT_GENERATION_TIMEOUT_MS); + let sessionId = null; + + try { + const createUrl = buildOpenCodeUrl('/session', ''); + const createResponse = await fetch(createUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...getOpenCodeAuthHeaders(), + }, + body: JSON.stringify({ + title: 'Git Generation', + }), + signal: completionTimeout.signal, + }); + + if (!createResponse.ok) { + const errorBody = await createResponse.json().catch(() => ({})); + throw new Error(`Failed to create session: ${createResponse.status} ${JSON.stringify(errorBody)}`); + } + + const sessionData = await createResponse.json(); + sessionId = sessionData?.id; + if (!sessionId) { + throw new Error('Session created but no ID returned'); + } + + const promptUrl = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/prompt_async`, ''); + const promptResponse = await fetch(promptUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...getOpenCodeAuthHeaders(), + }, + body: JSON.stringify({ + model: { providerID, modelID }, + parts: [{ type: 'text', text: prompt }], + }), + signal: completionTimeout.signal, + }); + + if (!promptResponse.ok) { + const errorBody = await promptResponse.json().catch(() => ({})); + throw new Error(`Failed to send prompt: ${promptResponse.status} ${JSON.stringify(errorBody)}`); + } + + const messagesUrl = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/message`, ''); + let lastAssistantText = ''; + let pollingAttempts = 0; + const maxPollingAttempts = Math.ceil(GIT_GENERATION_TIMEOUT_MS / GIT_GENERATION_POLL_INTERVAL_MS); + + while (pollingAttempts < maxPollingAttempts) { + pollingAttempts++; + + await new Promise((resolve) => setTimeout(resolve, GIT_GENERATION_POLL_INTERVAL_MS)); + + const messagesResponse = await fetch(`${messagesUrl}?limit=10`, { + method: 'GET', + headers: { + Accept: 'application/json', + ...getOpenCodeAuthHeaders(), + }, + signal: completionTimeout.signal, + }); + + if (!messagesResponse.ok) { + console.warn(`Session messages poll failed: ${messagesResponse.status}`); + continue; + } + + const messages = await messagesResponse.json().catch(() => null); + if (!Array.isArray(messages)) { + continue; + } + + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg?.info?.role === 'assistant' && msg?.info?.finish === 'stop') { + if (Array.isArray(msg.parts)) { + const textParts = msg.parts + .filter((p) => p?.type === 'text' && typeof p?.text === 'string') + .map((p) => p.text) + .filter(Boolean); + if (textParts.length > 0) { + return textParts.join('\n').trim(); + } + } + } + } + } + + throw new Error('Timeout waiting for generation to complete'); + } finally { + completionTimeout.cleanup(); + + if (sessionId) { + try { + const deleteUrl = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}`, ''); + await fetch(deleteUrl, { + method: 'DELETE', + headers: getOpenCodeAuthHeaders(), + signal: AbortSignal.timeout(5000), + }).catch((err) => { + console.warn('Failed to cleanup temporary session:', err?.message || err); + }); + } catch (err) { + console.warn('Failed to cleanup temporary session:', err?.message || err); + } + } + } +}; + const summarizeText = async (text, targetLength, zenModel) => { if (!text || typeof text !== 'string' || text.trim().length === 0) return text; @@ -1534,6 +1751,14 @@ const sanitizeSettingsUpdate = (payload) => { const trimmed = candidate.zenModel.trim(); result.zenModel = trimmed.length > 0 ? trimmed : undefined; } + if (typeof candidate.gitProviderId === 'string') { + const trimmed = candidate.gitProviderId.trim(); + result.gitProviderId = trimmed.length > 0 ? trimmed : undefined; + } + if (typeof candidate.gitModelId === 'string') { + const trimmed = candidate.gitModelId.trim(); + result.gitModelId = trimmed.length > 0 ? trimmed : undefined; + } if (typeof candidate.toolCallExpansion === 'string') { const mode = candidate.toolCallExpansion.trim(); if (mode === 'collapsed' || mode === 'activity' || mode === 'detailed') { @@ -9713,37 +9938,13 @@ highlights: Diff summary (may be truncated): ${diffSummaries}`; - const model = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined); + const { providerID, modelID } = await resolveGitModel({ + providerId: req.body?.providerId, + modelId: req.body?.modelId, + zenModel: req.body?.zenModel, + }); - const completionTimeout = createTimeoutSignal(LONG_REQUEST_TIMEOUT_MS); - let response; - try { - response = await fetch('https://opencode.ai/zen/v1/responses', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model, - input: [{ role: 'user', content: prompt }], - max_output_tokens: 1000, - stream: false, - reasoning: { - effort: 'low' - } - }), - signal: completionTimeout.signal, - }); - } finally { - completionTimeout.cleanup(); - } - - if (!response.ok) { - const errorBody = await response.json().catch(() => ({})); - console.error('Commit message generation failed:', errorBody); - return res.status(502).json({ error: 'Failed to generate commit message' }); - } - - const data = await response.json(); - const raw = data?.output?.find((item) => item?.type === 'message')?.content?.find((item) => item?.type === 'output_text')?.text?.trim(); + const raw = await generateWithSessionFlow({ prompt, providerID, modelID }); if (!raw) { return res.status(502).json({ error: 'No commit message returned by generator' }); @@ -9826,35 +10027,13 @@ Context: prompt += `\n\nDiff summary:\n${diffSummaries}`; - const model = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined); + const { providerID, modelID } = await resolveGitModel({ + providerId: req.body?.providerId, + modelId: req.body?.modelId, + zenModel: req.body?.zenModel, + }); - const completionTimeout = createTimeoutSignal(LONG_REQUEST_TIMEOUT_MS); - let response; - try { - response = await fetch('https://opencode.ai/zen/v1/responses', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model, - input: [{ role: 'user', content: prompt }], - max_output_tokens: 1200, - stream: false, - reasoning: { effort: 'low' }, - }), - signal: completionTimeout.signal, - }); - } finally { - completionTimeout.cleanup(); - } - - if (!response.ok) { - const errorBody = await response.json().catch(() => ({})); - console.error('PR description generation failed:', errorBody); - return res.status(502).json({ error: 'Failed to generate PR description' }); - } - - const data = await response.json(); - const raw = data?.output?.find((item) => item?.type === 'message')?.content?.find((item) => item?.type === 'output_text')?.text?.trim(); + const raw = await generateWithSessionFlow({ prompt, providerID, modelID }); if (!raw) { return res.status(502).json({ error: 'No PR description returned by generator' }); }