Skip to content

Commit 430dfad

Browse files
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
1 parent 76146e0 commit 430dfad

11 files changed

Lines changed: 204 additions & 53 deletions

File tree

backend/src/routes/providers.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const ModelStateSchema = z.object({
2323

2424
const UpdateModelStateSchema = z.object({
2525
recent: ModelSelectionSchema.optional(),
26+
favorite: ModelSelectionSchema.optional(),
2627
})
2728

2829
type ModelSelection = z.infer<typeof ModelSelectionSchema>
@@ -63,6 +64,17 @@ async function addRecentModel(model: ModelSelection): Promise<ModelState> {
6364
return nextState
6465
}
6566

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+
}
77+
6678
async function reloadOpenCodeConfig(openCodeSupervisor?: OpenCodeSupervisor): Promise<void> {
6779
if (openCodeSupervisor) {
6880
await openCodeSupervisor.reloadConfig('settings_reload')
@@ -89,6 +101,9 @@ export function createProvidersRoutes(openCodeSupervisor?: OpenCodeSupervisor) {
89101
try {
90102
const body = await c.req.json()
91103
const validated = UpdateModelStateSchema.parse(body)
104+
if (validated.favorite) {
105+
return c.json(await toggleFavoriteModel(validated.favorite))
106+
}
92107
if (!validated.recent) {
93108
return c.json(await readModelState())
94109
}

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: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -168,9 +168,11 @@ export interface ProvidersResult {
168168
default: Record<string, string>;
169169
}
170170

171-
async function getProvidersFromOpenCodeServer(): Promise<ProvidersResult> {
171+
async function getProvidersFromOpenCodeServer(directory?: string): Promise<ProvidersResult> {
172172
try {
173-
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+
});
174176

175177
if (response?.all && Array.isArray(response.all)) {
176178
const connectedSet = new Set(response.connected || []);
@@ -231,8 +233,8 @@ async function getProvidersFromOpenCodeServer(): Promise<ProvidersResult> {
231233
return { providers: [], connected: [], default: {} };
232234
}
233235

234-
export async function getProviders(): Promise<ProvidersResult> {
235-
return await getProvidersFromOpenCodeServer();
236+
export async function getProviders(directory?: string): Promise<ProvidersResult> {
237+
return await getProvidersFromOpenCodeServer(directory);
236238
}
237239

238240
export async function getOpenCodeModelState(): Promise<OpenCodeModelState> {
@@ -247,6 +249,14 @@ export async function addOpenCodeRecentModel(model: ModelSelection): Promise<Ope
247249
});
248250
}
249251

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+
});
258+
}
259+
250260
async function getConfiguredProviders(connectedIds: Set<string>): Promise<ProviderWithModels[]> {
251261
try {
252262
const config = await settingsApi.getDefaultOpenCodeConfig();
@@ -296,8 +306,8 @@ async function getConfiguredProviders(connectedIds: Set<string>): Promise<Provid
296306
}
297307
}
298308

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

303313
const configuredProviders = await getConfiguredProviders(connectedIds);
@@ -339,8 +349,9 @@ export async function getProvidersWithModels(): Promise<ProviderWithModels[]> {
339349
export async function getModel(
340350
providerId: string,
341351
modelId: string,
352+
directory?: string,
342353
): Promise<Model | null> {
343-
const providers = await getProvidersWithModels();
354+
const providers = await getProvidersWithModels(directory);
344355
const provider = providers.find((p) => p.id === providerId);
345356
if (!provider) return null;
346357

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
})

frontend/src/components/model/ModelQuickSelect.tsx

Lines changed: 60 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
import { useCallback, useMemo } from 'react'
2-
import { Check, ChevronRight, Clock, Sparkles } from 'lucide-react'
2+
import { Check, ChevronRight, Star } from 'lucide-react'
33
import {
44
DropdownMenu,
55
DropdownMenuContent,
66
DropdownMenuItem,
7-
DropdownMenuLabel,
8-
DropdownMenuSeparator,
97
DropdownMenuTrigger,
108
} from '@/components/ui/dropdown-menu'
119
import { useModelSelection } from '@/hooks/useModelSelection'
1210
import { useVariants } from '@/hooks/useVariants'
13-
import { formatModelName, getProviders } from '@/api/providers'
11+
import { formatModelName, formatProviderName, getProviders } from '@/api/providers'
1412
import { useQuery } from '@tanstack/react-query'
1513
import { useOpenCodeClient } from '@/hooks/useOpenCode'
1614

@@ -29,34 +27,40 @@ export function ModelQuickSelect({
2927
disabled,
3028
children,
3129
}: ModelQuickSelectProps) {
32-
const { modelString, recentModels, favoriteModels, setModel } = useModelSelection(opcodeUrl, directory)
30+
const { model, modelString, recentModels, favoriteModels, setModel, toggleFavorite } = useModelSelection(opcodeUrl, directory)
3331
const { availableVariants, currentVariant, setVariant, clearVariant, hasVariants } = useVariants(opcodeUrl, directory)
3432
const client = useOpenCodeClient(opcodeUrl, directory)
3533

3634
const { data: providersData } = useQuery({
3735
queryKey: ['opencode', 'providers', opcodeUrl, directory],
38-
queryFn: () => getProviders(),
36+
queryFn: () => getProviders(directory),
3937
enabled: !!client,
4038
staleTime: 30000,
4139
})
4240

4341
const getDisplayName = useCallback((providerID: string, modelID: string) => {
4442
const modelData = providersData?.providers
45-
.find(provider => provider.id === providerID)
46-
?.models?.[modelID]
43+
.find(provider => provider.id === providerID)
44+
?.models?.[modelID]
4745
return modelData ? formatModelName(modelData) : modelID
4846
}, [providersData])
4947

48+
const getProviderName = useCallback((providerID: string) => {
49+
const provider = providersData?.providers.find(provider => provider.id === providerID)
50+
return provider ? formatProviderName(provider) : providerID
51+
}, [providersData])
52+
5053
const favoriteModelsWithNames = useMemo(() => {
5154
return favoriteModels
5255
.filter(favorite => `${favorite.providerID}/${favorite.modelID}` !== modelString)
5356
.slice(0, 5)
5457
.map(favorite => ({
55-
...favorite,
56-
displayName: getDisplayName(favorite.providerID, favorite.modelID),
57-
key: `${favorite.providerID}/${favorite.modelID}`,
58-
}))
59-
}, [favoriteModels, getDisplayName, modelString])
58+
...favorite,
59+
displayName: getDisplayName(favorite.providerID, favorite.modelID),
60+
providerName: getProviderName(favorite.providerID),
61+
key: `${favorite.providerID}/${favorite.modelID}`,
62+
}))
63+
}, [favoriteModels, getDisplayName, getProviderName, modelString])
6064

6165
const recentModelsWithNames = useMemo(() => {
6266
return recentModels
@@ -66,11 +70,21 @@ export function ModelQuickSelect({
6670
})
6771
.slice(0, 5)
6872
.map(recent => ({
69-
...recent,
70-
displayName: getDisplayName(recent.providerID, recent.modelID),
71-
key: `${recent.providerID}/${recent.modelID}`,
72-
}))
73-
}, [recentModels, favoriteModels, getDisplayName, modelString])
73+
...recent,
74+
displayName: getDisplayName(recent.providerID, recent.modelID),
75+
providerName: getProviderName(recent.providerID),
76+
key: `${recent.providerID}/${recent.modelID}`,
77+
}))
78+
}, [recentModels, favoriteModels, getDisplayName, getProviderName, modelString])
79+
80+
const duplicateDisplayNames = useMemo(() => {
81+
const counts = [...favoriteModelsWithNames, ...recentModelsWithNames].reduce<Record<string, number>>((acc, item) => {
82+
acc[item.displayName] = (acc[item.displayName] || 0) + 1
83+
return acc
84+
}, {})
85+
86+
return new Set(Object.entries(counts).filter(([, count]) => count > 1).map(([name]) => name))
87+
}, [favoriteModelsWithNames, recentModelsWithNames])
7488

7589
const handleVariantSelect = (variant: string | undefined) => {
7690
if (variant === undefined) {
@@ -84,6 +98,14 @@ export function ModelQuickSelect({
8498
setModel({ providerID, modelID })
8599
}
86100

101+
const handleCurrentFavoriteToggle = () => {
102+
if (!model) return
103+
toggleFavorite(model)
104+
}
105+
106+
const isCurrentFavorite = model
107+
? favoriteModels.some((favorite) => favorite.providerID === model.providerID && favorite.modelID === model.modelID)
108+
: false
87109
const hasFavorites = favoriteModelsWithNames.length > 0
88110
const hasRecents = recentModelsWithNames.length > 0
89111

@@ -93,12 +115,18 @@ export function ModelQuickSelect({
93115
{children}
94116
</DropdownMenuTrigger>
95117
<DropdownMenuContent align="start" className="w-56">
118+
{model && (
119+
<DropdownMenuItem
120+
onClick={handleCurrentFavoriteToggle}
121+
className="flex items-center justify-between"
122+
>
123+
<span>{isCurrentFavorite ? 'Remove from favorites' : 'Add to favorites'}</span>
124+
<Star className={`h-4 w-4 ${isCurrentFavorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />
125+
</DropdownMenuItem>
126+
)}
127+
96128
{hasVariants && (
97129
<>
98-
<DropdownMenuLabel className="flex items-center gap-1.5">
99-
<Sparkles className="h-3 w-3" />
100-
Thinking Effort
101-
</DropdownMenuLabel>
102130
<DropdownMenuItem
103131
onClick={() => handleVariantSelect(undefined)}
104132
className="flex items-center justify-between"
@@ -116,47 +144,44 @@ export function ModelQuickSelect({
116144
{currentVariant === variant && <Check className="h-4 w-4" />}
117145
</DropdownMenuItem>
118146
))}
119-
{(hasFavorites || hasRecents) && <DropdownMenuSeparator />}
120147
</>
121148
)}
122149

123150
{hasFavorites && (
124151
<>
125-
<DropdownMenuLabel className="flex items-center gap-1.5">
126-
<Sparkles className="h-3 w-3" />
127-
Favorite Models
128-
</DropdownMenuLabel>
129152
{favoriteModelsWithNames.map((favorite) => (
130153
<DropdownMenuItem
131154
key={favorite.key}
132155
onClick={() => handleModelSelect(favorite.providerID, favorite.modelID)}
133156
className="flex items-center justify-between"
134157
>
135-
<span className="truncate">{favorite.displayName}</span>
158+
<span className="truncate">
159+
{duplicateDisplayNames.has(favorite.displayName)
160+
? `${favorite.providerName}/${favorite.displayName}`
161+
: favorite.displayName}
162+
</span>
136163
{modelString === favorite.key && <Check className="h-4 w-4" />}
137164
</DropdownMenuItem>
138165
))}
139-
{hasRecents && <DropdownMenuSeparator />}
140166
</>
141167
)}
142168

143169
{hasRecents && (
144170
<>
145-
<DropdownMenuLabel className="flex items-center gap-1.5">
146-
<Clock className="h-3 w-3" />
147-
Recent Models
148-
</DropdownMenuLabel>
149171
{recentModelsWithNames.map((recent) => (
150172
<DropdownMenuItem
151173
key={recent.key}
152174
onClick={() => handleModelSelect(recent.providerID, recent.modelID)}
153175
className="flex items-center justify-between"
154176
>
155-
<span className="truncate">{recent.displayName}</span>
177+
<span className="truncate">
178+
{duplicateDisplayNames.has(recent.displayName)
179+
? `${recent.providerName}/${recent.displayName}`
180+
: recent.displayName}
181+
</span>
156182
{modelString === recent.key && <Check className="h-4 w-4" />}
157183
</DropdownMenuItem>
158184
))}
159-
<DropdownMenuSeparator />
160185
</>
161186
)}
162187

0 commit comments

Comments
 (0)