495 lines
15 KiB
TypeScript
495 lines
15 KiB
TypeScript
import { createStore } from "solid-js/store"
|
|
import { dedupeWith } from "effect/Array"
|
|
import { createSimpleContext } from "./helper"
|
|
import { batch, createEffect, createMemo } from "solid-js"
|
|
import { useEvent } from "./event"
|
|
import path from "path"
|
|
import { useTuiPaths } from "./runtime"
|
|
import { useArgs } from "./args"
|
|
import { useClient } from "./client"
|
|
import { RGBA } from "@opentui/core"
|
|
import { readJson, writeJsonAtomic } from "../util/persistence"
|
|
import {
|
|
createModelPreferenceRepository,
|
|
cycleModelVariant,
|
|
modelPreferenceKey,
|
|
normalizeModelVariant,
|
|
type ModelPreference,
|
|
type ModelPreferenceModel,
|
|
} from "../model-preference"
|
|
import { useTheme } from "./theme"
|
|
import { useToast } from "../ui/toast"
|
|
import { useRoute } from "./route"
|
|
import { useData } from "./data"
|
|
import { usePermission } from "./permission"
|
|
|
|
export type LocalTheme = {
|
|
secondary: RGBA
|
|
accent: RGBA
|
|
success: RGBA
|
|
warning: RGBA
|
|
primary: RGBA
|
|
error: RGBA
|
|
info: RGBA
|
|
}
|
|
|
|
export function parseModel(model: string) {
|
|
const [providerID, ...rest] = model.split("/")
|
|
return {
|
|
providerID: providerID,
|
|
modelID: rest.join("/"),
|
|
}
|
|
}
|
|
|
|
export function recentModels(model: ModelPreferenceModel, recent: ModelPreferenceModel[]) {
|
|
const seen = new Set<string>()
|
|
return [model, ...recent]
|
|
.filter((item) => {
|
|
const key = modelPreferenceKey(item)
|
|
if (seen.has(key)) return false
|
|
seen.add(key)
|
|
return true
|
|
})
|
|
.slice(0, 10)
|
|
.map((item) => ({ providerID: item.providerID, modelID: item.modelID }))
|
|
}
|
|
|
|
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|
name: "Local",
|
|
init: () => {
|
|
const data = useData()
|
|
const client = useClient()
|
|
const toast = useToast()
|
|
const { theme, themeV2, mode } = useTheme()
|
|
const route = useRoute()
|
|
const paths = useTuiPaths()
|
|
const args = useArgs()
|
|
const event = useEvent()
|
|
const permission = usePermission()
|
|
|
|
function isModelValid(model: ModelPreferenceModel) {
|
|
return !!data.location.model
|
|
.list()
|
|
?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
|
|
}
|
|
|
|
function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) {
|
|
for (const modelFn of modelFns) {
|
|
const model = modelFn()
|
|
if (!model) continue
|
|
if (isModelValid(model)) return model
|
|
}
|
|
}
|
|
|
|
function createAgent() {
|
|
const agents = createMemo(() =>
|
|
(data.location.agent.list() ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden),
|
|
)
|
|
const visibleAgents = createMemo(() => (data.location.agent.list() ?? []).filter((agent) => !agent.hidden))
|
|
const [agentStore, setAgentStore] = createStore({
|
|
current: undefined as string | undefined,
|
|
})
|
|
const colors = createMemo(() => {
|
|
const step = mode() === "light" ? 800 : 200
|
|
return dedupeWith(themeV2.categorical(step), (first, second) => first.equals(second))
|
|
})
|
|
return {
|
|
list() {
|
|
return agents()
|
|
},
|
|
current() {
|
|
return agents().find((agent) => agent.id === agentStore.current) ?? agents().at(0)
|
|
},
|
|
set(id: string) {
|
|
if (!agents().some((agent) => agent.id === id))
|
|
return toast.show({
|
|
variant: "warning",
|
|
message: `Agent not found: ${id}`,
|
|
duration: 3000,
|
|
})
|
|
setAgentStore("current", id)
|
|
},
|
|
move(direction: 1 | -1) {
|
|
batch(() => {
|
|
const current = this.current()
|
|
if (!current) return
|
|
let next = agents().findIndex((agent) => agent.id === current.id) + direction
|
|
if (next < 0) next = agents().length - 1
|
|
if (next >= agents().length) next = 0
|
|
const value = agents()[next]
|
|
setAgentStore("current", value.id)
|
|
})
|
|
},
|
|
color(id: string) {
|
|
const index = visibleAgents().findIndex((agent) => agent.id === id)
|
|
if (index === -1) return colors()[0]
|
|
const agent = visibleAgents()[index]
|
|
|
|
if (agent?.color) {
|
|
const color = agent.color
|
|
if (color.startsWith("#")) return RGBA.fromHex(color)
|
|
// already validated by config, just satisfying TS here
|
|
return theme[color as keyof typeof theme] as RGBA
|
|
}
|
|
return colors()[index % colors().length]
|
|
},
|
|
}
|
|
}
|
|
|
|
const agent = createAgent()
|
|
|
|
function createModel() {
|
|
const [modelStore, setModelStore] = createStore<
|
|
ModelPreference & {
|
|
ready: boolean
|
|
model: Record<string, ModelPreferenceModel>
|
|
}
|
|
>({
|
|
ready: false,
|
|
model: {},
|
|
recent: [],
|
|
favorite: [],
|
|
variant: {},
|
|
})
|
|
|
|
const repository = createModelPreferenceRepository(path.join(paths.state, "model.json"))
|
|
const state = {
|
|
pending: false,
|
|
}
|
|
|
|
function save() {
|
|
if (!modelStore.ready) {
|
|
state.pending = true
|
|
return
|
|
}
|
|
state.pending = false
|
|
void repository
|
|
.patch({
|
|
recent: modelStore.recent,
|
|
favorite: modelStore.favorite,
|
|
variant: modelStore.variant,
|
|
})
|
|
.catch(() => undefined)
|
|
}
|
|
|
|
repository
|
|
.load()
|
|
.then((value) => {
|
|
setModelStore("recent", value.recent)
|
|
setModelStore("favorite", value.favorite)
|
|
setModelStore("variant", value.variant)
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => {
|
|
setModelStore("ready", true)
|
|
if (state.pending) save()
|
|
})
|
|
|
|
const fallbackModel = createMemo(() => {
|
|
if (args.model) {
|
|
const { providerID, modelID } = parseModel(args.model)
|
|
if (isModelValid({ providerID, modelID })) {
|
|
return {
|
|
providerID,
|
|
modelID,
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const item of modelStore.recent) {
|
|
if (isModelValid(item)) {
|
|
return item
|
|
}
|
|
}
|
|
|
|
const model = data.location.model.list()?.[0]
|
|
if (!model) return undefined
|
|
return {
|
|
providerID: model.providerID,
|
|
modelID: model.id,
|
|
}
|
|
})
|
|
|
|
const currentModel = createMemo(() => {
|
|
const a = agent.current()
|
|
return (
|
|
getFirstValidModel(
|
|
() => a && modelStore.model[a.id],
|
|
() => a?.model && { providerID: a.model.providerID, modelID: a.model.id },
|
|
fallbackModel,
|
|
) ?? undefined
|
|
)
|
|
})
|
|
|
|
return {
|
|
current: currentModel,
|
|
get ready() {
|
|
return modelStore.ready
|
|
},
|
|
recent() {
|
|
return modelStore.recent
|
|
},
|
|
favorite() {
|
|
return modelStore.favorite
|
|
},
|
|
parsed: createMemo(() => {
|
|
const value = currentModel()
|
|
if (!value) {
|
|
return {
|
|
provider: "Connect a provider",
|
|
model: "No provider selected",
|
|
reasoning: false,
|
|
}
|
|
}
|
|
const provider = data.location.provider.list()?.find((item) => item.id === value.providerID)
|
|
const info = data.location.model
|
|
.list()
|
|
?.find((item) => item.providerID === value.providerID && item.id === value.modelID)
|
|
return {
|
|
provider: provider?.name ?? value.providerID,
|
|
model: info?.name ?? value.modelID,
|
|
reasoning: (info?.variants?.length ?? 0) !== 0,
|
|
}
|
|
}),
|
|
cycle(direction: 1 | -1) {
|
|
const current = currentModel()
|
|
if (!current) return
|
|
const recent = modelStore.recent
|
|
const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
|
|
if (index === -1) return
|
|
let next = index + direction
|
|
if (next < 0) next = recent.length - 1
|
|
if (next >= recent.length) next = 0
|
|
const val = recent[next]
|
|
if (!val) return
|
|
const a = agent.current()
|
|
if (!a) return
|
|
setModelStore("model", a.id, { ...val })
|
|
},
|
|
cycleFavorite(direction: 1 | -1) {
|
|
const favorites = modelStore.favorite.filter((item) => isModelValid(item))
|
|
if (!favorites.length) {
|
|
toast.show({
|
|
variant: "info",
|
|
message: "Add a favorite model to use this shortcut",
|
|
duration: 3000,
|
|
})
|
|
return
|
|
}
|
|
const current = currentModel()
|
|
let index = -1
|
|
if (current) {
|
|
index = favorites.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
|
|
}
|
|
if (index === -1) {
|
|
index = direction === 1 ? 0 : favorites.length - 1
|
|
} else {
|
|
index += direction
|
|
if (index < 0) index = favorites.length - 1
|
|
if (index >= favorites.length) index = 0
|
|
}
|
|
const next = favorites[index]
|
|
if (!next) return
|
|
const a = agent.current()
|
|
if (!a) return
|
|
setModelStore("model", a.id, { ...next })
|
|
setModelStore("recent", recentModels(next, modelStore.recent))
|
|
save()
|
|
},
|
|
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
|
|
batch(() => {
|
|
if (!isModelValid(model)) {
|
|
toast.show({
|
|
message: `Model ${model.providerID}/${model.modelID} is not valid`,
|
|
variant: "warning",
|
|
duration: 3000,
|
|
})
|
|
return
|
|
}
|
|
const a = agent.current()
|
|
if (!a) return
|
|
setModelStore("model", a.id, model)
|
|
if (options?.recent) {
|
|
setModelStore("recent", recentModels(model, modelStore.recent))
|
|
save()
|
|
}
|
|
})
|
|
},
|
|
toggleFavorite(model: { providerID: string; modelID: string }) {
|
|
batch(() => {
|
|
if (!isModelValid(model)) {
|
|
toast.show({
|
|
message: `Model ${model.providerID}/${model.modelID} is not valid`,
|
|
variant: "warning",
|
|
duration: 3000,
|
|
})
|
|
return
|
|
}
|
|
const exists = modelStore.favorite.some(
|
|
(x) => x.providerID === model.providerID && x.modelID === model.modelID,
|
|
)
|
|
const next = exists
|
|
? modelStore.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID)
|
|
: [model, ...modelStore.favorite]
|
|
setModelStore(
|
|
"favorite",
|
|
next.map((x) => ({ providerID: x.providerID, modelID: x.modelID })),
|
|
)
|
|
save()
|
|
})
|
|
},
|
|
variant: {
|
|
selected() {
|
|
const m = currentModel()
|
|
if (!m) return undefined
|
|
return normalizeModelVariant(modelStore.variant[modelPreferenceKey(m)])
|
|
},
|
|
current() {
|
|
const v = this.selected()
|
|
if (v && this.list().includes(v)) return v
|
|
return undefined
|
|
},
|
|
list() {
|
|
const m = currentModel()
|
|
if (!m) return []
|
|
const info = data.location.model
|
|
.list()
|
|
?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
|
|
return info?.variants?.map((variant) => variant.id) ?? []
|
|
},
|
|
set(value: string | undefined) {
|
|
const m = currentModel()
|
|
if (!m) return
|
|
setModelStore("variant", modelPreferenceKey(m), normalizeModelVariant(value))
|
|
save()
|
|
},
|
|
cycle() {
|
|
const variants = this.list()
|
|
if (variants.length === 0) return
|
|
this.set(cycleModelVariant(this.current(), variants))
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
const model = createModel()
|
|
|
|
function createSession() {
|
|
const [sessionStore, setSessionStore] = createStore<{
|
|
ready: boolean
|
|
pinned: string[]
|
|
}>({
|
|
ready: false,
|
|
pinned: [],
|
|
})
|
|
|
|
const filePath = path.join(paths.state, "session.json")
|
|
const state = {
|
|
pending: false,
|
|
}
|
|
|
|
function save() {
|
|
if (!sessionStore.ready) {
|
|
state.pending = true
|
|
return
|
|
}
|
|
state.pending = false
|
|
void writeJsonAtomic(filePath, {
|
|
pinned: sessionStore.pinned,
|
|
})
|
|
}
|
|
|
|
readJson<unknown>(filePath)
|
|
.then((x) => {
|
|
if (!x || typeof x !== "object") return
|
|
const pinned = (x as Record<string, unknown>).pinned
|
|
if (Array.isArray(pinned))
|
|
setSessionStore(
|
|
"pinned",
|
|
pinned.filter((item): item is string => typeof item === "string"),
|
|
)
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => {
|
|
setSessionStore("ready", true)
|
|
if (state.pending) save()
|
|
})
|
|
|
|
const slots = createMemo(() => {
|
|
const existing = new Set(
|
|
data.session
|
|
.list()
|
|
.filter((x) => x.parentID === undefined)
|
|
.map((x) => x.id),
|
|
)
|
|
return sessionStore.pinned.filter((id) => existing.has(id)).slice(0, 9)
|
|
})
|
|
|
|
function prune(sessionID: string) {
|
|
batch(() => {
|
|
if (sessionStore.pinned.includes(sessionID)) {
|
|
setSessionStore(
|
|
"pinned",
|
|
sessionStore.pinned.filter((x) => x !== sessionID),
|
|
)
|
|
}
|
|
save()
|
|
})
|
|
}
|
|
|
|
event.on("session.deleted", (evt) => {
|
|
prune(evt.data.sessionID)
|
|
})
|
|
|
|
return {
|
|
get ready() {
|
|
return sessionStore.ready
|
|
},
|
|
pinned() {
|
|
return sessionStore.pinned
|
|
},
|
|
slots,
|
|
isPinned(sessionID: string) {
|
|
return sessionStore.pinned.includes(sessionID)
|
|
},
|
|
togglePin(sessionID: string) {
|
|
batch(() => {
|
|
const exists = sessionStore.pinned.includes(sessionID)
|
|
const next = exists
|
|
? sessionStore.pinned.filter((x) => x !== sessionID)
|
|
: [...sessionStore.pinned, sessionID]
|
|
setSessionStore("pinned", next)
|
|
save()
|
|
})
|
|
},
|
|
quickSwitch(slot: number) {
|
|
const target = slots()[slot - 1]
|
|
if (!target) return
|
|
if (route.data.type === "session" && route.data.sessionID === target) return
|
|
route.navigate({ type: "session", sessionID: target })
|
|
},
|
|
}
|
|
}
|
|
|
|
const session = createSession()
|
|
|
|
createEffect(() => {
|
|
const value = agent.current()
|
|
if (!value?.model) return
|
|
if (isModelValid({ providerID: value.model.providerID, modelID: value.model.id })) return
|
|
toast.show({
|
|
variant: "warning",
|
|
message: `Agent ${value.id}'s configured model ${value.model.providerID}/${value.model.id} is not valid`,
|
|
duration: 3000,
|
|
})
|
|
})
|
|
|
|
const result = {
|
|
model,
|
|
agent,
|
|
session,
|
|
permission,
|
|
}
|
|
return result
|
|
},
|
|
})
|