diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 6b9f887fa..0980bedb9 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -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 }) { - - {props.children} - + + + {props.children} + + diff --git a/packages/app/src/components/settings-models.tsx b/packages/app/src/components/settings-models.tsx index a3ba45f61..3a73a42de 100644 --- a/packages/app/src/components/settings-models.tsx +++ b/packages/app/src/components/settings-models.tsx @@ -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["model"]["list"]>[number] +type ModelItem = ReturnType["list"]>[number] export const SettingsModels: Component = () => { - const local = useLocal() const language = useLanguage() + const models = useModels() const list = useFilteredList({ - 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 = () => {
{(item) => { - const key: ModelKey = { providerID: item.provider.id, modelID: item.id } + const key = { providerID: item.provider.id, modelID: item.id } return (
@@ -111,9 +111,9 @@ export const SettingsModels: Component = () => {
{ - local.model.setVisibility(key, checked) + models.setVisibility(key, checked) }} hideLabel > diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 43d47e1b1..4b27e6d37 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -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 - }>({ - user: [], - recent: [], - variant: {}, - }), - ) + const models = useModels() const [ephemeral, setEphemeral] = createStore<{ model: Record @@ -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() - 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(() => { 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() diff --git a/packages/app/src/context/models.tsx b/packages/app/src/context/models.tsx new file mode 100644 index 000000000..fee3c10c6 --- /dev/null +++ b/packages/app/src/context/models.tsx @@ -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 +} + +export const { use: useModels, provider: ModelsProvider } = createSimpleContext({ + name: "Models", + init: () => { + const providers = useProviders() + + const [store, setStore, _, ready] = persisted( + Persist.global("model", ["model.v1"]), + createStore({ + 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() + 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, + }, + } + }, +})