feat(app): model settings
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
140
packages/app/src/context/models.tsx
Normal file
140
packages/app/src/context/models.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user