feat(app): model settings

This commit is contained in:
adamelmore
2026-01-26 08:14:33 -06:00
parent 1934ee13d8
commit 84b12a8fb7
4 changed files with 166 additions and 114 deletions

View File

@@ -21,6 +21,7 @@ import { PromptProvider } from "@/context/prompt"
import { FileProvider } from "@/context/file" import { FileProvider } from "@/context/file"
import { CommentsProvider } from "@/context/comments" import { CommentsProvider } from "@/context/comments"
import { NotificationProvider } from "@/context/notification" import { NotificationProvider } from "@/context/notification"
import { ModelsProvider } from "@/context/models"
import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command" import { CommandProvider } from "@/context/command"
import { LanguageProvider, useLanguage } from "@/context/language" import { LanguageProvider, useLanguage } from "@/context/language"
@@ -116,9 +117,11 @@ export function AppInterface(props: { defaultUrl?: string }) {
<PermissionProvider> <PermissionProvider>
<LayoutProvider> <LayoutProvider>
<NotificationProvider> <NotificationProvider>
<CommandProvider> <ModelsProvider>
<Layout>{props.children}</Layout> <CommandProvider>
</CommandProvider> <Layout>{props.children}</Layout>
</CommandProvider>
</ModelsProvider>
</NotificationProvider> </NotificationProvider>
</LayoutProvider> </LayoutProvider>
</PermissionProvider> </PermissionProvider>

View File

@@ -7,17 +7,17 @@ import { TextField } from "@opencode-ai/ui/text-field"
import type { IconName } from "@opencode-ai/ui/icons/provider" import type { IconName } from "@opencode-ai/ui/icons/provider"
import { type Component, For, Show } from "solid-js" import { type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { type ModelKey, useLocal } from "@/context/local" import { useModels } from "@/context/models"
import { popularProviders } from "@/hooks/use-providers" import { popularProviders } from "@/hooks/use-providers"
type ModelItem = ReturnType<ReturnType<typeof useLocal>["model"]["list"]>[number] type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
export const SettingsModels: Component = () => { export const SettingsModels: Component = () => {
const local = useLocal()
const language = useLanguage() const language = useLanguage()
const models = useModels()
const list = useFilteredList<ModelItem>({ const list = useFilteredList<ModelItem>({
items: (_filter) => local.model.list(), items: (_filter) => models.list(),
key: (x) => `${x.provider.id}:${x.id}`, key: (x) => `${x.provider.id}:${x.id}`,
filterKeys: ["provider.name", "name", "id"], filterKeys: ["provider.name", "name", "id"],
sortBy: (a, b) => a.name.localeCompare(b.name), sortBy: (a, b) => a.name.localeCompare(b.name),
@@ -103,7 +103,7 @@ export const SettingsModels: Component = () => {
<div class="bg-surface-raised-base px-4 rounded-lg"> <div class="bg-surface-raised-base px-4 rounded-lg">
<For each={group.items}> <For each={group.items}>
{(item) => { {(item) => {
const key: ModelKey = { providerID: item.provider.id, modelID: item.id } const key = { providerID: item.provider.id, modelID: item.id }
return ( return (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none"> <div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<div class="min-w-0"> <div class="min-w-0">
@@ -111,9 +111,9 @@ export const SettingsModels: Component = () => {
</div> </div>
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<Switch <Switch
checked={!!local.model.visible(key)} checked={models.visible(key)}
onChange={(checked) => { onChange={(checked) => {
local.model.setVisibility(key, checked) models.setVisibility(key, checked)
}} }}
hideLabel hideLabel
> >

View File

@@ -1,14 +1,12 @@
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup } from "solid-js" import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2" import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useSync } from "./sync" import { useSync } from "./sync"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { DateTime } from "luxon" import { useModels } from "@/context/models"
import { Persist, persisted } from "@/utils/persist"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -112,18 +110,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})() })()
const model = (() => { const model = (() => {
const [store, setStore, _, modelReady] = persisted( const models = useModels()
Persist.global("model", ["model.v1"]),
createStore<{
user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
recent: ModelKey[]
variant?: Record<string, string | undefined>
}>({
user: [],
recent: [],
variant: {},
}),
)
const [ephemeral, setEphemeral] = createStore<{ const [ephemeral, setEphemeral] = createStore<{
model: Record<string, ModelKey | undefined> model: Record<string, ModelKey | undefined>
@@ -131,57 +118,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model: {}, model: {},
}) })
const available = createMemo(() =>
providers.connected().flatMap((p) =>
Object.values(p.models).map((m) => ({
...m,
provider: p,
})),
),
)
const latest = createMemo(() =>
pipe(
available(),
filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6),
groupBy((x) => x.provider.id),
mapValues((models) =>
pipe(
models,
groupBy((x) => x.family),
values(),
(groups) =>
groups.flatMap((g) => {
const first = firstBy(g, [(x) => x.release_date, "desc"])
return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
}),
),
),
values(),
flat(),
),
)
const latestSet = createMemo(() => new Set(latest().map((x) => `${x.providerID}:${x.modelID}`)))
const userVisibilityMap = createMemo(() => {
const map = new Map<string, "show" | "hide">()
for (const item of store.user) {
map.set(`${item.providerID}:${item.modelID}`, item.visibility)
}
return map
})
const list = createMemo(() =>
available().map((m) => ({
...m,
name: m.name.replace("(latest)", "").trim(),
latest: m.name.includes("(latest)"),
})),
)
const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
const fallbackModel = createMemo<ModelKey | undefined>(() => { const fallbackModel = createMemo<ModelKey | undefined>(() => {
if (sync.data.config.model) { if (sync.data.config.model) {
const [providerID, modelID] = sync.data.config.model.split("/") const [providerID, modelID] = sync.data.config.model.split("/")
@@ -193,7 +129,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
} }
for (const item of store.recent) { for (const item of models.recent.list()) {
if (isModelValid(item)) { if (isModelValid(item)) {
return item return item
} }
@@ -225,10 +161,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
fallbackModel, fallbackModel,
) )
if (!key) return undefined if (!key) return undefined
return find(key) return models.find(key)
}) })
const recent = createMemo(() => store.recent.map(find).filter(Boolean)) const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
const cycle = (direction: 1 | -1) => { const cycle = (direction: 1 | -1) => {
const recentList = recent() const recentList = recent()
@@ -253,54 +189,32 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}) })
} }
function updateVisibility(model: ModelKey, visibility: "show" | "hide") {
const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
if (index >= 0) {
setStore("user", index, { visibility })
} else {
setStore("user", store.user.length, { ...model, visibility })
}
}
return { return {
ready: modelReady, ready: models.ready,
current, current,
recent, recent,
list, list: models.list,
cycle, cycle,
set(model: ModelKey | undefined, options?: { recent?: boolean }) { set(model: ModelKey | undefined, options?: { recent?: boolean }) {
batch(() => { batch(() => {
const currentAgent = agent.current() const currentAgent = agent.current()
const next = model ?? fallbackModel() const next = model ?? fallbackModel()
if (currentAgent) setEphemeral("model", currentAgent.name, next) if (currentAgent) setEphemeral("model", currentAgent.name, next)
if (model) updateVisibility(model, "show") if (model) models.setVisibility(model, true)
if (options?.recent && model) { if (options?.recent && model) models.recent.push(model)
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
if (uniq.length > 5) uniq.pop()
setStore("recent", uniq)
}
}) })
}, },
visible(model: ModelKey) { visible(model: ModelKey) {
const key = `${model.providerID}:${model.modelID}` return models.visible(model)
const visibility = userVisibilityMap().get(key)
if (visibility === "hide") return false
if (visibility === "show") return true
if (latestSet().has(key)) return true
// For models without valid release_date (e.g. custom models), show by default
const m = find(model)
if (!m?.release_date || !DateTime.fromISO(m.release_date).isValid) return true
return false
}, },
setVisibility(model: ModelKey, visible: boolean) { setVisibility(model: ModelKey, visible: boolean) {
updateVisibility(model, visible ? "show" : "hide") models.setVisibility(model, visible)
}, },
variant: { variant: {
current() { current() {
const m = current() const m = current()
if (!m) return undefined if (!m) return undefined
const key = `${m.provider.id}/${m.id}` return models.variant.get({ providerID: m.provider.id, modelID: m.id })
return store.variant?.[key]
}, },
list() { list() {
const m = current() const m = current()
@@ -311,12 +225,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
set(value: string | undefined) { set(value: string | undefined) {
const m = current() const m = current()
if (!m) return if (!m) return
const key = `${m.provider.id}/${m.id}` models.variant.set({ providerID: m.provider.id, modelID: m.id }, value)
if (!store.variant) {
setStore("variant", { [key]: value })
} else {
setStore("variant", key, value)
}
}, },
cycle() { cycle() {
const variants = this.list() const variants = this.list()

View File

@@ -0,0 +1,140 @@
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { DateTime } from "luxon"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useProviders } from "@/hooks/use-providers"
import { Persist, persisted } from "@/utils/persist"
export type ModelKey = { providerID: string; modelID: string }
type Visibility = "show" | "hide"
type User = ModelKey & { visibility: Visibility; favorite?: boolean }
type Store = {
user: User[]
recent: ModelKey[]
variant?: Record<string, string | undefined>
}
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
name: "Models",
init: () => {
const providers = useProviders()
const [store, setStore, _, ready] = persisted(
Persist.global("model", ["model.v1"]),
createStore<Store>({
user: [],
recent: [],
variant: {},
}),
)
const available = createMemo(() =>
providers.connected().flatMap((p) =>
Object.values(p.models).map((m) => ({
...m,
provider: p,
})),
),
)
const latest = createMemo(() =>
pipe(
available(),
filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6),
groupBy((x) => x.provider.id),
mapValues((models) =>
pipe(
models,
groupBy((x) => x.family),
values(),
(groups) =>
groups.flatMap((g) => {
const first = firstBy(g, [(x) => x.release_date, "desc"])
return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
}),
),
),
values(),
flat(),
),
)
const latestSet = createMemo(() => new Set(latest().map((x) => `${x.providerID}:${x.modelID}`)))
const visibility = createMemo(() => {
const map = new Map<string, Visibility>()
for (const item of store.user) map.set(`${item.providerID}:${item.modelID}`, item.visibility)
return map
})
const list = createMemo(() =>
available().map((m) => ({
...m,
name: m.name.replace("(latest)", "").trim(),
latest: m.name.includes("(latest)"),
})),
)
const find = (key: ModelKey) => list().find((m) => m.id === key.modelID && m.provider.id === key.providerID)
function update(model: ModelKey, state: Visibility) {
const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
if (index >= 0) {
setStore("user", index, { visibility: state })
return
}
setStore("user", store.user.length, { ...model, visibility: state })
}
const visible = (model: ModelKey) => {
const key = `${model.providerID}:${model.modelID}`
const state = visibility().get(key)
if (state === "hide") return false
if (state === "show") return true
if (latestSet().has(key)) return true
const m = find(model)
if (!m?.release_date || !DateTime.fromISO(m.release_date).isValid) return true
return false
}
const setVisibility = (model: ModelKey, state: boolean) => {
update(model, state ? "show" : "hide")
}
const push = (model: ModelKey) => {
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
if (uniq.length > 5) uniq.pop()
setStore("recent", uniq)
}
const variantKey = (model: ModelKey) => `${model.providerID}/${model.modelID}`
const getVariant = (model: ModelKey) => store.variant?.[variantKey(model)]
const setVariant = (model: ModelKey, value: string | undefined) => {
const key = variantKey(model)
if (!store.variant) {
setStore("variant", { [key]: value })
return
}
setStore("variant", key, value)
}
return {
ready,
list,
find,
visible,
setVisibility,
recent: {
list: createMemo(() => store.recent),
push,
},
variant: {
get: getVariant,
set: setVariant,
},
}
},
})