mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 08:14:33 +08:00
refactor(hermes): drop config health check scanner
The Hermes config.yaml schema has stabilized and users have migrated to the current provider fields, so the value of scanning for model.provider dangling references, custom_providers shape errors, v12 migration residue etc. no longer justifies the maintenance surface — and the scan produces false positives when users keep some providers under Hermes' v12+ providers: dict (Hermes' runtime merges both shapes, but CC Switch's scanner only looked at the list form). Removes the whole HermesHealthWarning type, scan_hermes_config_health command, HermesHealthBanner React component, useHermesHealth hook, warnings field on HermesWriteOutcome, and the three helper functions (yaml_as_non_empty_str, collect_mapping_string_keys, hermes_warning) that only served the scanner. Drops the matching i18n keys in zh/en/ja and the fixInWebUI button label that only the banner used.
This commit is contained in:
@@ -40,12 +40,6 @@ pub fn get_hermes_live_provider(
|
||||
hermes_config::get_provider(&providerId).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Scan config.yaml for known configuration hazards.
|
||||
#[tauri::command]
|
||||
pub fn scan_hermes_config_health() -> Result<Vec<hermes_config::HermesHealthWarning>, String> {
|
||||
hermes_config::scan_hermes_config_health().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Model Configuration Commands
|
||||
// ============================================================================
|
||||
|
||||
@@ -72,24 +72,12 @@ fn hermes_write_lock() -> &'static Mutex<()> {
|
||||
// Type Definitions
|
||||
// ============================================================================
|
||||
|
||||
/// Hermes 健康检查警告
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HermesHealthWarning {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// Hermes 写入结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HermesWriteOutcome {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub backup_path: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub warnings: Vec<HermesHealthWarning>,
|
||||
}
|
||||
|
||||
/// Hermes model section config
|
||||
@@ -343,8 +331,6 @@ fn write_yaml_section_to_config_locked(
|
||||
|
||||
atomic_write(&config_path, new_raw.as_bytes())?;
|
||||
|
||||
let warnings = scan_hermes_health_internal(&new_raw);
|
||||
|
||||
log::debug!(
|
||||
"Hermes config section '{}' written to {:?}",
|
||||
section_key,
|
||||
@@ -352,7 +338,6 @@ fn write_yaml_section_to_config_locked(
|
||||
);
|
||||
Ok(HermesWriteOutcome {
|
||||
backup_path: backup_path.map(|p| p.display().to_string()),
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -486,28 +471,6 @@ fn denormalize_provider_models_for_read(config: &mut serde_json::Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow a YAML scalar as a non-empty trimmed `&str`, or `None` when the
|
||||
/// value is absent, non-string, or blank after trimming.
|
||||
fn yaml_as_non_empty_str(v: &serde_yaml::Value) -> Option<&str> {
|
||||
v.as_str().map(str::trim).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Extract string keys from a YAML mapping as owned strings, dropping non-string keys.
|
||||
fn collect_mapping_string_keys(m: &serde_yaml::Mapping) -> Vec<String> {
|
||||
m.iter()
|
||||
.filter_map(|(k, _)| k.as_str().map(str::to_string))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Boilerplate-free constructor for [`HermesHealthWarning`].
|
||||
fn hermes_warning(code: &str, message: String, path: &str) -> HermesHealthWarning {
|
||||
HermesHealthWarning {
|
||||
code: code.to_string(),
|
||||
message,
|
||||
path: Some(path.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker field injected on provider payloads sourced from Hermes v12+
|
||||
/// `providers:` dict. CC Switch treats those as read-only — writes have to
|
||||
/// go through Hermes' own Web UI to keep its overlay semantics intact.
|
||||
@@ -856,160 +819,6 @@ pub fn apply_switch_defaults(
|
||||
set_model_config(&merged)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Health Check
|
||||
// ============================================================================
|
||||
|
||||
/// Scan Hermes config for known configuration hazards.
|
||||
///
|
||||
/// Parse failures are reported as warnings (not errors) so the UI can
|
||||
/// display them without blocking.
|
||||
pub fn scan_hermes_config_health() -> Result<Vec<HermesHealthWarning>, AppError> {
|
||||
let path = get_hermes_config_path();
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||
Ok(scan_hermes_health_internal(&content))
|
||||
}
|
||||
|
||||
fn scan_hermes_health_internal(content: &str) -> Vec<HermesHealthWarning> {
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
if content.trim().is_empty() {
|
||||
return warnings;
|
||||
}
|
||||
|
||||
let config = match serde_yaml::from_str::<serde_yaml::Value>(content) {
|
||||
Ok(cfg) => cfg,
|
||||
Err(err) => {
|
||||
warnings.push(hermes_warning(
|
||||
"config_parse_failed",
|
||||
format!("Hermes config could not be parsed as YAML: {err}"),
|
||||
&get_hermes_config_path().display().to_string(),
|
||||
));
|
||||
return warnings;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(model) = config.get("model") {
|
||||
if model.get("default").is_none() && model.get("provider").is_none() {
|
||||
warnings.push(hermes_warning(
|
||||
"model_no_default",
|
||||
"No default model or provider configured in 'model' section".to_string(),
|
||||
"model",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let cp_is_mapping = config
|
||||
.get("custom_providers")
|
||||
.map(|v| v.is_mapping())
|
||||
.unwrap_or(false);
|
||||
if cp_is_mapping {
|
||||
warnings.push(hermes_warning(
|
||||
"custom_providers_not_list",
|
||||
"custom_providers should be a YAML list (sequence), not a mapping".to_string(),
|
||||
"custom_providers",
|
||||
));
|
||||
}
|
||||
|
||||
let mut provider_models: HashMap<String, Vec<String>> = HashMap::new();
|
||||
let mut name_counts: HashMap<String, usize> = HashMap::new();
|
||||
let mut base_url_counts: HashMap<String, usize> = HashMap::new();
|
||||
|
||||
if let Some(seq) = config.get("custom_providers").and_then(|v| v.as_sequence()) {
|
||||
for item in seq {
|
||||
if let Some(name) = item.get("name").and_then(yaml_as_non_empty_str) {
|
||||
*name_counts.entry(name.to_string()).or_insert(0) += 1;
|
||||
if let Some(models) = item.get("models").and_then(|m| m.as_mapping()) {
|
||||
provider_models
|
||||
.entry(name.to_string())
|
||||
.or_insert_with(|| collect_mapping_string_keys(models));
|
||||
}
|
||||
}
|
||||
if let Some(url) = item
|
||||
.get("base_url")
|
||||
.and_then(|n| n.as_str())
|
||||
.map(|s| s.trim().trim_end_matches('/').to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
*base_url_counts.entry(url).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// name_counts keys are unique by construction, so iterating it already
|
||||
// reports each duplicate name exactly once — no extra dedupe set needed.
|
||||
for (name, count) in &name_counts {
|
||||
if *count > 1 {
|
||||
warnings.push(hermes_warning(
|
||||
"duplicate_provider_name",
|
||||
format!(
|
||||
"Duplicate provider name '{name}' in custom_providers — only one entry will be used"
|
||||
),
|
||||
"custom_providers",
|
||||
));
|
||||
}
|
||||
}
|
||||
for (url, count) in &base_url_counts {
|
||||
if *count > 1 {
|
||||
warnings.push(hermes_warning(
|
||||
"duplicate_provider_base_url",
|
||||
format!(
|
||||
"Duplicate base_url '{url}' in custom_providers — possible accidental copy"
|
||||
),
|
||||
"custom_providers",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(model) = config.get("model") {
|
||||
if let Some(provider_ref) = model.get("provider").and_then(yaml_as_non_empty_str) {
|
||||
if !name_counts.contains_key(provider_ref) {
|
||||
warnings.push(hermes_warning(
|
||||
"model_provider_unknown",
|
||||
format!(
|
||||
"model.provider '{provider_ref}' does not match any configured provider"
|
||||
),
|
||||
"model.provider",
|
||||
));
|
||||
} else if let Some(default) = model.get("default").and_then(yaml_as_non_empty_str) {
|
||||
if let Some(ids) = provider_models.get(provider_ref) {
|
||||
if !ids.is_empty() && !ids.iter().any(|id| id == default) {
|
||||
warnings.push(hermes_warning(
|
||||
"model_default_not_in_provider",
|
||||
format!(
|
||||
"model.default '{default}' is not in provider '{provider_ref}' models list"
|
||||
),
|
||||
"model.default",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let version = config
|
||||
.get("_config_version")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let providers_dict_populated = config
|
||||
.get("providers")
|
||||
.and_then(|v| v.as_mapping())
|
||||
.map(|m| !m.is_empty())
|
||||
.unwrap_or(false);
|
||||
if version >= 12 && providers_dict_populated {
|
||||
warnings.push(hermes_warning(
|
||||
"schema_migrated_v12",
|
||||
"Hermes' newer schema moved some entries into the 'providers:' dict. They are shown read-only in CC Switch — edit or remove those entries via Hermes Web UI.".to_string(),
|
||||
"providers",
|
||||
));
|
||||
}
|
||||
|
||||
warnings
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MCP Section Access (for mcp/hermes.rs to use in Phase 4)
|
||||
// ============================================================================
|
||||
@@ -1727,45 +1536,6 @@ custom_providers:
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Health check tests ----
|
||||
|
||||
#[test]
|
||||
fn health_check_on_invalid_yaml() {
|
||||
let warnings = scan_hermes_health_internal("not: valid: yaml: [");
|
||||
assert!(!warnings.is_empty());
|
||||
assert_eq!(warnings[0].code, "config_parse_failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_check_model_no_default() {
|
||||
let yaml = "model:\n context_length: 200000\n";
|
||||
let warnings = scan_hermes_health_internal(yaml);
|
||||
assert!(warnings.iter().any(|w| w.code == "model_no_default"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_check_custom_providers_not_list() {
|
||||
let yaml = "custom_providers:\n foo:\n base_url: http://localhost\n";
|
||||
let warnings = scan_hermes_health_internal(yaml);
|
||||
assert!(warnings
|
||||
.iter()
|
||||
.any(|w| w.code == "custom_providers_not_list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_check_valid_config() {
|
||||
let yaml = "\
|
||||
model:
|
||||
default: gpt-4
|
||||
provider: openrouter
|
||||
custom_providers:
|
||||
- name: openrouter
|
||||
base_url: https://openrouter.ai/api/v1
|
||||
";
|
||||
let warnings = scan_hermes_health_internal(yaml);
|
||||
assert!(warnings.is_empty());
|
||||
}
|
||||
|
||||
// ---- yaml_to_json / json_to_yaml ----
|
||||
|
||||
#[test]
|
||||
@@ -2174,115 +1944,4 @@ user_profile_enabled: false
|
||||
assert_eq!(user, MemoryKind::User);
|
||||
assert!(serde_json::from_str::<MemoryKind>("\"bogus\"").is_err());
|
||||
}
|
||||
|
||||
// ---- Extended health scan rules ----
|
||||
|
||||
#[test]
|
||||
fn health_check_model_provider_unknown() {
|
||||
let yaml = "\
|
||||
model:
|
||||
default: gpt-4
|
||||
provider: ghost
|
||||
custom_providers:
|
||||
- name: real
|
||||
base_url: https://real.example.com
|
||||
";
|
||||
let warnings = scan_hermes_health_internal(yaml);
|
||||
assert!(warnings.iter().any(|w| w.code == "model_provider_unknown"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_check_model_default_not_in_provider() {
|
||||
let yaml = "\
|
||||
model:
|
||||
default: missing-model
|
||||
provider: real
|
||||
custom_providers:
|
||||
- name: real
|
||||
base_url: https://real.example.com
|
||||
models:
|
||||
present-model: {}
|
||||
";
|
||||
let warnings = scan_hermes_health_internal(yaml);
|
||||
assert!(warnings
|
||||
.iter()
|
||||
.any(|w| w.code == "model_default_not_in_provider"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_check_duplicate_provider_name() {
|
||||
let yaml = "\
|
||||
custom_providers:
|
||||
- name: twin
|
||||
base_url: https://a.example.com
|
||||
- name: twin
|
||||
base_url: https://b.example.com
|
||||
";
|
||||
let warnings = scan_hermes_health_internal(yaml);
|
||||
let dup_count = warnings
|
||||
.iter()
|
||||
.filter(|w| w.code == "duplicate_provider_name")
|
||||
.count();
|
||||
assert_eq!(dup_count, 1, "each duplicate name should be reported once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_check_duplicate_provider_base_url() {
|
||||
let yaml = "\
|
||||
custom_providers:
|
||||
- name: a
|
||||
base_url: https://same.example.com/
|
||||
- name: b
|
||||
base_url: https://SAME.example.com
|
||||
";
|
||||
let warnings = scan_hermes_health_internal(yaml);
|
||||
assert!(warnings
|
||||
.iter()
|
||||
.any(|w| w.code == "duplicate_provider_base_url"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_check_schema_migrated_v12_warning() {
|
||||
let yaml = "\
|
||||
_config_version: 19
|
||||
providers:
|
||||
ds-1:
|
||||
base_url: https://api.deepseek.com
|
||||
";
|
||||
let warnings = scan_hermes_health_internal(yaml);
|
||||
assert!(warnings.iter().any(|w| w.code == "schema_migrated_v12"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_check_schema_migrated_not_reported_when_dict_empty() {
|
||||
// v19 with empty providers: {} should NOT surface the migration warning —
|
||||
// otherwise fresh installs would see it spuriously.
|
||||
let yaml = "\
|
||||
_config_version: 19
|
||||
providers: {}
|
||||
";
|
||||
let warnings = scan_hermes_health_internal(yaml);
|
||||
assert!(!warnings.iter().any(|w| w.code == "schema_migrated_v12"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_check_valid_config_with_matching_model_and_provider() {
|
||||
// Regression guard: none of the new rules should fire on a well-formed config.
|
||||
let yaml = "\
|
||||
model:
|
||||
default: gpt-4
|
||||
provider: openrouter
|
||||
custom_providers:
|
||||
- name: openrouter
|
||||
base_url: https://openrouter.ai/api/v1
|
||||
models:
|
||||
gpt-4: {}
|
||||
";
|
||||
let warnings = scan_hermes_health_internal(yaml);
|
||||
assert!(
|
||||
warnings.is_empty(),
|
||||
"expected no warnings, got: {:?}",
|
||||
warnings
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1264,7 +1264,6 @@ pub fn run() {
|
||||
commands::import_hermes_providers_from_live,
|
||||
commands::get_hermes_live_provider_ids,
|
||||
commands::get_hermes_live_provider,
|
||||
commands::scan_hermes_config_health,
|
||||
commands::get_hermes_model_config,
|
||||
commands::open_hermes_web_ui,
|
||||
commands::launch_hermes_dashboard,
|
||||
|
||||
+1
-14
@@ -41,11 +41,7 @@ import {
|
||||
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
|
||||
import { useProviderActions } from "@/hooks/useProviderActions";
|
||||
import { openclawKeys, useOpenClawHealth } from "@/hooks/useOpenClaw";
|
||||
import {
|
||||
hermesKeys,
|
||||
useHermesHealth,
|
||||
useOpenHermesWebUI,
|
||||
} from "@/hooks/useHermes";
|
||||
import { hermesKeys, useOpenHermesWebUI } from "@/hooks/useHermes";
|
||||
import { hermesApi } from "@/lib/api/hermes";
|
||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||
import { useAutoCompact } from "@/hooks/useAutoCompact";
|
||||
@@ -91,7 +87,6 @@ import EnvPanel from "@/components/openclaw/EnvPanel";
|
||||
import ToolsPanel from "@/components/openclaw/ToolsPanel";
|
||||
import AgentsDefaultsPanel from "@/components/openclaw/AgentsDefaultsPanel";
|
||||
import OpenClawHealthBanner from "@/components/openclaw/OpenClawHealthBanner";
|
||||
import HermesHealthBanner from "@/components/hermes/HermesHealthBanner";
|
||||
import HermesMemoryPanel from "@/components/hermes/HermesMemoryPanel";
|
||||
|
||||
type View =
|
||||
@@ -274,8 +269,6 @@ function App() {
|
||||
currentView === "openclawAgents");
|
||||
const { data: openclawHealthWarnings = [] } =
|
||||
useOpenClawHealth(isOpenClawView);
|
||||
const isHermesView = activeApp === "hermes" && currentView === "providers";
|
||||
const { data: hermesHealthWarnings = [] } = useHermesHealth(isHermesView);
|
||||
const hasSkillsSupport = true;
|
||||
const hasSessionSupport =
|
||||
activeApp === "claude" ||
|
||||
@@ -685,9 +678,6 @@ function App() {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: hermesKeys.liveProviderIds,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: hermesKeys.health,
|
||||
});
|
||||
}
|
||||
toast.success(
|
||||
t("notifications.removeFromConfigSuccess", {
|
||||
@@ -1555,9 +1545,6 @@ function App() {
|
||||
{isOpenClawView && openclawHealthWarnings.length > 0 && (
|
||||
<OpenClawHealthBanner warnings={openclawHealthWarnings} />
|
||||
)}
|
||||
{isHermesView && hermesHealthWarnings.length > 0 && (
|
||||
<HermesHealthBanner warnings={hermesHealthWarnings} />
|
||||
)}
|
||||
{renderContent()}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink, TriangleAlert } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useOpenHermesWebUI } from "@/hooks/useHermes";
|
||||
import type { HermesHealthWarning } from "@/types";
|
||||
|
||||
interface HermesHealthBannerProps {
|
||||
warnings: HermesHealthWarning[];
|
||||
}
|
||||
|
||||
function getWarningText(
|
||||
code: string,
|
||||
fallback: string,
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
) {
|
||||
switch (code) {
|
||||
case "config_parse_failed":
|
||||
return t("hermes.health.parseFailed", {
|
||||
defaultValue:
|
||||
"config.yaml could not be parsed as valid YAML. Fix the file before editing it here.",
|
||||
});
|
||||
case "config_not_found":
|
||||
return t("hermes.health.configNotFound", {
|
||||
defaultValue:
|
||||
"Hermes config.yaml not found. Create it at ~/.hermes/config.yaml or configure the path in settings.",
|
||||
});
|
||||
case "env_parse_failed":
|
||||
return t("hermes.health.envParseFailed", {
|
||||
defaultValue: "The .env file could not be parsed.",
|
||||
});
|
||||
case "model_no_default":
|
||||
return t("hermes.health.modelNoDefault", {
|
||||
defaultValue:
|
||||
"No default model or provider is configured in the 'model' section.",
|
||||
});
|
||||
case "custom_providers_not_list":
|
||||
return t("hermes.health.customProvidersNotList", {
|
||||
defaultValue:
|
||||
"custom_providers should be a YAML list (items prefixed with '-'), not a mapping.",
|
||||
});
|
||||
case "model_provider_unknown":
|
||||
return t("hermes.health.modelProviderUnknown", {
|
||||
defaultValue:
|
||||
"model.provider references a provider that is not configured.",
|
||||
});
|
||||
case "model_default_not_in_provider":
|
||||
return t("hermes.health.modelDefaultNotInProvider", {
|
||||
defaultValue:
|
||||
"model.default is not in the selected provider's models list.",
|
||||
});
|
||||
case "duplicate_provider_name":
|
||||
return t("hermes.health.duplicateProviderName", {
|
||||
defaultValue:
|
||||
"custom_providers contains duplicate provider names — only one entry will be used.",
|
||||
});
|
||||
case "duplicate_provider_base_url":
|
||||
return t("hermes.health.duplicateProviderBaseUrl", {
|
||||
defaultValue:
|
||||
"custom_providers contains duplicate base_urls — possible accidental copy.",
|
||||
});
|
||||
case "schema_migrated_v12":
|
||||
return t("hermes.health.schemaMigratedV12", {
|
||||
defaultValue:
|
||||
"Hermes' newer schema moved some providers into the 'providers:' dict. They are shown read-only in CC Switch — edit or remove those entries via Hermes Web UI.",
|
||||
});
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
const HermesHealthBanner: React.FC<HermesHealthBannerProps> = ({
|
||||
warnings,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const openHermesWebUI = useOpenHermesWebUI();
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
warnings.map((warning) => ({
|
||||
...warning,
|
||||
text: getWarningText(warning.code, warning.message, t),
|
||||
})),
|
||||
[t, warnings],
|
||||
);
|
||||
|
||||
if (warnings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 pt-4">
|
||||
<Alert className="border-amber-500/30 bg-amber-500/5">
|
||||
<TriangleAlert className="h-4 w-4" />
|
||||
<AlertTitle className="flex items-center justify-between gap-2">
|
||||
<span>
|
||||
{t("hermes.health.title", {
|
||||
defaultValue: "Hermes config warnings detected",
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void openHermesWebUI("/config")}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5 mr-1" />
|
||||
{t("hermes.webui.fixInWebUI")}
|
||||
</Button>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
{items.map((warning) => (
|
||||
<li key={`${warning.code}:${warning.path ?? warning.message}`}>
|
||||
{warning.text}
|
||||
{warning.path ? ` (${warning.path})` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HermesHealthBanner;
|
||||
@@ -27,7 +27,6 @@ export const hermesKeys = {
|
||||
all: ["hermes"] as const,
|
||||
liveProviderIds: ["hermes", "liveProviderIds"] as const,
|
||||
modelConfig: ["hermes", "modelConfig"] as const,
|
||||
health: ["hermes", "health"] as const,
|
||||
memory: (kind: HermesMemoryKind) => ["hermes", "memory", kind] as const,
|
||||
memoryLimits: ["hermes", "memoryLimits"] as const,
|
||||
};
|
||||
@@ -41,7 +40,6 @@ export function invalidateHermesProviderCaches(queryClient: QueryClient) {
|
||||
return Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: hermesKeys.liveProviderIds }),
|
||||
queryClient.invalidateQueries({ queryKey: hermesKeys.modelConfig }),
|
||||
queryClient.invalidateQueries({ queryKey: hermesKeys.health }),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -65,15 +63,6 @@ export function useHermesModelConfig(enabled: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useHermesHealth(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: hermesKeys.health,
|
||||
queryFn: () => hermesApi.scanHealth(),
|
||||
staleTime: 30_000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useHermesMemory(kind: HermesMemoryKind, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: hermesKeys.memory(kind),
|
||||
|
||||
@@ -1682,26 +1682,12 @@
|
||||
"open": "Open Hermes Web UI",
|
||||
"offline": "Hermes Web UI is not running. Start it with `hermes dashboard` first.",
|
||||
"openFailed": "Failed to open Hermes Web UI",
|
||||
"fixInWebUI": "Fix in Hermes Web UI",
|
||||
"launchConfirmTitle": "Hermes Dashboard is not running",
|
||||
"launchConfirmMessage": "Open a terminal and start it now with `hermes dashboard`?\n\nThe browser will open automatically once startup completes.\n\nIf the terminal reports that `hermes` cannot be found or the web extras are missing, run first:\npip install hermes-agent[web]",
|
||||
"launchConfirmAction": "Open terminal & launch",
|
||||
"launching": "Started `hermes dashboard` in a terminal.",
|
||||
"launchFailed": "Failed to open terminal"
|
||||
},
|
||||
"health": {
|
||||
"title": "Hermes config warnings detected",
|
||||
"parseFailed": "config.yaml could not be parsed as valid YAML. Fix the file before editing it here.",
|
||||
"configNotFound": "Hermes config.yaml not found. Create it at ~/.hermes/config.yaml or configure the path in settings.",
|
||||
"envParseFailed": "The .env file could not be parsed.",
|
||||
"modelNoDefault": "No default model or provider is configured in the 'model' section.",
|
||||
"customProvidersNotList": "custom_providers should be a YAML list (items prefixed with '-'), not a mapping.",
|
||||
"modelProviderUnknown": "model.provider references a provider that is not configured.",
|
||||
"modelDefaultNotInProvider": "model.default is not in the selected provider's models list.",
|
||||
"duplicateProviderName": "custom_providers contains duplicate provider names — only one entry will be used.",
|
||||
"duplicateProviderBaseUrl": "custom_providers contains duplicate base_urls — possible accidental copy.",
|
||||
"schemaMigratedV12": "Hermes' newer schema moved some providers into the 'providers:' dict. They are shown read-only in CC Switch — edit or remove those entries via Hermes Web UI."
|
||||
},
|
||||
"memory": {
|
||||
"title": "Memory",
|
||||
"agentTab": "Agent Memory (MEMORY.md)",
|
||||
|
||||
@@ -1682,26 +1682,12 @@
|
||||
"open": "Hermes Web UI を開く",
|
||||
"offline": "Hermes Web UI が起動していません。まず `hermes dashboard` を実行してください。",
|
||||
"openFailed": "Hermes Web UI を開けませんでした",
|
||||
"fixInWebUI": "Hermes Web UI で修正",
|
||||
"launchConfirmTitle": "Hermes Dashboard が起動していません",
|
||||
"launchConfirmMessage": "ターミナルを開いて `hermes dashboard` を実行しますか?\n\n起動完了後、ブラウザが自動的に開きます。\n\nターミナルに `hermes` が見つからない、または web 依存が不足するというエラーが表示される場合は、まず次を実行してください:\npip install hermes-agent[web]",
|
||||
"launchConfirmAction": "ターミナルを開いて起動",
|
||||
"launching": "ターミナルで hermes dashboard を起動しました。",
|
||||
"launchFailed": "ターミナルを開けませんでした"
|
||||
},
|
||||
"health": {
|
||||
"title": "Hermes 設定の警告を検出",
|
||||
"parseFailed": "config.yaml を有効な YAML として解析できません。ここで編集する前にファイルを修正してください。",
|
||||
"configNotFound": "Hermes config.yaml が見つかりません。~/.hermes/config.yaml に作成するか、設定でパスを構成してください。",
|
||||
"envParseFailed": ".env ファイルを解析できません。",
|
||||
"modelNoDefault": "'model' セクションに既定モデルまたはプロバイダーが設定されていません。",
|
||||
"customProvidersNotList": "custom_providers はマッピングではなく、YAML リスト('-' で始まる項目)である必要があります。",
|
||||
"modelProviderUnknown": "model.provider が指すプロバイダーは設定に存在しません。",
|
||||
"modelDefaultNotInProvider": "model.default は選択中のプロバイダーのモデル一覧に含まれていません。",
|
||||
"duplicateProviderName": "custom_providers にプロバイダー名の重複があります。1 件のみ有効になります。",
|
||||
"duplicateProviderBaseUrl": "custom_providers に重複した base_url があります。誤ってコピーされた可能性があります。",
|
||||
"schemaMigratedV12": "Hermes の新しいスキーマでは一部のプロバイダーが 'providers:' dict に移動しました。CC Switch では読み取り専用で表示されます。編集・削除は Hermes Web UI から行ってください。"
|
||||
},
|
||||
"memory": {
|
||||
"title": "メモリ",
|
||||
"agentTab": "エージェント記憶 (MEMORY.md)",
|
||||
|
||||
@@ -1682,26 +1682,12 @@
|
||||
"open": "打开 Hermes Web UI",
|
||||
"offline": "Hermes Web UI 未启动,请先运行 `hermes dashboard` 启动服务。",
|
||||
"openFailed": "打开 Hermes Web UI 失败",
|
||||
"fixInWebUI": "在 Hermes Web UI 修复",
|
||||
"launchConfirmTitle": "Hermes Dashboard 未启动",
|
||||
"launchConfirmMessage": "是否打开终端并运行 `hermes dashboard` 启动服务?\n\n启动完成后会自动打开浏览器。\n\n若终端提示找不到 hermes 或缺少 web 依赖,请先运行:\npip install hermes-agent[web]",
|
||||
"launchConfirmAction": "打开终端并启动",
|
||||
"launching": "已在终端启动 hermes dashboard",
|
||||
"launchFailed": "打开终端失败"
|
||||
},
|
||||
"health": {
|
||||
"title": "检测到 Hermes 配置警告",
|
||||
"parseFailed": "config.yaml 无法解析为有效 YAML。请先修复文件再在此编辑。",
|
||||
"configNotFound": "未找到 Hermes config.yaml。请在 ~/.hermes/config.yaml 创建或在设置中配置路径。",
|
||||
"envParseFailed": ".env 文件无法解析。",
|
||||
"modelNoDefault": "model 段未设置默认模型或供应商。",
|
||||
"customProvidersNotList": "custom_providers 应为 YAML 列表(以 `-` 开头),而不是映射。",
|
||||
"modelProviderUnknown": "model.provider 指向的供应商未在配置中找到。",
|
||||
"modelDefaultNotInProvider": "model.default 指向的模型不在所选供应商的模型列表中。",
|
||||
"duplicateProviderName": "custom_providers 中存在重复的供应商名,只会有一条生效。",
|
||||
"duplicateProviderBaseUrl": "custom_providers 中存在重复的 base_url,可能是意外复制。",
|
||||
"schemaMigratedV12": "Hermes 新版 schema 把部分供应商移到了 'providers:' dict。CC Switch 中以只读方式显示,编辑或删除请通过 Hermes Web UI 操作。"
|
||||
},
|
||||
"memory": {
|
||||
"title": "记忆管理",
|
||||
"agentTab": "Agent 记忆 (MEMORY.md)",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
HermesHealthWarning,
|
||||
HermesMemoryKind,
|
||||
HermesMemoryLimits,
|
||||
HermesModelConfig,
|
||||
@@ -12,19 +11,15 @@ import type {
|
||||
* CC Switch intentionally keeps its Hermes surface minimal — deep configuration
|
||||
* (model, agent behavior, env vars, skills, cron, logs, analytics) lives in
|
||||
* the Hermes Web UI at http://127.0.0.1:9119. CC Switch only reads the `model`
|
||||
* section to highlight the active provider, scans config health, and launches
|
||||
* the Hermes Web UI for everything else. Writes to `model` happen implicitly
|
||||
* via `apply_switch_defaults` when the user switches providers.
|
||||
* section to highlight the active provider and launches the Hermes Web UI for
|
||||
* everything else. Writes to `model` happen implicitly via
|
||||
* `apply_switch_defaults` when the user switches providers.
|
||||
*/
|
||||
export const hermesApi = {
|
||||
async getModelConfig(): Promise<HermesModelConfig | null> {
|
||||
return await invoke("get_hermes_model_config");
|
||||
},
|
||||
|
||||
async scanHealth(): Promise<HermesHealthWarning[]> {
|
||||
return await invoke("scan_hermes_config_health");
|
||||
},
|
||||
|
||||
/**
|
||||
* Probe the local Hermes Web UI and open it in the system browser.
|
||||
* Optional `path` lets callers deep-link to specific pages like `/config`.
|
||||
|
||||
@@ -589,17 +589,6 @@ export interface HermesModelConfig {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HermesHealthWarning {
|
||||
code: string;
|
||||
message: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface HermesWriteOutcome {
|
||||
backupPath?: string;
|
||||
warnings: HermesHealthWarning[];
|
||||
}
|
||||
|
||||
export type HermesMemoryKind = "memory" | "user";
|
||||
|
||||
export interface HermesMemoryLimits {
|
||||
|
||||
Reference in New Issue
Block a user