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 { CommentsProvider } from "@/context/comments"
import { NotificationProvider } from "@/context/notification"
import { ModelsProvider } from "@/context/models"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command"
import { LanguageProvider, useLanguage } from "@/context/language"
@@ -116,9 +117,11 @@ export function AppInterface(props: { defaultUrl?: string }) {
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
<CommandProvider>
<Layout>{props.children}</Layout>
</CommandProvider>
<ModelsProvider>
<CommandProvider>
<Layout>{props.children}</Layout>
</CommandProvider>
</ModelsProvider>
</NotificationProvider>
</LayoutProvider>
</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 Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { type ModelKey, useLocal } from "@/context/local"
import { useModels } from "@/context/models"
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 = () => {
const local = useLocal()
const language = useLanguage()
const models = useModels()
const list = useFilteredList<ModelItem>({
items: (_filter) => local.model.list(),
items: (_filter) => models.list(),
key: (x) => `${x.provider.id}:${x.id}`,
filterKeys: ["provider.name", "name", "id"],
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">
<For each={group.items}>
{(item) => {
const key: ModelKey = { providerID: item.provider.id, modelID: item.id }
const key = { providerID: item.provider.id, modelID: item.id }
return (
<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">
@@ -111,9 +111,9 @@ export const SettingsModels: Component = () => {
</div>
<div class="flex-shrink-0">
<Switch
checked={!!local.model.visible(key)}
checked={models.visible(key)}
onChange={(checked) => {
local.model.setVisibility(key, checked)
models.setVisibility(key, checked)
}}
hideLabel
>

View File

@@ -1,14 +1,12 @@
import { createStore, produce, reconcile } from "solid-js/store"
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 { createSimpleContext } from "@opencode-ai/ui/context"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { base64Encode } from "@opencode-ai/util/encode"
import { useProviders } from "@/hooks/use-providers"
import { DateTime } from "luxon"
import { Persist, persisted } from "@/utils/persist"
import { useModels } from "@/context/models"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
@@ -112,18 +110,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})()
const model = (() => {
const [store, setStore, _, modelReady] = persisted(
Persist.global("model", ["model.v1"]),
createStore<{
user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
recent: ModelKey[]
variant?: Record<string, string | undefined>
}>({
user: [],
recent: [],
variant: {},
}),
)
const models = useModels()
const [ephemeral, setEphemeral] = createStore<{
model: Record<string, ModelKey | undefined>
@@ -131,57 +118,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
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>(() => {
if (sync.data.config.model) {
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)) {
return item
}
@@ -225,10 +161,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
fallbackModel,
)
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 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 {
ready: modelReady,
ready: models.ready,
current,
recent,
list,
list: models.list,
cycle,
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
batch(() => {
const currentAgent = agent.current()
const next = model ?? fallbackModel()
if (currentAgent) setEphemeral("model", currentAgent.name, next)
if (model) updateVisibility(model, "show")
if (options?.recent && model) {
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
if (uniq.length > 5) uniq.pop()
setStore("recent", uniq)
}
if (model) models.setVisibility(model, true)
if (options?.recent && model) models.recent.push(model)
})
},
visible(model: ModelKey) {
const key = `${model.providerID}:${model.modelID}`
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
return models.visible(model)
},
setVisibility(model: ModelKey, visible: boolean) {
updateVisibility(model, visible ? "show" : "hide")
models.setVisibility(model, visible)
},
variant: {
current() {
const m = current()
if (!m) return undefined
const key = `${m.provider.id}/${m.id}`
return store.variant?.[key]
return models.variant.get({ providerID: m.provider.id, modelID: m.id })
},
list() {
const m = current()
@@ -311,12 +225,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
set(value: string | undefined) {
const m = current()
if (!m) return
const key = `${m.provider.id}/${m.id}`
if (!store.variant) {
setStore("variant", { [key]: value })
} else {
setStore("variant", key, value)
}
models.variant.set({ providerID: m.provider.id, modelID: m.id }, value)
},
cycle() {
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,
},
}
},
})