diff --git a/apps/app/public/ext-lmstudio.svg b/apps/app/public/ext-lmstudio.svg new file mode 100644 index 000000000..924d3c025 --- /dev/null +++ b/apps/app/public/ext-lmstudio.svg @@ -0,0 +1 @@ +LM Studio diff --git a/apps/app/src/app/extensions.ts b/apps/app/src/app/extensions.ts index f4eb2a2f1..ae87c15b5 100644 --- a/apps/app/src/app/extensions.ts +++ b/apps/app/src/app/extensions.ts @@ -353,4 +353,30 @@ export const BUILT_IN_OPENWORK_EXTENSION_MANIFESTS: OpenWorkExtensionManifest[] ], lifecycle: { reload: ["config"], detection: ["provider:ollama"] }, }, + { + schemaVersion: 1, + id: "lmstudio", + name: "LM Studio", + description: "Local model provider at http://127.0.0.1:1234.", + source: { format: "openwork-builtin", origin: "builtin", trusted: true }, + icon: { src: "/ext-lmstudio.svg" }, + composer: { prompt: "Use the LM Studio extension to " }, + setup: { + instructions: + "Start LM Studio's local server (Developer tab), choose a downloaded model, then add it as an OpenCode provider.", + primaryCta: "Add LM Studio model", + }, + resources: [ + { type: "local-service", id: "lmstudio-api", label: "LM Studio API", description: "http://127.0.0.1:1234", required: true }, + { type: "provider", id: "lmstudio", providerId: "lmstudio", packageName: "@ai-sdk/openai-compatible", required: true }, + ], + contributions: [ + { type: "settings-panel", ref: "openwork.lmstudio.settings", location: "settings-detail" }, + { type: "composer-prompt", prompt: "Use the LM Studio extension to ", location: "composer" }, + ], + enablement: [ + { type: "provider-connected", ref: "lmstudio", label: "LM Studio provider" }, + ], + lifecycle: { reload: ["config"], detection: ["provider:lmstudio"] }, + }, ]; diff --git a/apps/app/src/react-app/domains/settings/lmstudio-config.tsx b/apps/app/src/react-app/domains/settings/lmstudio-config.tsx new file mode 100644 index 000000000..9dca1e929 --- /dev/null +++ b/apps/app/src/react-app/domains/settings/lmstudio-config.tsx @@ -0,0 +1,347 @@ +/** @jsxImportSource react */ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { CheckCircle2, Download, Loader2, RefreshCw, XCircle } from "lucide-react"; + +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { + Field, + FieldContent, + FieldDescription, + FieldGroup, + FieldLabel, + FieldLegend, + FieldSet, + FieldTitle, +} from "@/components/ui/field"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { LMSTUDIO_PROVIDER_CONFIG } from "./openai-image-extension"; +import { registerExtensionConfig, type ExtensionConfigContext } from "./extension-registry"; + +const lmstudioConfigFactory = (ctx: ExtensionConfigContext) => ( + +); + +registerExtensionConfig("openwork.lmstudio.settings", lmstudioConfigFactory); +registerExtensionConfig("lmstudio", lmstudioConfigFactory); + +/** A model entry surfaced by the LM Studio local server. */ +type LMStudioModel = { + id: string; + /** "llm" | "vlm" | "embeddings" from the native /api/v0/models endpoint. */ + type?: string; + state?: string; + maxContextLength?: number; +}; + +type LMStudioStatus = "checking" | "running" | "unreachable"; + +/** Base URL for the LM Studio server without the trailing OpenAI "/v1" segment. */ +const LMSTUDIO_BASE = LMSTUDIO_PROVIDER_CONFIG.baseURL.replace(/\/v1\/?$/, ""); + +/** + * Fetch the models currently available in the running LM Studio instance. + * + * Prefers the native REST endpoint (`/api/v0/models`) which reports the model + * type and load state, and falls back to the OpenAI-compatible `/v1/models` + * endpoint on older LM Studio builds. The previous behaviour relied on the + * static models.dev catalog, which only ever listed three hardcoded models + * regardless of what the user had downloaded (#bug: hardcoded LM Studio models). + */ +async function fetchLMStudioModels(): Promise<{ status: "running" | "unreachable"; models: LMStudioModel[] }> { + // Native endpoint first — richer metadata (type, loaded state, context length). + try { + const response = await fetch(`${LMSTUDIO_BASE}/api/v0/models`, { + signal: AbortSignal.timeout(3000), + }); + if (response.ok) { + const data = await response.json(); + const models = (Array.isArray(data?.data) ? data.data : []) + .map((entry: Record): LMStudioModel => ({ + id: String(entry.id ?? ""), + type: typeof entry.type === "string" ? entry.type : undefined, + state: typeof entry.state === "string" ? entry.state : undefined, + maxContextLength: + typeof entry.max_context_length === "number" ? entry.max_context_length : undefined, + })) + .filter((model: LMStudioModel) => model.id && model.type !== "embeddings"); + return { status: "running", models }; + } + } catch { + // Fall through to the OpenAI-compatible endpoint below. + } + + // OpenAI-compatible fallback. + try { + const response = await fetch(`${LMSTUDIO_PROVIDER_CONFIG.baseURL}/models`, { + signal: AbortSignal.timeout(3000), + }); + if (!response.ok) { + return { status: "unreachable", models: [] }; + } + const data = await response.json(); + const models = (Array.isArray(data?.data) ? data.data : []) + .map((entry: Record): LMStudioModel => ({ id: String(entry.id ?? "") })) + .filter((model: LMStudioModel) => model.id); + return { status: "running", models }; + } catch { + return { status: "unreachable", models: [] }; + } +} + +function useLMStudioModels() { + const { data, isFetching, refetch } = useQuery({ + queryKey: ["lmstudio", "models"], + queryFn: fetchLMStudioModels, + refetchOnWindowFocus: false, + }); + + const status: LMStudioStatus = isFetching ? "checking" : (data?.status ?? "unreachable"); + + return { data, isFetching, refetch, status }; +} + +export type LMStudioConfigProps = { + busy: boolean; + status: string | null; + error: string | null; + onInstall: (input: { + providerId: string; + name: string; + baseURL: string; + modelId: string; + modelName: string; + setDefault: boolean; + }) => void | Promise; +}; + +export function LMStudioConfig(props: LMStudioConfigProps) { + const [selectedModel, setSelectedModel] = useState(""); + const [setDefault, setSetDefault] = useState(true); + + const { data, isFetching, refetch, status } = useLMStudioModels(); + + const models = data?.models ?? []; + + useEffect(() => { + if (!selectedModel && models[0]) { + setSelectedModel(models[0].id); + } + }, [models, selectedModel]); + + const handleInstall = () => { + if (!selectedModel) { + return; + } + + void props.onInstall({ + providerId: LMSTUDIO_PROVIDER_CONFIG.providerId, + name: LMSTUDIO_PROVIDER_CONFIG.name, + baseURL: LMSTUDIO_PROVIDER_CONFIG.baseURL, + modelId: selectedModel, + modelName: selectedModel, + setDefault, + }); + }; + + if (status === "unreachable") { + return ( + + + Configuration + Connect to a local LM Studio instance and choose a model. + + + + + + {props.error ? ( + + + {props.error} + + ) : null} + + + + + + + LM Studio isn't running + + Start LM Studio and enable its local server (Developer tab) so OpenWork can list your + downloaded models from {LMSTUDIO_BASE}. + + + + + + + + + ); + } + + return ( + + + Configuration + Connect to a local LM Studio instance and choose a model. + + + + + + {props.error ? ( + + + {props.error} + + ) : null} + + + {status === "checking" ? ( + + ) : status === "running" ? ( + + ) : ( + + )} + + {status === "checking" + ? "Checking LM Studio..." + : status === "running" + ? `LM Studio running (${models.length} model${models.length === 1 ? "" : "s"})` + : "LM Studio not reachable"} + + + + {/* Model selection */} + {status === "running" && models.length > 0 ? ( +
+ Available models + Select from models available in LM Studio. + + {models.map((model) => ( + + ))} + +
+ ) : null} + + {/* No models */} + {status === "running" && models.length === 0 ? ( + + + + + + No models available + + Download a model in LM Studio, then refresh to add it to your workspace. + + + + ) : null} + + {props.status ? ( + + + {props.status} + + ) : null} +
+ + + + } + /> + Use as default model in workspace + + + + +
+ ); +} + +interface ModelListProps { + value: string; + onValueChange: (value: string) => void; + children: React.ReactNode; +} + +export function ModelList({ value, onValueChange, children }: ModelListProps) { + return ( + + {children} + + ); +} + +interface ModelListItemProps { + model: LMStudioModel; +} + +function ModelListItem({ model }: ModelListItemProps) { + const detail = model.maxContextLength + ? `${Math.round(model.maxContextLength / 1024)}K ctx` + : model.type; + return ( + + + + + {model.id} + {detail ? {detail} : null} + + + + ); +} diff --git a/apps/app/src/react-app/domains/settings/openai-image-extension.ts b/apps/app/src/react-app/domains/settings/openai-image-extension.ts index 6e9cf3e5b..6b3861c1f 100644 --- a/apps/app/src/react-app/domains/settings/openai-image-extension.ts +++ b/apps/app/src/react-app/domains/settings/openai-image-extension.ts @@ -14,5 +14,11 @@ export const OLLAMA_PROVIDER_CONFIG = { defaultModelId: "qwen2.5-coder:7b", }; +export const LMSTUDIO_PROVIDER_CONFIG = { + providerId: "lmstudio", + name: "LM Studio (local)", + baseURL: "http://127.0.0.1:1234/v1", +}; + export const OPENAI_IMAGE_EXTENSION_ID = "openai-image-generation"; export const OPENAI_IMAGE_MODEL = "gpt-image-2"; diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx index c8ffc5df7..debf4227e 100644 --- a/apps/app/src/react-app/shell/settings-route.tsx +++ b/apps/app/src/react-app/shell/settings-route.tsx @@ -58,6 +58,7 @@ import { AiSettingsView } from "@/react-app/domains/settings/pages/ai-view"; // Side-effect imports: register extension config components into the registry. import "@/react-app/domains/settings/openai-image-gen-config"; import "@/react-app/domains/settings/ollama-config"; +import "@/react-app/domains/settings/lmstudio-config"; import "@/react-app/domains/settings/computer-use-config"; import "@/react-app/domains/settings/browser-extension-config"; import "@/react-app/domains/settings/openwork-voice-config";