mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(web): add context window support to dashboard config
- Add GET /api/model/info endpoint that resolves model metadata using the same 10-step context-length detection chain the agent uses. Returns auto-detected context length, config override, effective value, and model capabilities (tools, vision, reasoning, max output, model family). - Surface model.context_length as model_context_length virtual field in the config normalize/denormalize cycle. 0 = auto-detect (default), positive value overrides. Writing 0 removes context_length from the model dict on disk. - Add ModelInfoCard component showing resolved context window (e.g. '1M auto-detected' or '500K override — auto: 1M'), max output tokens, and colored capability badges (Tools, Vision, Reasoning, model family). - Inject ModelInfoCard between model field and context_length override in ConfigPage General tab. Card re-fetches on model change and after save. - Insert model_context_length right after model in CONFIG_SCHEMA ordering so the three elements (model input → info card → override) are adjacent.
This commit is contained in:
parent
eabc0a2f66
commit
8fd3093f49
6 changed files with 293 additions and 5 deletions
|
|
@ -96,6 +96,11 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
|
||||||
"description": "Default model (e.g. anthropic/claude-sonnet-4.6)",
|
"description": "Default model (e.g. anthropic/claude-sonnet-4.6)",
|
||||||
"category": "general",
|
"category": "general",
|
||||||
},
|
},
|
||||||
|
"model_context_length": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Context window override (0 = auto-detect from model metadata)",
|
||||||
|
"category": "general",
|
||||||
|
},
|
||||||
"terminal.backend": {
|
"terminal.backend": {
|
||||||
"type": "select",
|
"type": "select",
|
||||||
"description": "Terminal execution backend",
|
"description": "Terminal execution backend",
|
||||||
|
|
@ -246,6 +251,17 @@ def _build_schema_from_config(
|
||||||
|
|
||||||
CONFIG_SCHEMA = _build_schema_from_config(DEFAULT_CONFIG)
|
CONFIG_SCHEMA = _build_schema_from_config(DEFAULT_CONFIG)
|
||||||
|
|
||||||
|
# Inject virtual fields that don't live in DEFAULT_CONFIG but are surfaced
|
||||||
|
# by the normalize/denormalize cycle. Insert model_context_length right after
|
||||||
|
# the "model" key so it renders adjacent in the frontend.
|
||||||
|
_mcl_entry = _SCHEMA_OVERRIDES["model_context_length"]
|
||||||
|
_ordered_schema: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for _k, _v in CONFIG_SCHEMA.items():
|
||||||
|
_ordered_schema[_k] = _v
|
||||||
|
if _k == "model":
|
||||||
|
_ordered_schema["model_context_length"] = _mcl_entry
|
||||||
|
CONFIG_SCHEMA = _ordered_schema
|
||||||
|
|
||||||
|
|
||||||
class ConfigUpdate(BaseModel):
|
class ConfigUpdate(BaseModel):
|
||||||
config: dict
|
config: dict
|
||||||
|
|
@ -408,11 +424,19 @@ def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
or a dict (``{default: ..., provider: ..., base_url: ...}``). The schema is built
|
or a dict (``{default: ..., provider: ..., base_url: ...}``). The schema is built
|
||||||
from DEFAULT_CONFIG where ``model`` is a string, but user configs often have the
|
from DEFAULT_CONFIG where ``model`` is a string, but user configs often have the
|
||||||
dict form. Normalize to the string form so the frontend schema matches.
|
dict form. Normalize to the string form so the frontend schema matches.
|
||||||
|
|
||||||
|
Also surfaces ``model_context_length`` as a top-level field so the web UI can
|
||||||
|
display and edit it. A value of 0 means "auto-detect".
|
||||||
"""
|
"""
|
||||||
config = dict(config) # shallow copy
|
config = dict(config) # shallow copy
|
||||||
model_val = config.get("model")
|
model_val = config.get("model")
|
||||||
if isinstance(model_val, dict):
|
if isinstance(model_val, dict):
|
||||||
|
# Extract context_length before flattening the dict
|
||||||
|
ctx_len = model_val.get("context_length", 0)
|
||||||
config["model"] = model_val.get("default", model_val.get("name", ""))
|
config["model"] = model_val.get("default", model_val.get("name", ""))
|
||||||
|
config["model_context_length"] = ctx_len if isinstance(ctx_len, int) else 0
|
||||||
|
else:
|
||||||
|
config["model_context_length"] = 0
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -433,6 +457,93 @@ async def get_schema():
|
||||||
return {"fields": CONFIG_SCHEMA, "category_order": _CATEGORY_ORDER}
|
return {"fields": CONFIG_SCHEMA, "category_order": _CATEGORY_ORDER}
|
||||||
|
|
||||||
|
|
||||||
|
_EMPTY_MODEL_INFO: dict = {
|
||||||
|
"model": "",
|
||||||
|
"provider": "",
|
||||||
|
"auto_context_length": 0,
|
||||||
|
"config_context_length": 0,
|
||||||
|
"effective_context_length": 0,
|
||||||
|
"capabilities": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/model/info")
|
||||||
|
def get_model_info():
|
||||||
|
"""Return resolved model metadata for the currently configured model.
|
||||||
|
|
||||||
|
Calls the same context-length resolution chain the agent uses, so the
|
||||||
|
frontend can display "Auto-detected: 200K" alongside the override field.
|
||||||
|
Also returns model capabilities (vision, reasoning, tools) when available.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cfg = load_config()
|
||||||
|
model_cfg = cfg.get("model", "")
|
||||||
|
|
||||||
|
# Extract model name and provider from the config
|
||||||
|
if isinstance(model_cfg, dict):
|
||||||
|
model_name = model_cfg.get("default", model_cfg.get("name", ""))
|
||||||
|
provider = model_cfg.get("provider", "")
|
||||||
|
base_url = model_cfg.get("base_url", "")
|
||||||
|
config_ctx = model_cfg.get("context_length")
|
||||||
|
else:
|
||||||
|
model_name = str(model_cfg) if model_cfg else ""
|
||||||
|
provider = ""
|
||||||
|
base_url = ""
|
||||||
|
config_ctx = None
|
||||||
|
|
||||||
|
if not model_name:
|
||||||
|
return dict(_EMPTY_MODEL_INFO, provider=provider)
|
||||||
|
|
||||||
|
# Resolve auto-detected context length (pass config_ctx=None to get
|
||||||
|
# purely auto-detected value, then separately report the override)
|
||||||
|
try:
|
||||||
|
from agent.model_metadata import get_model_context_length
|
||||||
|
auto_ctx = get_model_context_length(
|
||||||
|
model=model_name,
|
||||||
|
base_url=base_url,
|
||||||
|
provider=provider,
|
||||||
|
config_context_length=None, # ignore override — we want auto value
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
auto_ctx = 0
|
||||||
|
|
||||||
|
config_ctx_int = 0
|
||||||
|
if isinstance(config_ctx, int) and config_ctx > 0:
|
||||||
|
config_ctx_int = config_ctx
|
||||||
|
|
||||||
|
# Effective is what the agent actually uses
|
||||||
|
effective_ctx = config_ctx_int if config_ctx_int > 0 else auto_ctx
|
||||||
|
|
||||||
|
# Try to get model capabilities from models.dev
|
||||||
|
caps = {}
|
||||||
|
try:
|
||||||
|
from agent.models_dev import get_model_capabilities
|
||||||
|
mc = get_model_capabilities(provider=provider, model=model_name)
|
||||||
|
if mc is not None:
|
||||||
|
caps = {
|
||||||
|
"supports_tools": mc.supports_tools,
|
||||||
|
"supports_vision": mc.supports_vision,
|
||||||
|
"supports_reasoning": mc.supports_reasoning,
|
||||||
|
"context_window": mc.context_window,
|
||||||
|
"max_output_tokens": mc.max_output_tokens,
|
||||||
|
"model_family": mc.model_family,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"model": model_name,
|
||||||
|
"provider": provider,
|
||||||
|
"auto_context_length": auto_ctx,
|
||||||
|
"config_context_length": config_ctx_int,
|
||||||
|
"effective_context_length": effective_ctx,
|
||||||
|
"capabilities": caps,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
_log.exception("GET /api/model/info failed")
|
||||||
|
return dict(_EMPTY_MODEL_INFO)
|
||||||
|
|
||||||
|
|
||||||
def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:
|
def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Reverse _normalize_config_for_web before saving.
|
"""Reverse _normalize_config_for_web before saving.
|
||||||
|
|
||||||
|
|
@ -440,12 +551,24 @@ def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
to recover model subkeys (provider, base_url, api_mode, etc.) that were
|
to recover model subkeys (provider, base_url, api_mode, etc.) that were
|
||||||
stripped from the GET response. The frontend only sees model as a flat
|
stripped from the GET response. The frontend only sees model as a flat
|
||||||
string; the rest is preserved transparently.
|
string; the rest is preserved transparently.
|
||||||
|
|
||||||
|
Also handles ``model_context_length`` — writes it back into the model dict
|
||||||
|
as ``context_length``. A value of 0 or absent means "auto-detect" (omitted
|
||||||
|
from the dict so get_model_context_length() uses its normal resolution).
|
||||||
"""
|
"""
|
||||||
config = dict(config)
|
config = dict(config)
|
||||||
# Remove any _model_meta that might have leaked in (shouldn't happen
|
# Remove any _model_meta that might have leaked in (shouldn't happen
|
||||||
# with the stripped GET response, but be defensive)
|
# with the stripped GET response, but be defensive)
|
||||||
config.pop("_model_meta", None)
|
config.pop("_model_meta", None)
|
||||||
|
|
||||||
|
# Extract and remove model_context_length before processing model
|
||||||
|
ctx_override = config.pop("model_context_length", 0)
|
||||||
|
if not isinstance(ctx_override, int):
|
||||||
|
try:
|
||||||
|
ctx_override = int(ctx_override)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
ctx_override = 0
|
||||||
|
|
||||||
model_val = config.get("model")
|
model_val = config.get("model")
|
||||||
if isinstance(model_val, str) and model_val:
|
if isinstance(model_val, str) and model_val:
|
||||||
# Read the current disk config to recover model subkeys
|
# Read the current disk config to recover model subkeys
|
||||||
|
|
@ -455,7 +578,20 @@ def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
if isinstance(disk_model, dict):
|
if isinstance(disk_model, dict):
|
||||||
# Preserve all subkeys, update default with the new value
|
# Preserve all subkeys, update default with the new value
|
||||||
disk_model["default"] = model_val
|
disk_model["default"] = model_val
|
||||||
|
# Write context_length into the model dict (0 = remove/auto)
|
||||||
|
if ctx_override > 0:
|
||||||
|
disk_model["context_length"] = ctx_override
|
||||||
|
else:
|
||||||
|
disk_model.pop("context_length", None)
|
||||||
config["model"] = disk_model
|
config["model"] = disk_model
|
||||||
|
else:
|
||||||
|
# Model was previously a bare string — upgrade to dict if
|
||||||
|
# user is setting a context_length override
|
||||||
|
if ctx_override > 0:
|
||||||
|
config["model"] = {
|
||||||
|
"default": model_val,
|
||||||
|
"context_length": ctx_override,
|
||||||
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # can't read disk config — just use the string form
|
pass # can't read disk config — just use the string form
|
||||||
return config
|
return config
|
||||||
|
|
|
||||||
116
web/src/components/ModelInfoCard.tsx
Normal file
116
web/src/components/ModelInfoCard.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Brain,
|
||||||
|
Eye,
|
||||||
|
Gauge,
|
||||||
|
Lightbulb,
|
||||||
|
Wrench,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { ModelInfoResponse } from "@/lib/api";
|
||||||
|
import { formatTokenCount } from "@/lib/format";
|
||||||
|
|
||||||
|
interface ModelInfoCardProps {
|
||||||
|
/** Current model string from config state — used to detect changes */
|
||||||
|
currentModel: string;
|
||||||
|
/** Bumped after config saves to trigger re-fetch */
|
||||||
|
refreshKey?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelInfoCard({ currentModel, refreshKey = 0 }: ModelInfoCardProps) {
|
||||||
|
const [info, setInfo] = useState<ModelInfoResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const lastFetchKeyRef = useRef("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentModel) return;
|
||||||
|
// Re-fetch when model changes OR when refreshKey bumps (after save)
|
||||||
|
const fetchKey = `${currentModel}:${refreshKey}`;
|
||||||
|
if (fetchKey === lastFetchKeyRef.current) return;
|
||||||
|
lastFetchKeyRef.current = fetchKey;
|
||||||
|
setLoading(true);
|
||||||
|
api
|
||||||
|
.getModelInfo()
|
||||||
|
.then(setInfo)
|
||||||
|
.catch(() => setInfo(null))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [currentModel, refreshKey]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
Loading model info…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!info || !info.model) return null;
|
||||||
|
|
||||||
|
const caps = info.capabilities;
|
||||||
|
const hasCaps = caps && Object.keys(caps).length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border/60 bg-muted/30 px-3 py-2.5 space-y-2">
|
||||||
|
{/* Context window */}
|
||||||
|
<div className="flex items-center gap-4 text-xs">
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Gauge className="h-3.5 w-3.5" />
|
||||||
|
<span className="font-medium">Context Window</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono font-semibold text-foreground">
|
||||||
|
{formatTokenCount(info.effective_context_length)}
|
||||||
|
</span>
|
||||||
|
{info.config_context_length > 0 ? (
|
||||||
|
<span className="text-amber-500/80 text-[10px]">
|
||||||
|
(override — auto: {formatTokenCount(info.auto_context_length)})
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground/60 text-[10px]">auto-detected</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Max output */}
|
||||||
|
{hasCaps && caps.max_output_tokens && caps.max_output_tokens > 0 && (
|
||||||
|
<div className="flex items-center gap-4 text-xs">
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Lightbulb className="h-3.5 w-3.5" />
|
||||||
|
<span className="font-medium">Max Output</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono font-semibold text-foreground">
|
||||||
|
{formatTokenCount(caps.max_output_tokens)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Capability badges */}
|
||||||
|
{hasCaps && (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||||
|
{caps.supports_tools && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
|
||||||
|
<Wrench className="h-2.5 w-2.5" /> Tools
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{caps.supports_vision && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-2 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
|
||||||
|
<Eye className="h-2.5 w-2.5" /> Vision
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{caps.supports_reasoning && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-purple-500/10 px-2 py-0.5 text-[10px] font-medium text-purple-600 dark:text-purple-400">
|
||||||
|
<Brain className="h-2.5 w-2.5" /> Reasoning
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{caps.model_family && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||||
|
{caps.model_family}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -43,6 +43,7 @@ export const api = {
|
||||||
getConfig: () => fetchJSON<Record<string, unknown>>("/api/config"),
|
getConfig: () => fetchJSON<Record<string, unknown>>("/api/config"),
|
||||||
getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"),
|
getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"),
|
||||||
getSchema: () => fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>("/api/config/schema"),
|
getSchema: () => fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>("/api/config/schema"),
|
||||||
|
getModelInfo: () => fetchJSON<ModelInfoResponse>("/api/model/info"),
|
||||||
saveConfig: (config: Record<string, unknown>) =>
|
saveConfig: (config: Record<string, unknown>) =>
|
||||||
fetchJSON<{ ok: boolean }>("/api/config", {
|
fetchJSON<{ ok: boolean }>("/api/config", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
|
|
@ -325,6 +326,24 @@ export interface SessionSearchResponse {
|
||||||
results: SessionSearchResult[];
|
results: SessionSearchResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Model info types ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ModelInfoResponse {
|
||||||
|
model: string;
|
||||||
|
provider: string;
|
||||||
|
auto_context_length: number;
|
||||||
|
config_context_length: number;
|
||||||
|
effective_context_length: number;
|
||||||
|
capabilities: {
|
||||||
|
supports_tools?: boolean;
|
||||||
|
supports_vision?: boolean;
|
||||||
|
supports_reasoning?: boolean;
|
||||||
|
context_window?: number;
|
||||||
|
max_output_tokens?: number;
|
||||||
|
model_family?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ── OAuth provider types ────────────────────────────────────────────────
|
// ── OAuth provider types ────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface OAuthProviderStatus {
|
export interface OAuthProviderStatus {
|
||||||
|
|
|
||||||
9
web/src/lib/format.ts
Normal file
9
web/src/lib/format.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
/**
|
||||||
|
* Format a token count as a human-readable string (e.g. 1M, 128K, 4096).
|
||||||
|
* Strips trailing ".0" for clean round numbers.
|
||||||
|
*/
|
||||||
|
export function formatTokenCount(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}K`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { formatTokenCount } from "@/lib/format";
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Cpu,
|
Cpu,
|
||||||
|
|
@ -18,11 +19,7 @@ const PERIODS = [
|
||||||
|
|
||||||
const CHART_HEIGHT_PX = 160;
|
const CHART_HEIGHT_PX = 160;
|
||||||
|
|
||||||
function formatTokens(n: number): string {
|
const formatTokens = formatTokenCount;
|
||||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
||||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
||||||
return String(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(day: string): string {
|
function formatDate(day: string): string {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import { getNestedValue, setNestedValue } from "@/lib/nested";
|
||||||
import { useToast } from "@/hooks/useToast";
|
import { useToast } from "@/hooks/useToast";
|
||||||
import { Toast } from "@/components/Toast";
|
import { Toast } from "@/components/Toast";
|
||||||
import { AutoField } from "@/components/AutoField";
|
import { AutoField } from "@/components/AutoField";
|
||||||
|
import { ModelInfoCard } from "@/components/ModelInfoCard";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -87,6 +88,7 @@ export default function ConfigPage() {
|
||||||
const [yamlLoading, setYamlLoading] = useState(false);
|
const [yamlLoading, setYamlLoading] = useState(false);
|
||||||
const [yamlSaving, setYamlSaving] = useState(false);
|
const [yamlSaving, setYamlSaving] = useState(false);
|
||||||
const [activeCategory, setActiveCategory] = useState<string>("");
|
const [activeCategory, setActiveCategory] = useState<string>("");
|
||||||
|
const [modelInfoRefreshKey, setModelInfoRefreshKey] = useState(0);
|
||||||
const { toast, showToast } = useToast();
|
const { toast, showToast } = useToast();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
|
@ -174,6 +176,7 @@ export default function ConfigPage() {
|
||||||
try {
|
try {
|
||||||
await api.saveConfig(config);
|
await api.saveConfig(config);
|
||||||
showToast("Configuration saved", "success");
|
showToast("Configuration saved", "success");
|
||||||
|
setModelInfoRefreshKey((k) => k + 1);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(`Failed to save: ${e}`, "error");
|
showToast(`Failed to save: ${e}`, "error");
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -186,6 +189,7 @@ export default function ConfigPage() {
|
||||||
try {
|
try {
|
||||||
await api.saveConfigRaw(yamlText);
|
await api.saveConfigRaw(yamlText);
|
||||||
showToast("YAML config saved", "success");
|
showToast("YAML config saved", "success");
|
||||||
|
setModelInfoRefreshKey((k) => k + 1);
|
||||||
api.getConfig().then(setConfig).catch(() => {});
|
api.getConfig().then(setConfig).catch(() => {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(`Failed to save YAML: ${e}`, "error");
|
showToast(`Failed to save YAML: ${e}`, "error");
|
||||||
|
|
@ -238,6 +242,7 @@ export default function ConfigPage() {
|
||||||
const renderFields = (fields: [string, Record<string, unknown>][], showCategory = false) => {
|
const renderFields = (fields: [string, Record<string, unknown>][], showCategory = false) => {
|
||||||
let lastSection = "";
|
let lastSection = "";
|
||||||
let lastCat = "";
|
let lastCat = "";
|
||||||
|
const currentModel = config ? String(getNestedValue(config, "model") ?? "") : "";
|
||||||
return fields.map(([key, s]) => {
|
return fields.map(([key, s]) => {
|
||||||
const parts = key.split(".");
|
const parts = key.split(".");
|
||||||
const section = parts.length > 1 ? parts[0] : "";
|
const section = parts.length > 1 ? parts[0] : "";
|
||||||
|
|
@ -274,6 +279,12 @@ export default function ConfigPage() {
|
||||||
onChange={(v) => setConfig(setNestedValue(config, key, v))}
|
onChange={(v) => setConfig(setNestedValue(config, key, v))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Inject model info card right after the model field */}
|
||||||
|
{key === "model" && currentModel && (
|
||||||
|
<div className="py-1">
|
||||||
|
<ModelInfoCard currentModel={currentModel} refreshKey={modelInfoRefreshKey} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue