Skip to content

Commit e6ff873

Browse files
feat(model): add favorites system for model selection (#201)
* fix: sync model selection with OpenCode state * feat(model): add favorites system for model selection - Add toggleFavoriteModel API endpoint - Add favorite toggle UI in ModelQuickSelect and ModelSelectDialog - Show provider name prefix when model display names are ambiguous - Fix directory scoping for getProviders API calls feat(assistant): send welcome message on new session creation - Send setup guidance message when creating new assistant session --------- Co-authored-by: Chris Scott <>
1 parent 3dd8e34 commit e6ff873

11 files changed

Lines changed: 470 additions & 80 deletions

File tree

backend/src/routes/providers.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,79 @@
11
import { Hono } from 'hono'
22
import { z } from 'zod'
3+
import path from 'path'
34
import { AuthService } from '../services/auth'
45
import { SetCredentialRequestSchema } from '../../../shared/src/schemas/auth'
56
import { logger } from '../utils/logger'
67
import { setOpenCodeAuth, deleteOpenCodeAuth } from '../services/proxy'
78
import { opencodeServerManager } from '../services/opencode-single-server'
89
import type { OpenCodeSupervisor } from '../services/opencode-supervisor'
10+
import { fileExists, readFileContent, writeFileContent } from '../services/file-operations'
11+
import { getWorkspacePath } from '@opencode-manager/shared/config/env'
12+
13+
const ModelSelectionSchema = z.object({
14+
providerID: z.string().min(1),
15+
modelID: z.string().min(1),
16+
})
17+
18+
const ModelStateSchema = z.object({
19+
recent: z.array(ModelSelectionSchema).default([]),
20+
favorite: z.array(ModelSelectionSchema).default([]),
21+
variant: z.record(z.string(), z.string().optional()).default({}),
22+
})
23+
24+
const UpdateModelStateSchema = z.object({
25+
recent: ModelSelectionSchema.optional(),
26+
favorite: ModelSelectionSchema.optional(),
27+
})
28+
29+
type ModelSelection = z.infer<typeof ModelSelectionSchema>
30+
type ModelState = z.infer<typeof ModelStateSchema>
31+
32+
const MAX_RECENT_MODELS = 10
33+
34+
function getModelStatePath(): string {
35+
return path.join(getWorkspacePath(), '.opencode', 'state', 'opencode', 'model.json')
36+
}
37+
38+
async function readModelState(): Promise<ModelState> {
39+
const modelStatePath = getModelStatePath()
40+
if (!await fileExists(modelStatePath)) {
41+
return { recent: [], favorite: [], variant: {} }
42+
}
43+
44+
return ModelStateSchema.parse(JSON.parse(await readFileContent(modelStatePath)))
45+
}
46+
47+
function uniqueModels(models: ModelSelection[]): ModelSelection[] {
48+
const seen = new Set<string>()
49+
return models.filter((model) => {
50+
const key = `${model.providerID}/${model.modelID}`
51+
if (seen.has(key)) {
52+
return false
53+
}
54+
seen.add(key)
55+
return true
56+
})
57+
}
58+
59+
async function addRecentModel(model: ModelSelection): Promise<ModelState> {
60+
const state = await readModelState()
61+
const recent = uniqueModels([model, ...state.recent]).slice(0, MAX_RECENT_MODELS)
62+
const nextState = { ...state, recent }
63+
await writeFileContent(getModelStatePath(), JSON.stringify(nextState, null, 2))
64+
return nextState
65+
}
66+
67+
async function toggleFavoriteModel(model: ModelSelection): Promise<ModelState> {
68+
const state = await readModelState()
69+
const exists = state.favorite.some((favorite) => favorite.providerID === model.providerID && favorite.modelID === model.modelID)
70+
const favorite = exists
71+
? state.favorite.filter((favorite) => favorite.providerID !== model.providerID || favorite.modelID !== model.modelID)
72+
: uniqueModels([model, ...state.favorite])
73+
const nextState = { ...state, favorite }
74+
await writeFileContent(getModelStatePath(), JSON.stringify(nextState, null, 2))
75+
return nextState
76+
}
977

1078
async function reloadOpenCodeConfig(openCodeSupervisor?: OpenCodeSupervisor): Promise<void> {
1179
if (openCodeSupervisor) {
@@ -20,6 +88,35 @@ export function createProvidersRoutes(openCodeSupervisor?: OpenCodeSupervisor) {
2088
const app = new Hono()
2189
const authService = new AuthService()
2290

91+
app.get('/model-state', async (c) => {
92+
try {
93+
return c.json(await readModelState())
94+
} catch (error) {
95+
logger.error('Failed to read OpenCode model state:', error)
96+
return c.json({ recent: [], favorite: [], variant: {} })
97+
}
98+
})
99+
100+
app.post('/model-state', async (c) => {
101+
try {
102+
const body = await c.req.json()
103+
const validated = UpdateModelStateSchema.parse(body)
104+
if (validated.favorite) {
105+
return c.json(await toggleFavoriteModel(validated.favorite))
106+
}
107+
if (!validated.recent) {
108+
return c.json(await readModelState())
109+
}
110+
return c.json(await addRecentModel(validated.recent))
111+
} catch (error) {
112+
logger.error('Failed to update OpenCode model state:', error)
113+
if (error instanceof z.ZodError) {
114+
return c.json({ error: 'Invalid request data', details: error.issues }, 400)
115+
}
116+
return c.json({ error: 'Failed to update OpenCode model state' }, 500)
117+
}
118+
})
119+
23120
app.get('/credentials', async (c) => {
24121
try {
25122
const providers = await authService.list()

backend/src/services/opencode-single-server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ class OpenCodeServerManager {
287287
...gitIdentityEnv,
288288
GIT_SSH_COMMAND: gitSshCommand,
289289
XDG_DATA_HOME: path.join(openCodeServerDirectory, '.opencode/state'),
290+
XDG_STATE_HOME: path.join(openCodeServerDirectory, '.opencode/state'),
290291
XDG_CONFIG_HOME: path.join(openCodeServerDirectory, '.config'),
291292
...(OPENCODE_SERVER_PUBLIC_URL ? { OPENCODE_PUBLIC_URL: OPENCODE_SERVER_PUBLIC_URL } : {}),
292293
...(OPENCODE_SERVER_PASSWORD

frontend/src/api/providers.ts

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,17 @@ export interface ProviderWithModels {
115115
isConnected: boolean;
116116
}
117117

118+
export interface ModelSelection {
119+
providerID: string;
120+
modelID: string;
121+
}
122+
123+
export interface OpenCodeModelState {
124+
recent: ModelSelection[];
125+
favorite: ModelSelection[];
126+
variant: Record<string, string | undefined>;
127+
}
128+
118129
interface ConfigProvider {
119130
npm?: string;
120131
name?: string;
@@ -151,9 +162,17 @@ interface OpenCodeProviderResponse {
151162
default: Record<string, string>;
152163
}
153164

154-
async function getProvidersFromOpenCodeServer(): Promise<{ providers: Provider[]; connected: string[] }> {
165+
export interface ProvidersResult {
166+
providers: Provider[];
167+
connected: string[];
168+
default: Record<string, string>;
169+
}
170+
171+
async function getProvidersFromOpenCodeServer(directory?: string): Promise<ProvidersResult> {
155172
try {
156-
const response = await fetchWrapper<OpenCodeProviderResponse>(`${API_BASE_URL}/api/opencode/provider`);
173+
const response = await fetchWrapper<OpenCodeProviderResponse>(`${API_BASE_URL}/api/opencode/provider`, {
174+
params: { directory },
175+
});
157176

158177
if (response?.all && Array.isArray(response.all)) {
159178
const connectedSet = new Set(response.connected || []);
@@ -205,17 +224,37 @@ async function getProvidersFromOpenCodeServer(): Promise<{ providers: Provider[]
205224
};
206225
});
207226

208-
return { providers, connected: response.connected || [] };
227+
return { providers, connected: response.connected || [], default: response.default || {} };
209228
}
210229
} catch {
211230
// Silently return empty providers on failure - graceful degradation
212231
}
213232

214-
return { providers: [], connected: [] };
233+
return { providers: [], connected: [], default: {} };
234+
}
235+
236+
export async function getProviders(directory?: string): Promise<ProvidersResult> {
237+
return await getProvidersFromOpenCodeServer(directory);
238+
}
239+
240+
export async function getOpenCodeModelState(): Promise<OpenCodeModelState> {
241+
return await fetchWrapper<OpenCodeModelState>(`${API_BASE_URL}/api/providers/model-state`);
215242
}
216243

217-
export async function getProviders(): Promise<{ providers: Provider[]; connected: string[] }> {
218-
return await getProvidersFromOpenCodeServer();
244+
export async function addOpenCodeRecentModel(model: ModelSelection): Promise<OpenCodeModelState> {
245+
return await fetchWrapper<OpenCodeModelState>(`${API_BASE_URL}/api/providers/model-state`, {
246+
method: 'POST',
247+
headers: { 'Content-Type': 'application/json' },
248+
body: JSON.stringify({ recent: model }),
249+
});
250+
}
251+
252+
export async function toggleOpenCodeFavoriteModel(model: ModelSelection): Promise<OpenCodeModelState> {
253+
return await fetchWrapper<OpenCodeModelState>(`${API_BASE_URL}/api/providers/model-state`, {
254+
method: 'POST',
255+
headers: { 'Content-Type': 'application/json' },
256+
body: JSON.stringify({ favorite: model }),
257+
});
219258
}
220259

221260
async function getConfiguredProviders(connectedIds: Set<string>): Promise<ProviderWithModels[]> {
@@ -267,8 +306,8 @@ async function getConfiguredProviders(connectedIds: Set<string>): Promise<Provid
267306
}
268307
}
269308

270-
export async function getProvidersWithModels(): Promise<ProviderWithModels[]> {
271-
const { providers: builtinProviders, connected } = await getProviders();
309+
export async function getProvidersWithModels(directory?: string): Promise<ProviderWithModels[]> {
310+
const { providers: builtinProviders, connected } = await getProviders(directory);
272311
const connectedIds = new Set(connected);
273312

274313
const configuredProviders = await getConfiguredProviders(connectedIds);
@@ -310,8 +349,9 @@ export async function getProvidersWithModels(): Promise<ProviderWithModels[]> {
310349
export async function getModel(
311350
providerId: string,
312351
modelId: string,
352+
directory?: string,
313353
): Promise<Model | null> {
314-
const providers = await getProvidersWithModels();
354+
const providers = await getProvidersWithModels(directory);
315355
const provider = providers.find((p) => p.id === providerId);
316356
if (!provider) return null;
317357

frontend/src/components/message/PromptInput.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -937,7 +937,7 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read)
937937
const client = useOpenCodeClient(opcodeUrl, directory)
938938
const { data: providersData } = useQuery({
939939
queryKey: ['opencode', 'providers', opcodeUrl, directory],
940-
queryFn: () => getProviders(),
940+
queryFn: () => getProviders(directory),
941941
enabled: !!client,
942942
staleTime: 30000,
943943
})

0 commit comments

Comments
 (0)