refactor(server): canonicalize service API (#31049)
This commit is contained in:
@@ -0,0 +1,548 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { batch, createEffect, createMemo } from "solid-js"
|
||||
import { useSync } from "./sync"
|
||||
import { useEvent } from "./event"
|
||||
import path from "path"
|
||||
import { useTuiEnvironment } from "../runtime"
|
||||
import { useArgs } from "./args"
|
||||
import { useSDK } from "./sdk"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { readJson, writeJsonAtomic } from "../util/persistence"
|
||||
|
||||
export type LocalTheme = {
|
||||
secondary: RGBA
|
||||
accent: RGBA
|
||||
success: RGBA
|
||||
warning: RGBA
|
||||
primary: RGBA
|
||||
error: RGBA
|
||||
info: RGBA
|
||||
}
|
||||
|
||||
export type LocalDependencies = {
|
||||
theme: LocalTheme
|
||||
toast: {
|
||||
show(options: { variant: "info" | "warning" | "error"; message: string; duration?: number }): void
|
||||
}
|
||||
route: {
|
||||
readonly data: { type: string; sessionID?: string }
|
||||
navigate(route: { type: "session"; sessionID: string }): void
|
||||
}
|
||||
}
|
||||
|
||||
export function parseModel(model: string) {
|
||||
const [providerID, ...rest] = model.split("/")
|
||||
return {
|
||||
providerID: providerID,
|
||||
modelID: rest.join("/"),
|
||||
}
|
||||
}
|
||||
|
||||
export function recentModels(
|
||||
model: { providerID: string; modelID: string },
|
||||
recent: { providerID: string; modelID: string }[],
|
||||
) {
|
||||
const seen = new Set<string>()
|
||||
return [model, ...recent]
|
||||
.filter((item) => {
|
||||
const key = `${item.providerID}/${item.modelID}`
|
||||
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: (props: LocalDependencies) => {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const toast = props.toast
|
||||
const environment = useTuiEnvironment()
|
||||
|
||||
function isModelValid(model: { providerID: string; modelID: string }) {
|
||||
const provider = sync.data.provider.find((x) => x.id === model.providerID)
|
||||
return !!provider?.models[model.modelID]
|
||||
}
|
||||
|
||||
function getFirstValidModel(...modelFns: (() => { providerID: string; modelID: string } | undefined)[]) {
|
||||
for (const modelFn of modelFns) {
|
||||
const model = modelFn()
|
||||
if (!model) continue
|
||||
if (isModelValid(model)) return model
|
||||
}
|
||||
}
|
||||
|
||||
function createAgent() {
|
||||
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
|
||||
const visibleAgents = createMemo(() => sync.data.agent.filter((x) => !x.hidden))
|
||||
const [agentStore, setAgentStore] = createStore({
|
||||
current: undefined as string | undefined,
|
||||
})
|
||||
const theme = props.theme
|
||||
const colors = createMemo(() => [
|
||||
theme.secondary,
|
||||
theme.accent,
|
||||
theme.success,
|
||||
theme.warning,
|
||||
theme.primary,
|
||||
theme.error,
|
||||
theme.info,
|
||||
])
|
||||
return {
|
||||
list() {
|
||||
return agents()
|
||||
},
|
||||
current() {
|
||||
return agents().find((x) => x.name === agentStore.current) ?? agents().at(0)
|
||||
},
|
||||
set(name: string) {
|
||||
if (!agents().some((x) => x.name === name))
|
||||
return toast.show({
|
||||
variant: "warning",
|
||||
message: `Agent not found: ${name}`,
|
||||
duration: 3000,
|
||||
})
|
||||
setAgentStore("current", name)
|
||||
},
|
||||
move(direction: 1 | -1) {
|
||||
batch(() => {
|
||||
const current = this.current()
|
||||
if (!current) return
|
||||
let next = agents().findIndex((x) => x.name === current.name) + direction
|
||||
if (next < 0) next = agents().length - 1
|
||||
if (next >= agents().length) next = 0
|
||||
const value = agents()[next]
|
||||
setAgentStore("current", value.name)
|
||||
})
|
||||
},
|
||||
color(name: string) {
|
||||
const index = visibleAgents().findIndex((x) => x.name === name)
|
||||
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<{
|
||||
ready: boolean
|
||||
model: Record<
|
||||
string,
|
||||
{
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
>
|
||||
recent: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}[]
|
||||
favorite: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}[]
|
||||
variant: Record<string, string | undefined>
|
||||
}>({
|
||||
ready: false,
|
||||
model: {},
|
||||
recent: [],
|
||||
favorite: [],
|
||||
variant: {},
|
||||
})
|
||||
|
||||
const filePath = path.join(environment.paths.state, "model.json")
|
||||
const state = {
|
||||
pending: false,
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!modelStore.ready) {
|
||||
state.pending = true
|
||||
return
|
||||
}
|
||||
state.pending = false
|
||||
void writeJsonAtomic(filePath, {
|
||||
recent: modelStore.recent,
|
||||
favorite: modelStore.favorite,
|
||||
variant: modelStore.variant,
|
||||
})
|
||||
}
|
||||
|
||||
readJson<unknown>(filePath)
|
||||
.then((x) => {
|
||||
if (!x || typeof x !== "object") return
|
||||
const value = x as Record<string, unknown>
|
||||
if (Array.isArray(value.recent)) setModelStore("recent", value.recent)
|
||||
if (Array.isArray(value.favorite)) setModelStore("favorite", value.favorite)
|
||||
if (typeof value.variant === "object" && value.variant !== null)
|
||||
setModelStore("variant", value.variant as Record<string, string | undefined>)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
setModelStore("ready", true)
|
||||
if (state.pending) save()
|
||||
})
|
||||
|
||||
const args = useArgs()
|
||||
const fallbackModel = createMemo(() => {
|
||||
if (args.model) {
|
||||
const { providerID, modelID } = parseModel(args.model)
|
||||
if (isModelValid({ providerID, modelID })) {
|
||||
return {
|
||||
providerID,
|
||||
modelID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sync.data.config.model) {
|
||||
const { providerID, modelID } = parseModel(sync.data.config.model)
|
||||
if (isModelValid({ providerID, modelID })) {
|
||||
return {
|
||||
providerID,
|
||||
modelID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of modelStore.recent) {
|
||||
if (isModelValid(item)) {
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
const provider = sync.data.provider[0]
|
||||
if (!provider) return undefined
|
||||
const defaultModel = sync.data.provider_default[provider.id]
|
||||
const firstModel = Object.values(provider.models)[0]
|
||||
const model = defaultModel ?? firstModel?.id
|
||||
if (!model) return undefined
|
||||
return {
|
||||
providerID: provider.id,
|
||||
modelID: model,
|
||||
}
|
||||
})
|
||||
|
||||
const currentModel = createMemo(() => {
|
||||
const a = agent.current()
|
||||
return (
|
||||
getFirstValidModel(
|
||||
() => a && modelStore.model[a.name],
|
||||
() => a && a.model,
|
||||
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 = sync.data.provider.find((x) => x.id === value.providerID)
|
||||
const info = provider?.models[value.modelID]
|
||||
return {
|
||||
provider: provider?.name ?? value.providerID,
|
||||
model: info?.name ?? value.modelID,
|
||||
reasoning: info?.capabilities?.reasoning ?? false,
|
||||
}
|
||||
}),
|
||||
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.name, { ...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.name, { ...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.name, 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
|
||||
const key = `${m.providerID}/${m.modelID}`
|
||||
return modelStore.variant[key]
|
||||
},
|
||||
current() {
|
||||
const v = this.selected()
|
||||
if (!v) return undefined
|
||||
if (!this.list().includes(v)) return undefined
|
||||
return v
|
||||
},
|
||||
list() {
|
||||
const m = currentModel()
|
||||
if (!m) return []
|
||||
const provider = sync.data.provider.find((x) => x.id === m.providerID)
|
||||
const info = provider?.models[m.modelID]
|
||||
if (!info?.variants) return []
|
||||
return Object.keys(info.variants)
|
||||
},
|
||||
set(value: string | undefined) {
|
||||
const m = currentModel()
|
||||
if (!m) return
|
||||
const key = `${m.providerID}/${m.modelID}`
|
||||
setModelStore("variant", key, value ?? "default")
|
||||
save()
|
||||
},
|
||||
cycle() {
|
||||
const variants = this.list()
|
||||
if (variants.length === 0) return
|
||||
const current = this.current()
|
||||
if (!current) {
|
||||
this.set(variants[0])
|
||||
return
|
||||
}
|
||||
const index = variants.indexOf(current)
|
||||
if (index === -1 || index === variants.length - 1) {
|
||||
this.set(undefined)
|
||||
return
|
||||
}
|
||||
this.set(variants[index + 1])
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const model = createModel()
|
||||
|
||||
function createSession() {
|
||||
const [sessionStore, setSessionStore] = createStore<{
|
||||
ready: boolean
|
||||
pinned: string[]
|
||||
}>({
|
||||
ready: false,
|
||||
pinned: [],
|
||||
})
|
||||
|
||||
const filePath = path.join(environment.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 route = props.route
|
||||
const event = useEvent()
|
||||
|
||||
const slots = createMemo(() => {
|
||||
const existing = new Set(sync.data.session.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.properties.info.id)
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
const mcp = {
|
||||
isEnabled(name: string) {
|
||||
const status = sync.data.mcp[name]
|
||||
return status?.status === "connected"
|
||||
},
|
||||
async toggle(name: string) {
|
||||
const status = sync.data.mcp[name]
|
||||
if (status?.status === "connected") {
|
||||
// Disable: disconnect the MCP
|
||||
await sdk.client.mcp.disconnect({ name })
|
||||
} else {
|
||||
// Enable/Retry: connect the MCP (handles disabled, failed, and other states)
|
||||
await sdk.client.mcp.connect({ name })
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const value = agent.current()
|
||||
if (!value?.model) return
|
||||
if (isModelValid(value.model)) return
|
||||
toast.show({
|
||||
variant: "warning",
|
||||
message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
|
||||
duration: 3000,
|
||||
})
|
||||
})
|
||||
|
||||
const result = {
|
||||
model,
|
||||
agent,
|
||||
mcp,
|
||||
session,
|
||||
}
|
||||
return result
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user