feat(28-02): create ollamaApi client and Hermes Ollama model dropdown

- Add ui/src/api/ollama.ts with ollamaApi.status() and ollamaApi.models()
- Replace free-text Model input with hybrid dropdown/fallback in HermesLocalConfigFields
- Dropdown shows pulled Ollama models with * prefix for recommended entries
- Install callout shown when Ollama is absent (with link to installUrl)
- Edit mode: selecting an Ollama model atomically sets model + provider:custom + base_url
- Manual entry fallback via "Other (manual entry)..." option or when Ollama absent
- Uses useCompany() hook for companyId (consistent with AgentConfigForm pattern)
This commit is contained in:
Nexus Dev 2026-04-02 17:03:00 +00:00
parent 5345b67f92
commit 926b3a8763
2 changed files with 141 additions and 16 deletions

View file

@ -1,9 +1,13 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { AdapterConfigFieldsProps } from "../types";
import {
Field,
DraftInput,
} from "../../components/agent-config-primitives";
import { ChoosePathButton } from "../../components/PathInstructionsModal";
import { useCompany } from "../../context/CompanyContext";
import { ollamaApi } from "../../api/ollama";
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
@ -19,6 +23,63 @@ export function HermesLocalConfigFields({
mark,
hideInstructionsFile,
}: AdapterConfigFieldsProps) {
const { selectedCompanyId } = useCompany();
const companyId = selectedCompanyId;
const [manualEntry, setManualEntry] = useState(false);
const { data: ollamaStatus } = useQuery({
queryKey: ["ollama", "status", companyId],
queryFn: () => ollamaApi.status(companyId!),
enabled: Boolean(companyId),
staleTime: 60_000,
});
const { data: ollamaModels } = useQuery({
queryKey: ["ollama", "models", companyId],
queryFn: () => ollamaApi.models(companyId!),
enabled: Boolean(companyId && ollamaStatus?.installed),
staleTime: 60_000,
});
const currentModel = isCreate
? (values!.model ?? "")
: eff("adapterConfig", "model", String(config.model ?? ""));
function handleSelectModel(selectedModel: string) {
if (selectedModel === "__manual__") {
setManualEntry(true);
return;
}
if (selectedModel === "") return;
// Set fields when selecting an Ollama model
if (isCreate) {
// In create mode, CreateConfigValues does not have provider/base_url;
// only model is stored via form values. The server resolves provider
// at runtime from the model name or ~/.hermes/config.yaml.
set!({ model: selectedModel });
} else {
// In edit mode, set all three fields atomically on adapterConfig
mark("adapterConfig", "model", selectedModel);
mark("adapterConfig", "provider", "custom");
mark("adapterConfig", "base_url", "http://localhost:11434/v1");
}
}
function formatModelLabel(m: { name: string; parameterSize: string; quantization: string; recommended: boolean; recommendationReason: string | null }) {
const params = m.parameterSize ? ` (${m.parameterSize}, ${m.quantization})` : "";
const recSuffix = m.recommended ? " - Recommended for your system" : "";
const star = m.recommended ? "* " : "";
return `${star}${m.name}${params}${recSuffix}`;
}
const showDropdown =
ollamaStatus?.installed === true &&
(ollamaModels?.models?.length ?? 0) > 0 &&
!manualEntry;
const showInstallCallout = ollamaStatus?.installed === false;
return (
<>
{!hideInstructionsFile && (
@ -47,26 +108,59 @@ export function HermesLocalConfigFields({
</div>
</Field>
)}
<Field
label="Model"
hint="Provider/model format (e.g. anthropic/claude-sonnet-4). Leave blank to use Hermes default."
hint={
showDropdown
? "Select a locally pulled Ollama model. Recommended models are marked with *."
: "Provider/model format (e.g. anthropic/claude-sonnet-4). Leave blank to use Hermes default."
}
>
<DraftInput
value={
isCreate
? values!.model ?? ""
: eff("adapterConfig", "model", String(config.model ?? ""))
}
onCommit={(v) =>
isCreate
? set!({ model: v })
: mark("adapterConfig", "model", v || undefined)
}
immediate
className={inputClass}
placeholder="anthropic/claude-sonnet-4"
/>
{showInstallCallout && (
<div className="mb-2 rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-sm">
<span className="text-amber-200">Ollama is not detected. </span>
<a
href={ollamaStatus.installUrl ?? "https://ollama.com/download"}
target="_blank"
rel="noopener noreferrer"
className="text-amber-400 underline hover:text-amber-300"
>
Install Ollama
</a>
<span className="text-muted-foreground"> to use local models.</span>
</div>
)}
{showDropdown ? (
<select
className={inputClass}
value={currentModel || ""}
onChange={(e) => handleSelectModel(e.target.value)}
>
<option value="">Select a model...</option>
{(ollamaModels?.models ?? []).map((m) => (
<option key={m.name} value={m.name}>
{formatModelLabel(m)}
</option>
))}
<option value="__manual__">Other (manual entry)...</option>
</select>
) : (
<DraftInput
value={currentModel}
onCommit={(v) =>
isCreate
? set!({ model: v })
: mark("adapterConfig", "model", v || undefined)
}
immediate
className={inputClass}
placeholder="anthropic/claude-sonnet-4"
/>
)}
</Field>
{!isCreate && (
<>
<Field

31
ui/src/api/ollama.ts Normal file
View file

@ -0,0 +1,31 @@
import { api } from "./client";
export interface OllamaStatus {
installed: boolean;
version: string | null;
installUrl: string;
}
export interface OllamaModel {
name: string;
parameterSize: string;
quantization: string;
sizeBytes: number;
family: string;
recommended: boolean;
recommendationReason: string | null;
}
export interface OllamaModelsResponse {
models: OllamaModel[];
ramGb: number;
}
export const ollamaApi = {
status(companyId: string): Promise<OllamaStatus> {
return api.get<OllamaStatus>(`/companies/${companyId}/ollama/status`);
},
models(companyId: string): Promise<OllamaModelsResponse> {
return api.get<OllamaModelsResponse>(`/companies/${companyId}/ollama/models`);
},
};